md-2-ats 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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +238 -0
- package/dist/cli.js +935 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +814 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +208 -0
- package/dist/index.d.ts +208 -0
- package/dist/index.js +768 -0
- package/dist/index.js.map +1 -0
- package/package.json +86 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
// src/page/config.ts
|
|
2
|
+
var A4 = Object.freeze({ width: 595.28, height: 841.89 });
|
|
3
|
+
function contentWidth(page) {
|
|
4
|
+
return page.size.width - page.margin.left - page.margin.right;
|
|
5
|
+
}
|
|
6
|
+
function contentHeight(page) {
|
|
7
|
+
return page.size.height - page.margin.top - page.margin.bottom;
|
|
8
|
+
}
|
|
9
|
+
function createA4(margin = {}) {
|
|
10
|
+
const marginValue = {
|
|
11
|
+
top: 0,
|
|
12
|
+
right: 0,
|
|
13
|
+
bottom: 0,
|
|
14
|
+
left: 0,
|
|
15
|
+
...margin
|
|
16
|
+
};
|
|
17
|
+
return { size: { ...A4 }, margin: marginValue };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/page/flow.ts
|
|
21
|
+
function createFlow(page) {
|
|
22
|
+
const cursor = { x: 0, y: 0 };
|
|
23
|
+
let pageNumber = 0;
|
|
24
|
+
return {
|
|
25
|
+
get page() {
|
|
26
|
+
return page;
|
|
27
|
+
},
|
|
28
|
+
get cursor() {
|
|
29
|
+
return cursor;
|
|
30
|
+
},
|
|
31
|
+
get pageNumber() {
|
|
32
|
+
return pageNumber;
|
|
33
|
+
},
|
|
34
|
+
contentWidth: () => contentWidth(page),
|
|
35
|
+
contentHeight: () => contentHeight(page),
|
|
36
|
+
isLastPageEmpty: () => cursor.y === 0,
|
|
37
|
+
needNewPage: (dy) => cursor.y + dy >= contentHeight(page),
|
|
38
|
+
newPage: () => {
|
|
39
|
+
pageNumber += 1;
|
|
40
|
+
cursor.x = 0;
|
|
41
|
+
cursor.y = 0;
|
|
42
|
+
},
|
|
43
|
+
advance: (dy) => {
|
|
44
|
+
cursor.y += dy;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/parser/index.ts
|
|
50
|
+
import { marked } from "marked";
|
|
51
|
+
function parseMarkdown(markdown) {
|
|
52
|
+
const tokens = marked.lexer(markdown);
|
|
53
|
+
const blocks = [];
|
|
54
|
+
for (const token of tokens) {
|
|
55
|
+
const block = parseBlock(token);
|
|
56
|
+
if (block) {
|
|
57
|
+
blocks.push(block);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { blocks };
|
|
61
|
+
}
|
|
62
|
+
function parseBlock(token) {
|
|
63
|
+
switch (token.type) {
|
|
64
|
+
case "heading": {
|
|
65
|
+
const heading = token;
|
|
66
|
+
const level = clampHeadingLevel(heading.depth);
|
|
67
|
+
return { type: "heading", level, children: parseInline(heading.tokens) };
|
|
68
|
+
}
|
|
69
|
+
case "paragraph":
|
|
70
|
+
return { type: "paragraph", children: parseInline(token.tokens ?? []) };
|
|
71
|
+
case "list":
|
|
72
|
+
return parseList(token);
|
|
73
|
+
case "hr":
|
|
74
|
+
return { type: "thematicBreak" };
|
|
75
|
+
case "blockquote":
|
|
76
|
+
return parseBlockquote(token);
|
|
77
|
+
case "space":
|
|
78
|
+
return null;
|
|
79
|
+
default:
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function parseBlockquote(token) {
|
|
84
|
+
const children = [];
|
|
85
|
+
for (const block of token.tokens ?? []) {
|
|
86
|
+
if (block.type === "paragraph") {
|
|
87
|
+
if (children.length > 0) {
|
|
88
|
+
children.push({ type: "text", text: " " });
|
|
89
|
+
}
|
|
90
|
+
children.push(...parseInline(block.tokens ?? []));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { type: "blockquote", children };
|
|
94
|
+
}
|
|
95
|
+
function parseList(token) {
|
|
96
|
+
return {
|
|
97
|
+
type: "list",
|
|
98
|
+
ordered: token.ordered,
|
|
99
|
+
items: token.items.map(parseListItem)
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function parseListItem(item) {
|
|
103
|
+
const children = [];
|
|
104
|
+
const nested = [];
|
|
105
|
+
for (const block of item.tokens ?? []) {
|
|
106
|
+
if (block.type === "list") {
|
|
107
|
+
nested.push(parseList(block));
|
|
108
|
+
} else if ("tokens" in block && block.tokens) {
|
|
109
|
+
children.push(...parseInline(block.tokens));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { children, ...nested.length > 0 ? { nested } : {} };
|
|
113
|
+
}
|
|
114
|
+
function clampHeadingLevel(depth) {
|
|
115
|
+
return Math.min(Math.max(depth, 1), 3);
|
|
116
|
+
}
|
|
117
|
+
function parseInline(tokens) {
|
|
118
|
+
const nodes = [];
|
|
119
|
+
for (const token of tokens) {
|
|
120
|
+
switch (token.type) {
|
|
121
|
+
case "text": {
|
|
122
|
+
const text = token;
|
|
123
|
+
nodes.push({ type: "text", text: text.text });
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
case "strong": {
|
|
127
|
+
const strong = token;
|
|
128
|
+
nodes.push({ type: "strong", children: parseInline(strong.tokens) });
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case "em": {
|
|
132
|
+
const em = token;
|
|
133
|
+
nodes.push({ type: "emphasis", children: parseInline(em.tokens) });
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case "link": {
|
|
137
|
+
const link = token;
|
|
138
|
+
nodes.push({ type: "link", text: link.text, href: link.href });
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "image": {
|
|
142
|
+
const image = token;
|
|
143
|
+
nodes.push({ type: "image", href: image.href });
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case "codespan": {
|
|
147
|
+
const codespan = token;
|
|
148
|
+
nodes.push({ type: "text", text: codespan.text });
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return nodes;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/validator/index.ts
|
|
157
|
+
import { marked as marked2 } from "marked";
|
|
158
|
+
var EMAIL_REGEX = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;
|
|
159
|
+
var RAW_EMAIL_REGEX = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i;
|
|
160
|
+
function validateCV(markdown) {
|
|
161
|
+
const doc = parseMarkdown(markdown);
|
|
162
|
+
if (doc.blocks.length === 0) {
|
|
163
|
+
return { issues: [{ severity: "error", message: "Document is empty." }], valid: false };
|
|
164
|
+
}
|
|
165
|
+
const issues = [];
|
|
166
|
+
const hasName = doc.blocks.some((b) => b.type === "heading" && b.level === 1);
|
|
167
|
+
if (!hasName) {
|
|
168
|
+
issues.push({
|
|
169
|
+
severity: "error",
|
|
170
|
+
message: 'Missing a level-1 heading. Add "# Your Name" for the candidate name.'
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
validateContact(doc, issues);
|
|
174
|
+
validateHierarchy(doc, issues);
|
|
175
|
+
validateSection(doc, "experience", issues);
|
|
176
|
+
validateSection(doc, "education", issues);
|
|
177
|
+
detectUnsupported(markdown, issues);
|
|
178
|
+
return { issues, valid: issues.every((i) => i.severity !== "error") };
|
|
179
|
+
}
|
|
180
|
+
function validateContact(doc, issues) {
|
|
181
|
+
const texts = flattenText(doc);
|
|
182
|
+
const links = flattenLinks(doc);
|
|
183
|
+
const emails = /* @__PURE__ */ new Set();
|
|
184
|
+
for (const t of texts) {
|
|
185
|
+
const m = t.match(RAW_EMAIL_REGEX);
|
|
186
|
+
if (m) emails.add(m[0]);
|
|
187
|
+
}
|
|
188
|
+
for (const href of links) {
|
|
189
|
+
if (href.startsWith("mailto:")) emails.add(href.slice("mailto:".length).split("?")[0] ?? "");
|
|
190
|
+
}
|
|
191
|
+
const validEmails = [...emails].filter((e) => EMAIL_REGEX.test(e));
|
|
192
|
+
const hasContactLink = links.some((href) => {
|
|
193
|
+
const lower = href.toLowerCase();
|
|
194
|
+
return /mailto:|github\.com|linkedin\.com|twitter\.com|x\.com|dev\.to|t\.me/.test(lower);
|
|
195
|
+
});
|
|
196
|
+
if (validEmails.length === 0 && !hasContactLink) {
|
|
197
|
+
issues.push({
|
|
198
|
+
severity: "warning",
|
|
199
|
+
message: "No contact info found. Add an email or a contact link (e.g. mailto:, github, linkedin)."
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if ([...emails].some((e) => !EMAIL_REGEX.test(e))) {
|
|
203
|
+
issues.push({
|
|
204
|
+
severity: "warning",
|
|
205
|
+
message: `Invalid email address found: "${[...emails].filter((e) => !EMAIL_REGEX.test(e)).join(", ")}".`
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function validateHierarchy(doc, issues) {
|
|
210
|
+
let prevLevel = 0;
|
|
211
|
+
doc.blocks.forEach((block, index) => {
|
|
212
|
+
if (block.type !== "heading") return;
|
|
213
|
+
if (prevLevel !== 0 && block.level > prevLevel + 1) {
|
|
214
|
+
issues.push({
|
|
215
|
+
severity: "warning",
|
|
216
|
+
blockIndex: index,
|
|
217
|
+
message: `Heading level skipped from H${prevLevel} to H${block.level}. Prefer H1 \u2192 H2 \u2192 H3 order.`
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
prevLevel = block.level;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
function validateSection(doc, sectionName, issues) {
|
|
224
|
+
const headings = [];
|
|
225
|
+
doc.blocks.forEach((block, index) => {
|
|
226
|
+
if (block.type === "heading") headings.push({ block, index });
|
|
227
|
+
});
|
|
228
|
+
const target = headings.find(
|
|
229
|
+
({ block }) => block.level === 2 && headingText(block).toLowerCase() === sectionName
|
|
230
|
+
);
|
|
231
|
+
if (!target) {
|
|
232
|
+
issues.push({
|
|
233
|
+
severity: "warning",
|
|
234
|
+
message: `Section "${sectionName}" not found. Add "## ${sectionName}" heading.`
|
|
235
|
+
});
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const body = [];
|
|
239
|
+
const nextSection = doc.blocks.slice(target.index + 1).findIndex((b) => b.type === "heading" && b.level === 2);
|
|
240
|
+
const end = nextSection === -1 ? doc.blocks.length : target.index + 1 + nextSection;
|
|
241
|
+
for (let k = target.index + 1; k < end; k++) {
|
|
242
|
+
const block = doc.blocks[k];
|
|
243
|
+
if (block) body.push({ block, index: k });
|
|
244
|
+
}
|
|
245
|
+
const hasBody = body.some(({ block }) => block.type !== "heading" && block.type !== "thematicBreak");
|
|
246
|
+
if (!hasBody) {
|
|
247
|
+
issues.push({
|
|
248
|
+
severity: "warning",
|
|
249
|
+
blockIndex: target.index,
|
|
250
|
+
message: `Section "${sectionName}" has no content below its heading.`
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const entries = body.filter(
|
|
255
|
+
(s) => s.block.type === "heading" && s.block.level === 3
|
|
256
|
+
);
|
|
257
|
+
entries.forEach(({ block, index }) => {
|
|
258
|
+
const hasContent = body.filter((s) => s.index > index).some(({ block: b }) => b.type !== "heading" && b.type !== "thematicBreak");
|
|
259
|
+
if (!hasContent) {
|
|
260
|
+
issues.push({
|
|
261
|
+
severity: "warning",
|
|
262
|
+
blockIndex: index,
|
|
263
|
+
message: `Entry "${headingText(block)}" in "${sectionName}" has no content below it.`
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
function detectUnsupported(markdown, issues) {
|
|
269
|
+
const supported = /* @__PURE__ */ new Set(["space", "heading", "paragraph", "list", "hr", "blockquote"]);
|
|
270
|
+
for (const token of marked2.lexer(markdown)) {
|
|
271
|
+
if (!supported.has(token.type)) {
|
|
272
|
+
issues.push({
|
|
273
|
+
severity: "warning",
|
|
274
|
+
message: `Unsupported Markdown ignored: "${token.type}".`
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function headingText(block) {
|
|
280
|
+
return inlineText(block.children);
|
|
281
|
+
}
|
|
282
|
+
function inlineText(nodes) {
|
|
283
|
+
let out = "";
|
|
284
|
+
for (const node of nodes) {
|
|
285
|
+
switch (node.type) {
|
|
286
|
+
case "text":
|
|
287
|
+
out += node.text;
|
|
288
|
+
break;
|
|
289
|
+
case "strong":
|
|
290
|
+
case "emphasis":
|
|
291
|
+
out += inlineText(node.children);
|
|
292
|
+
break;
|
|
293
|
+
case "link":
|
|
294
|
+
out += node.text;
|
|
295
|
+
break;
|
|
296
|
+
case "image":
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
function flattenText(doc) {
|
|
303
|
+
const out = [];
|
|
304
|
+
for (const block of doc.blocks) {
|
|
305
|
+
if (block.type === "paragraph") out.push(inlineText(block.children));
|
|
306
|
+
else if (block.type === "heading") out.push(inlineText(block.children));
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
function flattenLinks(doc) {
|
|
311
|
+
const out = [];
|
|
312
|
+
const walk = (nodes) => {
|
|
313
|
+
for (const node of nodes) {
|
|
314
|
+
if (node.type === "link") out.push(node.href);
|
|
315
|
+
else if (node.type === "strong" || node.type === "emphasis") walk(node.children);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
for (const block of doc.blocks) {
|
|
319
|
+
if (block.type === "paragraph") walk(block.children);
|
|
320
|
+
else if (block.type === "heading") walk(block.children);
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// src/theme/default.ts
|
|
326
|
+
function createDefaultTheme() {
|
|
327
|
+
return {
|
|
328
|
+
name: "ats-simple",
|
|
329
|
+
text: {
|
|
330
|
+
name: { family: "Helvetica", size: 22, weight: "bold" },
|
|
331
|
+
section: { family: "Helvetica", size: 13, weight: "bold" },
|
|
332
|
+
entryTitle: { family: "Helvetica", size: 11, weight: "bold" },
|
|
333
|
+
body: { family: "Helvetica", size: 10, weight: "normal" }
|
|
334
|
+
},
|
|
335
|
+
color: {
|
|
336
|
+
ink: { r: 0.1, g: 0.1, b: 0.1 },
|
|
337
|
+
accent: { r: 0.05, g: 0.15, b: 0.4 },
|
|
338
|
+
muted: { r: 0.35, g: 0.35, b: 0.35 }
|
|
339
|
+
},
|
|
340
|
+
spacing: {
|
|
341
|
+
sectionGap: 6,
|
|
342
|
+
entryGap: 6,
|
|
343
|
+
entrySpacing: 8,
|
|
344
|
+
lineHeight: 1.35,
|
|
345
|
+
columnGap: 8,
|
|
346
|
+
headerGap: 8
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/renderer/index.ts
|
|
352
|
+
import { PDFDocument as PDFDocument2 } from "pdf-lib";
|
|
353
|
+
|
|
354
|
+
// src/renderer/render.ts
|
|
355
|
+
import { StandardFonts, rgb } from "pdf-lib";
|
|
356
|
+
import fontkit from "@pdf-lib/fontkit";
|
|
357
|
+
|
|
358
|
+
// src/renderer/measure.ts
|
|
359
|
+
function measureWidth(run) {
|
|
360
|
+
return run.style.font.widthOfTextAtSize(run.text, run.style.size);
|
|
361
|
+
}
|
|
362
|
+
function wrapRuns(runs, maxWidth) {
|
|
363
|
+
if (runs.length === 0) return [];
|
|
364
|
+
const words = [];
|
|
365
|
+
for (const run of runs) {
|
|
366
|
+
const parts = run.text.split(/(\s+)/);
|
|
367
|
+
for (const part of parts) {
|
|
368
|
+
if (part === "" || /^\s+$/.test(part)) continue;
|
|
369
|
+
words.push({ text: part, style: run.style });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const lines = [];
|
|
373
|
+
let current = [];
|
|
374
|
+
let currentWidth = 0;
|
|
375
|
+
for (const word of words) {
|
|
376
|
+
const w = measureWidth(word);
|
|
377
|
+
const gapWidth = current.length === 0 ? 0 : measureWidth({ text: " ", style: word.style });
|
|
378
|
+
if (current.length > 0 && currentWidth + gapWidth + w > maxWidth) {
|
|
379
|
+
lines.push(current);
|
|
380
|
+
current = [{ ...word }];
|
|
381
|
+
currentWidth = w;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (current.length > 0) {
|
|
385
|
+
current.push({ text: " ", style: word.style });
|
|
386
|
+
currentWidth += gapWidth;
|
|
387
|
+
}
|
|
388
|
+
current.push({ ...word });
|
|
389
|
+
currentWidth += w;
|
|
390
|
+
}
|
|
391
|
+
if (current.length > 0) lines.push(current);
|
|
392
|
+
return lines;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/renderer/sanitize.ts
|
|
396
|
+
var CP1252_SPECIALS = /* @__PURE__ */ new Set([
|
|
397
|
+
8364,
|
|
398
|
+
8218,
|
|
399
|
+
402,
|
|
400
|
+
8222,
|
|
401
|
+
8230,
|
|
402
|
+
8224,
|
|
403
|
+
8225,
|
|
404
|
+
710,
|
|
405
|
+
8240,
|
|
406
|
+
352,
|
|
407
|
+
8249,
|
|
408
|
+
338,
|
|
409
|
+
381,
|
|
410
|
+
8216,
|
|
411
|
+
8217,
|
|
412
|
+
8220,
|
|
413
|
+
8221,
|
|
414
|
+
8226,
|
|
415
|
+
8211,
|
|
416
|
+
8212,
|
|
417
|
+
732,
|
|
418
|
+
8482,
|
|
419
|
+
353,
|
|
420
|
+
8250,
|
|
421
|
+
339,
|
|
422
|
+
382,
|
|
423
|
+
376
|
|
424
|
+
]);
|
|
425
|
+
var FALLBACKS = {
|
|
426
|
+
12539: "\xB7",
|
|
427
|
+
// ・ -> ·
|
|
428
|
+
12289: ",",
|
|
429
|
+
// 、-> ,
|
|
430
|
+
65292: ",",
|
|
431
|
+
// ,-> ,
|
|
432
|
+
12290: ".",
|
|
433
|
+
// 。-> .
|
|
434
|
+
65311: "?",
|
|
435
|
+
// ?-> ?
|
|
436
|
+
65281: "!",
|
|
437
|
+
// !-> !
|
|
438
|
+
8220: '"',
|
|
439
|
+
// " -> "
|
|
440
|
+
8221: '"',
|
|
441
|
+
// " -> "
|
|
442
|
+
8216: "'",
|
|
443
|
+
// ' -> '
|
|
444
|
+
8217: "'"
|
|
445
|
+
// ' -> '
|
|
446
|
+
};
|
|
447
|
+
function isWinAnsi(code) {
|
|
448
|
+
if (code >= 32 && code <= 126) return true;
|
|
449
|
+
if (code >= 160 && code <= 255) return true;
|
|
450
|
+
return CP1252_SPECIALS.has(code);
|
|
451
|
+
}
|
|
452
|
+
function sanitizeText(text) {
|
|
453
|
+
let out = "";
|
|
454
|
+
for (const ch of text) {
|
|
455
|
+
const code = ch.codePointAt(0);
|
|
456
|
+
if (isWinAnsi(code)) {
|
|
457
|
+
out += ch;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const fallback = FALLBACKS[code];
|
|
461
|
+
if (fallback) out += fallback;
|
|
462
|
+
}
|
|
463
|
+
return out;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// src/renderer/render.ts
|
|
467
|
+
var ASCENT = 0.72;
|
|
468
|
+
var STANDARD_FONTS = {
|
|
469
|
+
helvetica: {
|
|
470
|
+
regular: StandardFonts.Helvetica,
|
|
471
|
+
bold: StandardFonts.HelveticaBold,
|
|
472
|
+
italic: StandardFonts.HelveticaOblique
|
|
473
|
+
},
|
|
474
|
+
times: {
|
|
475
|
+
regular: StandardFonts.TimesRoman,
|
|
476
|
+
bold: StandardFonts.TimesRomanBold,
|
|
477
|
+
italic: StandardFonts.TimesRomanItalic
|
|
478
|
+
},
|
|
479
|
+
courier: {
|
|
480
|
+
regular: StandardFonts.Courier,
|
|
481
|
+
bold: StandardFonts.CourierBold,
|
|
482
|
+
italic: StandardFonts.CourierOblique
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
function resolveStandardFamily(family) {
|
|
486
|
+
const key = (family ?? "helvetica").toLowerCase();
|
|
487
|
+
return STANDARD_FONTS[key] ?? STANDARD_FONTS.helvetica;
|
|
488
|
+
}
|
|
489
|
+
async function renderDocument(doc, document, opts = {}) {
|
|
490
|
+
const theme = opts.theme;
|
|
491
|
+
if (!theme) throw new Error("renderDocument requires a theme");
|
|
492
|
+
const page = opts.page ?? createA4({ top: 40, right: 40, bottom: 40, left: 40 });
|
|
493
|
+
const flow = createFlow(page);
|
|
494
|
+
createPage(doc, page);
|
|
495
|
+
if (opts.fonts) doc.registerFontkit(fontkit);
|
|
496
|
+
const standard = resolveStandardFamily(theme.text.body.family);
|
|
497
|
+
const font = opts.fonts?.regular ? await doc.embedFont(opts.fonts.regular, { subset: true }) : await doc.embedFont(standard.regular);
|
|
498
|
+
const bold = opts.fonts?.bold ? await doc.embedFont(opts.fonts.bold, { subset: true }) : opts.fonts?.regular ? font : await doc.embedFont(standard.bold);
|
|
499
|
+
const italic = opts.fonts?.italic ? await doc.embedFont(opts.fonts.italic, { subset: true }) : opts.fonts?.regular ? font : await doc.embedFont(standard.italic);
|
|
500
|
+
const profile = opts.profile ? {
|
|
501
|
+
image: await embedImage(doc, opts.profile.bytes),
|
|
502
|
+
size: opts.profile.size ?? 96,
|
|
503
|
+
position: opts.profile.position ?? "right"
|
|
504
|
+
} : null;
|
|
505
|
+
const sanitize = !opts.fonts?.regular;
|
|
506
|
+
const ctx = { doc, flow, page, theme, font, bold, italic, profile, sanitize };
|
|
507
|
+
renderProfile(ctx);
|
|
508
|
+
for (const block of document.blocks) {
|
|
509
|
+
renderBlock(ctx, block);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function renderBlock(ctx, block) {
|
|
513
|
+
switch (block.type) {
|
|
514
|
+
case "heading":
|
|
515
|
+
renderHeading(ctx, block);
|
|
516
|
+
break;
|
|
517
|
+
case "paragraph":
|
|
518
|
+
renderParagraph(ctx, block.children, specToStyle(ctx, ctx.theme.text.body));
|
|
519
|
+
break;
|
|
520
|
+
case "list":
|
|
521
|
+
renderList(ctx, block);
|
|
522
|
+
break;
|
|
523
|
+
case "thematicBreak":
|
|
524
|
+
renderThematicBreak(ctx);
|
|
525
|
+
break;
|
|
526
|
+
case "blockquote":
|
|
527
|
+
renderBlockquote(ctx, block);
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
function specToStyle(ctx, spec) {
|
|
532
|
+
const weight = spec.weight === "bold" ? "bold" : "normal";
|
|
533
|
+
return { font: weight === "bold" ? ctx.bold : ctx.font, size: spec.size };
|
|
534
|
+
}
|
|
535
|
+
function lineHeightFor(ctx, size) {
|
|
536
|
+
return size * ctx.theme.spacing.lineHeight;
|
|
537
|
+
}
|
|
538
|
+
function gap(ctx, pts) {
|
|
539
|
+
if (pts <= 0) return;
|
|
540
|
+
if (ctx.flow.needNewPage(pts)) ensurePage(ctx);
|
|
541
|
+
ctx.flow.advance(pts);
|
|
542
|
+
}
|
|
543
|
+
function renderHeading(ctx, block) {
|
|
544
|
+
const spacing = ctx.theme.spacing;
|
|
545
|
+
if (block.level === 2) gap(ctx, spacing.sectionGap);
|
|
546
|
+
else if (block.level === 3) gap(ctx, spacing.entrySpacing);
|
|
547
|
+
const spec = block.level === 1 ? ctx.theme.text.name : block.level === 2 ? ctx.theme.text.section : ctx.theme.text.entryTitle;
|
|
548
|
+
const color = block.level === 2 ? ctx.theme.color.accent : ctx.theme.color.ink;
|
|
549
|
+
const style = specToStyle(ctx, spec);
|
|
550
|
+
renderLines(ctx, block.children, style, color);
|
|
551
|
+
if (block.level === 1) gap(ctx, spacing.headerGap);
|
|
552
|
+
else gap(ctx, spacing.entryGap);
|
|
553
|
+
}
|
|
554
|
+
function renderParagraph(ctx, nodes, spec) {
|
|
555
|
+
const spacing = ctx.theme.spacing;
|
|
556
|
+
renderLines(ctx, nodes, spec, ctx.theme.color.ink);
|
|
557
|
+
gap(ctx, spacing.entryGap);
|
|
558
|
+
}
|
|
559
|
+
function renderProfile(ctx) {
|
|
560
|
+
if (!ctx.profile) return;
|
|
561
|
+
const { image, size, position } = ctx.profile;
|
|
562
|
+
const pdfPage = currentPdfPage(ctx);
|
|
563
|
+
const contentWidth2 = ctx.flow.contentWidth();
|
|
564
|
+
let x;
|
|
565
|
+
if (position === "left") x = marginLeft(ctx);
|
|
566
|
+
else if (position === "center") x = marginLeft(ctx) + (contentWidth2 - size) / 2;
|
|
567
|
+
else x = marginLeft(ctx) + contentWidth2 - size;
|
|
568
|
+
const y = yToPdf(ctx, size);
|
|
569
|
+
pdfPage.drawImage(image, { x, y, width: size, height: size });
|
|
570
|
+
}
|
|
571
|
+
function renderBlockquote(ctx, block) {
|
|
572
|
+
const spacing = ctx.theme.spacing;
|
|
573
|
+
const spec = specToStyle(ctx, ctx.theme.text.body);
|
|
574
|
+
renderLines(ctx, block.children, spec, ctx.theme.color.muted);
|
|
575
|
+
gap(ctx, spacing.entryGap);
|
|
576
|
+
}
|
|
577
|
+
function renderLines(ctx, nodes, spec, color) {
|
|
578
|
+
const runs = nodesToRuns(ctx, nodes, spec);
|
|
579
|
+
const maxWidth = ctx.flow.contentWidth();
|
|
580
|
+
const lines = wrapRuns(runs, maxWidth);
|
|
581
|
+
const lineHeight = lineHeightFor(ctx, spec.size);
|
|
582
|
+
for (const line of lines) {
|
|
583
|
+
if (ctx.flow.needNewPage(lineHeight)) ensurePage(ctx);
|
|
584
|
+
drawLine(ctx, line, color);
|
|
585
|
+
ctx.flow.advance(lineHeight);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function renderList(ctx, block) {
|
|
589
|
+
const spacing = ctx.theme.spacing;
|
|
590
|
+
const spec = specToStyle(ctx, ctx.theme.text.body);
|
|
591
|
+
const lineHeight = lineHeightFor(ctx, spec.size);
|
|
592
|
+
const indent = spec.size * 1.2;
|
|
593
|
+
for (const item of block.items) {
|
|
594
|
+
const bullet = block.ordered ? `${block.items.indexOf(item) + 1}. ` : "\u2022 ";
|
|
595
|
+
const runs = [{ text: bullet, style: spec }];
|
|
596
|
+
const wrapped = wrapRuns([...runs, ...nodesToRuns(ctx, item.children, spec)], ctx.flow.contentWidth() - indent);
|
|
597
|
+
wrapped.forEach((line, i) => {
|
|
598
|
+
const padded = i === 0 ? line : padRuns(line, indent, spec);
|
|
599
|
+
if (ctx.flow.needNewPage(lineHeight)) ensurePage(ctx);
|
|
600
|
+
drawLine(ctx, padded, ctx.theme.color.ink);
|
|
601
|
+
ctx.flow.advance(lineHeight);
|
|
602
|
+
});
|
|
603
|
+
if (item.nested) {
|
|
604
|
+
for (const sub of item.nested) {
|
|
605
|
+
renderNestedList(ctx, sub, indent);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
gap(ctx, spacing.entryGap);
|
|
610
|
+
}
|
|
611
|
+
function renderNestedList(ctx, block, baseIndent) {
|
|
612
|
+
const spec = specToStyle(ctx, ctx.theme.text.body);
|
|
613
|
+
const lineHeight = lineHeightFor(ctx, spec.size);
|
|
614
|
+
const indent = baseIndent + spec.size * 1.2;
|
|
615
|
+
for (const item of block.items) {
|
|
616
|
+
const bullet = block.ordered ? `${block.items.indexOf(item) + 1}. ` : "\u2022 ";
|
|
617
|
+
const runs = [{ text: bullet, style: spec }];
|
|
618
|
+
const wrapped = wrapRuns([...runs, ...nodesToRuns(ctx, item.children, spec)], ctx.flow.contentWidth() - indent);
|
|
619
|
+
wrapped.forEach((line, i) => {
|
|
620
|
+
const padded = i === 0 ? padRuns(line, baseIndent, spec) : padRuns(line, indent, spec);
|
|
621
|
+
if (ctx.flow.needNewPage(lineHeight)) ensurePage(ctx);
|
|
622
|
+
drawLine(ctx, padded, ctx.theme.color.ink);
|
|
623
|
+
ctx.flow.advance(lineHeight);
|
|
624
|
+
});
|
|
625
|
+
if (item.nested) {
|
|
626
|
+
for (const sub of item.nested) {
|
|
627
|
+
renderNestedList(ctx, sub, indent);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function renderThematicBreak(ctx) {
|
|
633
|
+
const spacing = ctx.theme.spacing;
|
|
634
|
+
const y = ctx.flow.cursor.y;
|
|
635
|
+
const h = 0.75;
|
|
636
|
+
if (ctx.flow.needNewPage(spacing.entryGap + h)) ensurePage(ctx);
|
|
637
|
+
const pdfPage = currentPdfPage(ctx);
|
|
638
|
+
pdfPage.drawRectangle({
|
|
639
|
+
x: marginLeft(ctx),
|
|
640
|
+
y: yToPdf(ctx, y + spacing.entryGap + h),
|
|
641
|
+
width: ctx.flow.contentWidth(),
|
|
642
|
+
height: h,
|
|
643
|
+
color: toColor(ctx.theme.color.muted)
|
|
644
|
+
});
|
|
645
|
+
ctx.flow.advance(spacing.entryGap * 2 + h);
|
|
646
|
+
}
|
|
647
|
+
function nodesToRuns(ctx, nodes, base) {
|
|
648
|
+
const runs = [];
|
|
649
|
+
const walk = (list, style) => {
|
|
650
|
+
for (const node of list) {
|
|
651
|
+
switch (node.type) {
|
|
652
|
+
case "text":
|
|
653
|
+
if (node.text) runs.push({ text: ctx.sanitize ? sanitizeText(node.text) : node.text, style });
|
|
654
|
+
break;
|
|
655
|
+
case "strong":
|
|
656
|
+
walk(node.children, { font: ctx.bold, size: style.size });
|
|
657
|
+
break;
|
|
658
|
+
case "emphasis":
|
|
659
|
+
walk(node.children, { font: ctx.italic, size: style.size });
|
|
660
|
+
break;
|
|
661
|
+
case "link":
|
|
662
|
+
runs.push({ text: ctx.sanitize ? sanitizeText(node.text) : node.text, style });
|
|
663
|
+
break;
|
|
664
|
+
case "image":
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
walk(nodes, base);
|
|
670
|
+
return runs;
|
|
671
|
+
}
|
|
672
|
+
function padRuns(runs, pad, spec) {
|
|
673
|
+
if (pad <= 0) return runs;
|
|
674
|
+
return [{ text: " ".repeat(Math.max(1, Math.round(pad / (spec.size * 0.5)))), style: spec }, ...runs];
|
|
675
|
+
}
|
|
676
|
+
function drawLine(ctx, line, color) {
|
|
677
|
+
const pdfPage = currentPdfPage(ctx);
|
|
678
|
+
const size = Math.max(...line.map((r) => r.style.size));
|
|
679
|
+
const baseline = ctx.flow.cursor.y + size * ASCENT;
|
|
680
|
+
let x = marginLeft(ctx);
|
|
681
|
+
for (const run of line) {
|
|
682
|
+
pdfPage.drawText(run.text, {
|
|
683
|
+
x,
|
|
684
|
+
y: yToPdf(ctx, baseline),
|
|
685
|
+
size: run.style.size,
|
|
686
|
+
font: run.style.font,
|
|
687
|
+
color: toColor(color)
|
|
688
|
+
});
|
|
689
|
+
x += measureWidth(run);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
function marginLeft(ctx) {
|
|
693
|
+
return ctx.page.margin.left;
|
|
694
|
+
}
|
|
695
|
+
function yToPdf(ctx, y) {
|
|
696
|
+
return ctx.page.size.height - ctx.page.margin.top - y;
|
|
697
|
+
}
|
|
698
|
+
function toColor(c) {
|
|
699
|
+
return rgb(c.r, c.g, c.b);
|
|
700
|
+
}
|
|
701
|
+
function createPage(doc, page) {
|
|
702
|
+
doc.addPage([page.size.width, page.size.height]);
|
|
703
|
+
}
|
|
704
|
+
async function embedImage(doc, bytes) {
|
|
705
|
+
const isPng = bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71;
|
|
706
|
+
const isJpeg = bytes[0] === 255 && bytes[1] === 216;
|
|
707
|
+
if (isPng) return doc.embedPng(bytes);
|
|
708
|
+
if (isJpeg) return doc.embedJpg(bytes);
|
|
709
|
+
throw new Error("Unsupported image format: only PNG and JPEG are supported.");
|
|
710
|
+
}
|
|
711
|
+
function ensurePage(ctx) {
|
|
712
|
+
ctx.flow.newPage();
|
|
713
|
+
ctx.doc.addPage([ctx.page.size.width, ctx.page.size.height]);
|
|
714
|
+
}
|
|
715
|
+
function currentPdfPage(ctx) {
|
|
716
|
+
return ctx.doc.getPage(ctx.flow.pageNumber);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// src/renderer/index.ts
|
|
720
|
+
async function renderMarkdownToPdf(markdown, opts = {}) {
|
|
721
|
+
const doc = await PDFDocument2.create();
|
|
722
|
+
const theme = opts.theme ?? createDefaultTheme();
|
|
723
|
+
await renderDocument(doc, parseMarkdown(markdown), {
|
|
724
|
+
theme,
|
|
725
|
+
page: opts.page,
|
|
726
|
+
profile: opts.profile,
|
|
727
|
+
fonts: opts.fonts
|
|
728
|
+
});
|
|
729
|
+
return doc.save();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// src/generate.ts
|
|
733
|
+
import { readFile, writeFile } from "fs/promises";
|
|
734
|
+
async function generateCV(options) {
|
|
735
|
+
const markdown = await readFile(options.input, "utf8");
|
|
736
|
+
const validation = validateCV(markdown);
|
|
737
|
+
const errors = validation.issues.filter((i) => i.severity === "error");
|
|
738
|
+
if (errors.length > 0) {
|
|
739
|
+
const summary = errors.map((e) => `- ${e.message}`).join("\n");
|
|
740
|
+
throw new Error(`CV validation failed:
|
|
741
|
+
${summary}`);
|
|
742
|
+
}
|
|
743
|
+
const theme = options.theme ?? createDefaultTheme();
|
|
744
|
+
const bytes = await renderMarkdownToPdf(markdown, {
|
|
745
|
+
theme,
|
|
746
|
+
page: options.page,
|
|
747
|
+
profile: options.profile,
|
|
748
|
+
fonts: options.fonts
|
|
749
|
+
});
|
|
750
|
+
await writeFile(options.output, bytes);
|
|
751
|
+
return {
|
|
752
|
+
issues: validation.issues,
|
|
753
|
+
output: options.output
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
export {
|
|
757
|
+
contentHeight,
|
|
758
|
+
contentWidth,
|
|
759
|
+
createA4,
|
|
760
|
+
createDefaultTheme,
|
|
761
|
+
createFlow,
|
|
762
|
+
generateCV,
|
|
763
|
+
parseMarkdown,
|
|
764
|
+
renderDocument,
|
|
765
|
+
renderMarkdownToPdf,
|
|
766
|
+
validateCV
|
|
767
|
+
};
|
|
768
|
+
//# sourceMappingURL=index.js.map
|