@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 +21 -0
- package/README.md +247 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +188 -0
- package/dist/core/browser.d.ts +5 -0
- package/dist/core/browser.js +43 -0
- package/dist/core/fit.d.ts +11 -0
- package/dist/core/fit.js +69 -0
- package/dist/core/lint.d.ts +2 -0
- package/dist/core/lint.js +188 -0
- package/dist/core/markdown.d.ts +10 -0
- package/dist/core/markdown.js +76 -0
- package/dist/core/photo.d.ts +12 -0
- package/dist/core/photo.js +42 -0
- package/dist/core/render.d.ts +28 -0
- package/dist/core/render.js +104 -0
- package/dist/core/rules.d.ts +20 -0
- package/dist/core/rules.js +53 -0
- package/dist/core/tailor.d.ts +10 -0
- package/dist/core/tailor.js +82 -0
- package/dist/core/variants.d.ts +16 -0
- package/dist/core/variants.js +42 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +130 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +2 -0
- package/dist/ui.d.ts +13 -0
- package/dist/ui.js +16 -0
- package/package.json +66 -0
- package/themes/classic.css +40 -0
- package/themes/compact.css +30 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { resolve, basename, dirname, join } from "node:path";
|
|
3
|
+
/** Start a tailored copy from a master file. It only copies; trimming is up to you or the agent. */
|
|
4
|
+
export function newVariant(master, name) {
|
|
5
|
+
const src = resolve(master);
|
|
6
|
+
if (!existsSync(src))
|
|
7
|
+
throw new Error(`Master file not found: ${master}`);
|
|
8
|
+
const dest = join(dirname(src), `cv-${name}.md`);
|
|
9
|
+
if (existsSync(dest))
|
|
10
|
+
throw new Error(`Already exists: ${dest}`);
|
|
11
|
+
const raw = readFileSync(src, "utf8").replace(/^<!--[\s\S]*?-->\n*/, "");
|
|
12
|
+
writeFileSync(dest, raw, "utf8");
|
|
13
|
+
return dest;
|
|
14
|
+
}
|
|
15
|
+
export function listVariants(dir) {
|
|
16
|
+
return readdirSync(resolve(dir))
|
|
17
|
+
.filter((f) => f.endsWith(".md"))
|
|
18
|
+
.map((f) => {
|
|
19
|
+
const raw = readFileSync(join(resolve(dir), f), "utf8");
|
|
20
|
+
const lines = raw.split("\n");
|
|
21
|
+
return {
|
|
22
|
+
file: f,
|
|
23
|
+
name: basename(f, ".md"),
|
|
24
|
+
lines: lines.length,
|
|
25
|
+
bullets: lines.filter((l) => /^\s*[*-]\s+/.test(l)).length,
|
|
26
|
+
sections: lines.filter((l) => l.startsWith("## ")).map((l) => l.slice(3).trim()),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** Compare two versions: which bullets exist on only one side. */
|
|
31
|
+
export function diffVariants(a, b) {
|
|
32
|
+
const read = (p) => readFileSync(resolve(p), "utf8").split("\n")
|
|
33
|
+
.filter((l) => /^\s*[*-]\s+/.test(l))
|
|
34
|
+
.map((l) => l.replace(/^\s*[*-]\s+/, "").replace(/\*\*/g, "").trim());
|
|
35
|
+
const A = read(a), B = read(b);
|
|
36
|
+
const setB = new Set(B), setA = new Set(A);
|
|
37
|
+
return {
|
|
38
|
+
onlyInA: A.filter((l) => !setB.has(l)),
|
|
39
|
+
onlyInB: B.filter((l) => !setA.has(l)),
|
|
40
|
+
shared: A.filter((l) => setB.has(l)).length,
|
|
41
|
+
};
|
|
42
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { render, loadTheme, pageBoxFromCss } from "./core/render.js";
|
|
2
|
+
export { analyseFit } from "./core/fit.js";
|
|
3
|
+
export { lint } from "./core/lint.js";
|
|
4
|
+
export { tailor } from "./core/tailor.js";
|
|
5
|
+
export { newVariant, listVariants, diffVariants } from "./core/variants.js";
|
|
6
|
+
export { parseCv, renderBody, buildHtml } from "./core/markdown.js";
|
|
7
|
+
export { photoBlock } from "./core/photo.js";
|
|
8
|
+
export { findChrome } from "./core/browser.js";
|
|
9
|
+
export * from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { render, loadTheme, pageBoxFromCss } from "./core/render.js";
|
|
2
|
+
export { analyseFit } from "./core/fit.js";
|
|
3
|
+
export { lint } from "./core/lint.js";
|
|
4
|
+
export { tailor } from "./core/tailor.js";
|
|
5
|
+
export { newVariant, listVariants, diffVariants } from "./core/variants.js";
|
|
6
|
+
export { parseCv, renderBody, buildHtml } from "./core/markdown.js";
|
|
7
|
+
export { photoBlock } from "./core/photo.js";
|
|
8
|
+
export { findChrome } from "./core/browser.js";
|
|
9
|
+
export * from "./types.js";
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { resolve, join } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { render } from "../core/render.js";
|
|
9
|
+
import { analyseFit } from "../core/fit.js";
|
|
10
|
+
import { lint } from "../core/lint.js";
|
|
11
|
+
import { tailor } from "../core/tailor.js";
|
|
12
|
+
import { newVariant, listVariants, diffVariants } from "../core/variants.js";
|
|
13
|
+
const server = new McpServer({ name: "markcv", version: "0.1.0" });
|
|
14
|
+
/** Every tool returns JSON so an agent can loop on the result, rather than prose for a human. */
|
|
15
|
+
const json = (data) => ({
|
|
16
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
17
|
+
});
|
|
18
|
+
const fail = (message) => ({
|
|
19
|
+
content: [{ type: "text", text: JSON.stringify({ error: message }, null, 2) }],
|
|
20
|
+
isError: true,
|
|
21
|
+
});
|
|
22
|
+
const mdFile = z.string().describe("Path to the CV markdown file");
|
|
23
|
+
server.registerTool("render_cv", {
|
|
24
|
+
title: "Build a PDF from a markdown CV",
|
|
25
|
+
description: "Render the CV to PDF (and HTML). Returns the real page count plus layout numbers to verify against.",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
file: mdFile,
|
|
28
|
+
pdf: z.string().optional().describe("Output PDF path; defaults to the .md path with a .pdf extension"),
|
|
29
|
+
theme: z.enum(["classic", "compact"]).optional(),
|
|
30
|
+
target_pages: z.number().int().min(1).max(10).optional(),
|
|
31
|
+
},
|
|
32
|
+
}, async ({ file, pdf, theme, target_pages }) => {
|
|
33
|
+
if (!existsSync(resolve(file)))
|
|
34
|
+
return fail(`File not found: ${file}`);
|
|
35
|
+
const res = await render({
|
|
36
|
+
input: file,
|
|
37
|
+
pdf: pdf ?? file.replace(/\.md$/, ".pdf"),
|
|
38
|
+
theme,
|
|
39
|
+
targetPages: target_pages,
|
|
40
|
+
});
|
|
41
|
+
const fit = analyseFit(res.measure, res.pageBox, res.pdfPages ?? 0, target_pages);
|
|
42
|
+
return json({
|
|
43
|
+
pdf: res.pdfPath,
|
|
44
|
+
pages: res.pdfPages,
|
|
45
|
+
photo: res.photoFile,
|
|
46
|
+
photo_warning: res.photoWarning,
|
|
47
|
+
fit,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
server.registerTool("check_fit", {
|
|
51
|
+
title: "Why the CV does not fit",
|
|
52
|
+
description: "Separates two very different causes: content that is too long, versus bad page breaks. " +
|
|
53
|
+
"Returns the overflow, the blocks pushed onto a new page, the pixels wasted, and concrete suggestions.",
|
|
54
|
+
inputSchema: {
|
|
55
|
+
file: mdFile,
|
|
56
|
+
target_pages: z.number().int().min(1).max(10).default(2),
|
|
57
|
+
theme: z.enum(["classic", "compact"]).optional(),
|
|
58
|
+
},
|
|
59
|
+
}, async ({ file, target_pages, theme }) => {
|
|
60
|
+
if (!existsSync(resolve(file)))
|
|
61
|
+
return fail(`File not found: ${file}`);
|
|
62
|
+
const tmpPdf = join(tmpdir(), `markcv-mcp-${Date.now()}.pdf`);
|
|
63
|
+
const res = await render({ input: file, pdf: tmpPdf, theme, targetPages: target_pages });
|
|
64
|
+
return json(analyseFit(res.measure, res.pageBox, res.pdfPages ?? 0, target_pages));
|
|
65
|
+
});
|
|
66
|
+
server.registerTool("lint_cv", {
|
|
67
|
+
title: "Audit CV content",
|
|
68
|
+
description: "Finds unverifiable self-praise, UNDERSTATED claims relative to the real work, present-tense " +
|
|
69
|
+
"bullets on finished jobs, skills with no supporting evidence, duplicate bullets, and IC/manager role mismatch.",
|
|
70
|
+
inputSchema: { file: mdFile },
|
|
71
|
+
}, async ({ file }) => {
|
|
72
|
+
if (!existsSync(resolve(file)))
|
|
73
|
+
return fail(`File not found: ${file}`);
|
|
74
|
+
const findings = lint(readFileSync(resolve(file), "utf8"));
|
|
75
|
+
return json({
|
|
76
|
+
total: findings.length,
|
|
77
|
+
errors: findings.filter((f) => f.severity === "error").length,
|
|
78
|
+
warnings: findings.filter((f) => f.severity === "warn").length,
|
|
79
|
+
findings,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
server.registerTool("tailor_to_jd", {
|
|
83
|
+
title: "Compare the CV against a job description",
|
|
84
|
+
description: "Points out JD requirements with no evidence, keywords that live only under SKILLS, and unrelated " +
|
|
85
|
+
"bullets (cut those first, before cutting anything valuable).",
|
|
86
|
+
inputSchema: {
|
|
87
|
+
file: mdFile,
|
|
88
|
+
jd_file: z.string().optional().describe("Path to a job-description file"),
|
|
89
|
+
jd_text: z.string().optional().describe("Job description pasted inline"),
|
|
90
|
+
},
|
|
91
|
+
}, async ({ file, jd_file, jd_text }) => {
|
|
92
|
+
if (!existsSync(resolve(file)))
|
|
93
|
+
return fail(`File not found: ${file}`);
|
|
94
|
+
const jd = jd_text ?? (jd_file && existsSync(resolve(jd_file)) ? readFileSync(resolve(jd_file), "utf8") : "");
|
|
95
|
+
if (!jd.trim())
|
|
96
|
+
return fail("Either jd_file or jd_text is required");
|
|
97
|
+
return json(tailor(readFileSync(resolve(file), "utf8"), jd));
|
|
98
|
+
});
|
|
99
|
+
server.registerTool("list_variants", {
|
|
100
|
+
title: "List CV versions",
|
|
101
|
+
description: "Every .md file in a folder with its line count, bullet count and section list.",
|
|
102
|
+
inputSchema: { dir: z.string().default(".") },
|
|
103
|
+
}, async ({ dir }) => {
|
|
104
|
+
if (!existsSync(resolve(dir)))
|
|
105
|
+
return fail(`Directory not found: ${dir}`);
|
|
106
|
+
return json(listVariants(dir));
|
|
107
|
+
});
|
|
108
|
+
server.registerTool("new_variant", {
|
|
109
|
+
title: "Start a tailored version",
|
|
110
|
+
description: "Copy a master CV to cv-<name>.md so it can be trimmed for one specific role.",
|
|
111
|
+
inputSchema: { master: mdFile, name: z.string().regex(/^[a-z0-9-]+$/) },
|
|
112
|
+
}, async ({ master, name }) => {
|
|
113
|
+
try {
|
|
114
|
+
return json({ created: newVariant(master, name) });
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
return fail(e.message);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
server.registerTool("diff_variants", {
|
|
121
|
+
title: "Compare two CV versions",
|
|
122
|
+
description: "Which bullets exist on only one side — use it to see what a tailored copy dropped.",
|
|
123
|
+
inputSchema: { a: mdFile, b: mdFile },
|
|
124
|
+
}, async ({ a, b }) => {
|
|
125
|
+
if (!existsSync(resolve(a)) || !existsSync(resolve(b)))
|
|
126
|
+
return fail("One of the two files was not found");
|
|
127
|
+
return json(diffVariants(a, b));
|
|
128
|
+
});
|
|
129
|
+
const transport = new StdioServerTransport();
|
|
130
|
+
await server.connect(transport);
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Types shared by the CLI and the MCP server. */
|
|
2
|
+
export interface RenderOptions {
|
|
3
|
+
/** Source markdown file. */
|
|
4
|
+
input: string;
|
|
5
|
+
/** Output PDF path. Omit to only produce HTML. */
|
|
6
|
+
pdf?: string;
|
|
7
|
+
/** Output HTML path. Omit to use a temp file. */
|
|
8
|
+
html?: string;
|
|
9
|
+
/** Theme name under themes/, defaults to "classic". */
|
|
10
|
+
theme?: string;
|
|
11
|
+
/** Portrait photo. Omit to auto-detect photo.* next to the markdown file. */
|
|
12
|
+
photo?: string | null;
|
|
13
|
+
/** Target page count, used to report pass/fail. */
|
|
14
|
+
targetPages?: number;
|
|
15
|
+
}
|
|
16
|
+
/** A block pushed onto a new page, with the gap it left behind. */
|
|
17
|
+
export interface CulpritBlock {
|
|
18
|
+
title: string;
|
|
19
|
+
tag: string;
|
|
20
|
+
/** Vertical position in the content flow (px). */
|
|
21
|
+
top: number;
|
|
22
|
+
/** Empty space left at the bottom of the previous page (px). */
|
|
23
|
+
wastedPx: number;
|
|
24
|
+
page: number;
|
|
25
|
+
}
|
|
26
|
+
export interface FitReport {
|
|
27
|
+
pages: number;
|
|
28
|
+
targetPages?: number;
|
|
29
|
+
fits: boolean;
|
|
30
|
+
/** Real content height (px, measured at the printable width). */
|
|
31
|
+
contentHeight: number;
|
|
32
|
+
/** Usable height of a single page (px). */
|
|
33
|
+
usablePerPage: number;
|
|
34
|
+
/** Space left over; negative means the content is too long. */
|
|
35
|
+
slackPx: number;
|
|
36
|
+
/** Whether the content would fit if no block were pushed to a new page. */
|
|
37
|
+
contentWouldFit: boolean;
|
|
38
|
+
/** Pixels wasted by blocks pushed onto a new page. */
|
|
39
|
+
wastedByBreaksPx: number;
|
|
40
|
+
culprits: CulpritBlock[];
|
|
41
|
+
/** Suggested actions, most effective first. */
|
|
42
|
+
suggestions: string[];
|
|
43
|
+
}
|
|
44
|
+
export type Severity = "error" | "warn" | "info";
|
|
45
|
+
export interface LintFinding {
|
|
46
|
+
rule: string;
|
|
47
|
+
severity: Severity;
|
|
48
|
+
line: number;
|
|
49
|
+
/** The text that triggered this finding. */
|
|
50
|
+
excerpt: string;
|
|
51
|
+
message: string;
|
|
52
|
+
suggestion?: string;
|
|
53
|
+
}
|
|
54
|
+
export interface TailorReport {
|
|
55
|
+
/** JD requirements with no evidence anywhere in the CV. */
|
|
56
|
+
missing: {
|
|
57
|
+
keyword: string;
|
|
58
|
+
hint: string;
|
|
59
|
+
}[];
|
|
60
|
+
/** Keywords that appear only under SKILLS, with no experience backing them. */
|
|
61
|
+
unsupported: {
|
|
62
|
+
keyword: string;
|
|
63
|
+
hint: string;
|
|
64
|
+
}[];
|
|
65
|
+
/** Bullets unrelated to the JD - cut these before cutting anything valuable. */
|
|
66
|
+
irrelevant: {
|
|
67
|
+
line: number;
|
|
68
|
+
excerpt: string;
|
|
69
|
+
}[];
|
|
70
|
+
/** JD keywords the CV genuinely backs up. */
|
|
71
|
+
covered: {
|
|
72
|
+
keyword: string;
|
|
73
|
+
evidenceLine: number;
|
|
74
|
+
}[];
|
|
75
|
+
score: number;
|
|
76
|
+
}
|
|
77
|
+
export interface CvDoc {
|
|
78
|
+
/** Raw markdown. */
|
|
79
|
+
raw: string;
|
|
80
|
+
/** Lines, for line-number lookups. */
|
|
81
|
+
lines: string[];
|
|
82
|
+
/** Level-2 sections in document order. */
|
|
83
|
+
sections: {
|
|
84
|
+
title: string;
|
|
85
|
+
start: number;
|
|
86
|
+
end: number;
|
|
87
|
+
}[];
|
|
88
|
+
}
|
package/dist/types.js
ADDED
package/dist/ui.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const c: {
|
|
2
|
+
bold: (s: string | number) => string;
|
|
3
|
+
dim: (s: string | number) => string;
|
|
4
|
+
red: (s: string | number) => string;
|
|
5
|
+
green: (s: string | number) => string;
|
|
6
|
+
yellow: (s: string | number) => string;
|
|
7
|
+
blue: (s: string | number) => string;
|
|
8
|
+
cyan: (s: string | number) => string;
|
|
9
|
+
};
|
|
10
|
+
export declare const ok: (s: string) => string;
|
|
11
|
+
export declare const bad: (s: string) => string;
|
|
12
|
+
export declare const warn: (s: string) => string;
|
|
13
|
+
export declare const info: (s: string) => string;
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Terminal colours, disabled automatically when output is piped elsewhere. */
|
|
2
|
+
const on = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
3
|
+
const wrap = (code) => (s) => (on ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
4
|
+
export const c = {
|
|
5
|
+
bold: wrap("1"),
|
|
6
|
+
dim: wrap("2"),
|
|
7
|
+
red: wrap("31"),
|
|
8
|
+
green: wrap("32"),
|
|
9
|
+
yellow: wrap("33"),
|
|
10
|
+
blue: wrap("34"),
|
|
11
|
+
cyan: wrap("36"),
|
|
12
|
+
};
|
|
13
|
+
export const ok = (s) => `${c.green("✓")} ${s}`;
|
|
14
|
+
export const bad = (s) => `${c.red("✗")} ${s}`;
|
|
15
|
+
export const warn = (s) => `${c.yellow("!")} ${s}`;
|
|
16
|
+
export const info = (s) => `${c.dim("·")} ${s}`;
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@phuthuycoding/markcv",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Fit, lint and build a CV from Markdown - CLI + MCP server for AI agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"markcv": "dist/cli.js",
|
|
8
|
+
"markcv-mcp": "dist/mcp/server.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"themes",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"dev": "tsc --watch",
|
|
26
|
+
"mcp": "node dist/mcp/server.js",
|
|
27
|
+
"test": "node --test test/*.test.js",
|
|
28
|
+
"prepublishOnly": "npm run build",
|
|
29
|
+
"prepare": "npm run build"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"cv",
|
|
33
|
+
"resume",
|
|
34
|
+
"markdown",
|
|
35
|
+
"pdf",
|
|
36
|
+
"mcp",
|
|
37
|
+
"ats",
|
|
38
|
+
"lint",
|
|
39
|
+
"page-fit"
|
|
40
|
+
],
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
44
|
+
"commander": "^12.1.0",
|
|
45
|
+
"markdown-it": "^14.1.0",
|
|
46
|
+
"puppeteer-core": "^23.10.0",
|
|
47
|
+
"zod": "^3.23.8"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/markdown-it": "^14.1.2",
|
|
51
|
+
"@types/node": "^22.10.0",
|
|
52
|
+
"typescript": "^5.7.2"
|
|
53
|
+
},
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/phuthuycoding/markcv.git"
|
|
57
|
+
},
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/phuthuycoding/markcv/issues"
|
|
60
|
+
},
|
|
61
|
+
"homepage": "https://github.com/phuthuycoding/markcv#readme",
|
|
62
|
+
"author": "phuthuycoding",
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* classic - the default. Single column, no emoji, real text layer: safe for ATS parsers. */
|
|
2
|
+
@page { size: A4; margin: 11mm 13mm; }
|
|
3
|
+
* { box-sizing: border-box; }
|
|
4
|
+
body {
|
|
5
|
+
font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
6
|
+
font-size: 9pt; line-height: 1.36; color: #1b1b1b; margin: 0;
|
|
7
|
+
-webkit-font-smoothing: antialiased;
|
|
8
|
+
}
|
|
9
|
+
.header { position: relative; min-height: var(--photo-h, 0); padding-right: var(--photo-gutter, 0); }
|
|
10
|
+
.photo {
|
|
11
|
+
position: absolute; top: 0; right: 0;
|
|
12
|
+
width: var(--photo-w, 30mm); height: var(--photo-h, 40mm);
|
|
13
|
+
object-fit: cover; border-radius: 2px;
|
|
14
|
+
}
|
|
15
|
+
.photo-empty {
|
|
16
|
+
position: absolute; top: 0; right: 0;
|
|
17
|
+
width: var(--photo-w, 30mm); height: var(--photo-h, 40mm);
|
|
18
|
+
border: 1px dashed #c2c2c2; border-radius: 2px; background: #fafafa;
|
|
19
|
+
display: flex; align-items: center; justify-content: center;
|
|
20
|
+
color: #a8a8a8; font-size: 7pt; letter-spacing: .04em; text-align: center;
|
|
21
|
+
}
|
|
22
|
+
h1 { font-size: 19pt; font-weight: 700; letter-spacing: -.01em; margin: 0 0 5px; color: #0f0f0f; }
|
|
23
|
+
h2 {
|
|
24
|
+
font-size: 9.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: .09em;
|
|
25
|
+
color: #0f0f0f; border-bottom: 1.3px solid #2b2b2b;
|
|
26
|
+
padding-bottom: 2px; margin: 9px 0 5px; break-after: avoid;
|
|
27
|
+
}
|
|
28
|
+
h3 { font-size: 10pt; font-weight: 700; color: #111; margin: 6px 0 1px; break-after: avoid; }
|
|
29
|
+
p { margin: 0 0 2px; }
|
|
30
|
+
.contacts { margin-bottom: 2px; }
|
|
31
|
+
p.contact { white-space: nowrap; font-size: 8.6pt; color: #333; margin: 0; line-height: 1.5; }
|
|
32
|
+
p.meta { font-size: 8.8pt; color: #444; margin: 0 0 3px; }
|
|
33
|
+
ul { margin: 2px 0 4px; padding-left: 14px; }
|
|
34
|
+
ul ul { margin: 1px 0; }
|
|
35
|
+
li { margin-bottom: 1.4px; break-inside: avoid; }
|
|
36
|
+
strong { font-weight: 600; color: #000; }
|
|
37
|
+
em { font-style: italic; }
|
|
38
|
+
code { font-family: "SF Mono", Menlo, monospace; font-size: 8.8pt; color: #222; }
|
|
39
|
+
hr { display: none; }
|
|
40
|
+
a { color: inherit; text-decoration: none; }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* compact - same as classic but tighter, for squeezing into fewer pages. */
|
|
2
|
+
@page { size: A4; margin: 10mm 12mm; }
|
|
3
|
+
* { box-sizing: border-box; }
|
|
4
|
+
body {
|
|
5
|
+
font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
6
|
+
font-size: 8.6pt; line-height: 1.32; color: #1b1b1b; margin: 0;
|
|
7
|
+
}
|
|
8
|
+
.header { position: relative; min-height: var(--photo-h, 0); padding-right: var(--photo-gutter, 0); }
|
|
9
|
+
.photo { position: absolute; top: 0; right: 0; width: var(--photo-w, 28mm); height: var(--photo-h, 37mm); object-fit: cover; }
|
|
10
|
+
.photo-empty {
|
|
11
|
+
position: absolute; top: 0; right: 0; width: var(--photo-w, 28mm); height: var(--photo-h, 37mm);
|
|
12
|
+
border: 1px dashed #c2c2c2; background: #fafafa; display: flex; align-items: center;
|
|
13
|
+
justify-content: center; color: #a8a8a8; font-size: 6.5pt;
|
|
14
|
+
}
|
|
15
|
+
h1 { font-size: 17pt; font-weight: 700; margin: 0 0 4px; }
|
|
16
|
+
h2 {
|
|
17
|
+
font-size: 9pt; font-weight: 700; text-transform: uppercase; letter-spacing: .08em;
|
|
18
|
+
border-bottom: 1.2px solid #2b2b2b; padding-bottom: 2px; margin: 7px 0 4px; break-after: avoid;
|
|
19
|
+
}
|
|
20
|
+
h3 { font-size: 9.4pt; font-weight: 700; margin: 5px 0 1px; break-after: avoid; }
|
|
21
|
+
p { margin: 0 0 2px; }
|
|
22
|
+
.contacts { margin-bottom: 2px; }
|
|
23
|
+
p.contact { white-space: nowrap; font-size: 8.2pt; color: #333; margin: 0; line-height: 1.45; }
|
|
24
|
+
p.meta { font-size: 8.4pt; color: #444; margin: 0 0 2px; }
|
|
25
|
+
ul { margin: 2px 0 3px; padding-left: 13px; }
|
|
26
|
+
li { margin-bottom: 1px; break-inside: avoid; }
|
|
27
|
+
strong { font-weight: 600; color: #000; }
|
|
28
|
+
code { font-family: "SF Mono", Menlo, monospace; font-size: 8.4pt; }
|
|
29
|
+
hr { display: none; }
|
|
30
|
+
a { color: inherit; text-decoration: none; }
|