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