my-last-cv 0.0.7 → 0.0.9

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56c56926e52415beebd4ff21594a85a4ef86be44f2ebe1716b5454204714f0e9
4
- data.tar.gz: c75b9746a02372e488fd237f9d6ee667f9136b126826716b870c3f74816a0a1d
3
+ metadata.gz: 04b56bee3c092687375a967a44f81464be5f0b34fa3a453499fb4b3ac22fda06
4
+ data.tar.gz: dcaccb9a74c42cf24267201ddd6e95b17c434cbfb955fb8479ec3e88d76f5fab
5
5
  SHA512:
6
- metadata.gz: 229b38de93a8b39be1ba36ae5f3de149e3bdef877967ee0dd2d6c4cf8db14003de54f804438da98d4711d9f37c6249d92c0a8c3a17e190fdd66d227736c1af08
7
- data.tar.gz: 9426974cfee0e9c7abbf5759bb95a449ecf210ab24bc7c57db04889a5480f8a27a2ff9c2ccd4dab7efb201a4f60089673e1b118890fa61e6ea9bc02a5f10aa42
6
+ metadata.gz: ba6f2f816174ee03ed4f417caca9e65b8cfeec6c684b29166f687d8ca7ac0e8a090026d7f1cafc68fdac27a42cf934126fc339b3efef8e83920d5fdac7b8fd7f
7
+ data.tar.gz: b514c0f500dec9398744f3185295ee350d4deaf9b647a789a9f0f893d9c729716d8235c2c706571bab428785d31a0150f729582f94a8cba2c7354aadff9fed38
data/README.md CHANGED
@@ -1,14 +1,128 @@
1
1
  # my-last-cv
2
- A simple gem to generate beautiful CV from a markdown file
3
2
 
4
- ## How to use it?
3
+ A Ruby gem and command-line tool that generates a styled PDF CV from Markdown.
4
+
5
+ ## Requirements
6
+
7
+ Ruby 3.0 or later.
8
+
9
+ ## Installation
10
+
5
11
  ```bash
6
12
  bundle add my-last-cv
7
13
  bundle install
8
- bundle exec my_last_cv sample/cv.md output/cv.pdf
9
14
  ```
10
15
 
16
+ For local development, install the project's dependencies:
17
+
18
+ ```bash
19
+ bundle install
20
+ ```
21
+
22
+ ## Generate a CV
23
+
24
+ ```bash
25
+ bundle exec my_last_cv path/to/cv.md output/cv.pdf
26
+ ```
27
+
28
+ The output directory is created automatically. The CLI uses the bundled Inter and
29
+ Lora fonts. Start from [`sample/cv.md`](sample/cv.md) to create your document.
30
+ Its compact layout is designed to fit typical CVs on a single page.
31
+
32
+ ## Markdown format
33
+
34
+ Use YAML front matter for contact information, a level-one heading for the name,
35
+ level-two headings for sections, and level-three headings for entries within a
36
+ section:
37
+
38
+ ```markdown
39
+ ---
40
+ title: "Ruby developer"
41
+ email: "jane@example.com"
42
+ phone: "+33 6 12 34 56 78"
43
+ linkedin: "https://www.linkedin.com/in/janedoe"
44
+ github: "https://github.com/janedoe"
45
+ website: "https://janedoe.dev"
46
+ location: "Paris, France"
47
+ ---
48
+
49
+ # Jane Doe
50
+ Short professional introduction displayed below the header.
51
+
52
+ ## Experience
53
+ ### Senior Developer — Example Corp
54
+ Introductory paragraph for this role.
55
+ - Built and maintained Ruby applications.
56
+ - Mentored other developers.
57
+
58
+ ## Skills
59
+ - Ruby
60
+ - PostgreSQL
61
+ ```
62
+
63
+ Supported front-matter contact keys are `name`, `title`, `email`, `phone`,
64
+ `linkedin`, `github`, `website`, and `location`. They are rendered with icons;
65
+ `name` and `title` are omitted from the contact list when they duplicate the
66
+ document heading or introduction.
67
+
68
+ Text immediately below the main heading becomes the introduction. Section and
69
+ entry content can contain paragraphs and bullet lists. A `###` entry without a
70
+ preceding `##` section is placed in a section named `Divers`.
71
+
72
+ ## Inline Markdown support
73
+
74
+ Contact fields, headings, paragraphs, and bullet items support:
75
+
76
+ - `**bold**`
77
+ - `*italic*`
78
+ - `` `code` ``
79
+ - `[links](https://example.com)`
80
+
81
+ Escape Markdown punctuation with a backslash when it should remain literal, for
82
+ example `\*not italic\*`.
83
+
84
+ ## Ruby API and styling
85
+
86
+ ```ruby
87
+ require "my_last_cv"
88
+
89
+ style = MyLastCV::Style.new(
90
+ header_font: "Inter",
91
+ header_size: 22,
92
+ section_font: "Inter",
93
+ section_size: 12,
94
+ body_font: "Lora",
95
+ body_size: 10,
96
+ page_options: { margin: 36 },
97
+ accent_color: "1F7AE0"
98
+ )
99
+
100
+ MyLastCV.generate("cv.md", "output/cv.pdf", style: style)
101
+ ```
102
+
103
+ `MyLastCV::Style` defaults to Helvetica, sizes `18`, `12`, and `10`, a page
104
+ margin of `48`, and a black accent color (`000000`). `page_options` is passed to
105
+ `Prawn::Document`, so Prawn page settings such as `page_size` and `layout` can
106
+ also be supplied.
107
+
11
108
  ## Custom fonts
12
- By default, the gem looks for fonts in `./fonts` of the calling project.
13
- You can override this with `Style.new(fonts_dir: "/path/to/fonts")`
14
- or `MY_LAST_CV_FONTS_DIR=/path/to/fonts`.
109
+
110
+ Inter and Lora are bundled with the gem. To override them, provide a directory
111
+ containing `Inter-Regular.ttf`, `Inter-Bold.ttf`, `Lora-Regular.ttf`, and
112
+ `Lora-Bold.ttf`. Optional `Inter-Italic.ttf`, `Inter-BoldItalic.ttf`,
113
+ `Lora-Italic.ttf`, and `Lora-BoldItalic.ttf` files enable matching italic
114
+ styles; missing variants fall back to their regular or bold face.
115
+
116
+ ```ruby
117
+ MyLastCV::Style.new(fonts_dir: "/path/to/fonts")
118
+ ```
119
+
120
+ Alternatively, set `MY_LAST_CV_FONTS_DIR=/path/to/fonts`. Font directories are
121
+ resolved in this order: `fonts_dir`, `MY_LAST_CV_FONTS_DIR`, `./fonts`, then the
122
+ fonts bundled with the gem.
123
+
124
+ ## Development
125
+
126
+ ```bash
127
+ bundle exec rake spec
128
+ ```
data/exe/my_last_cv CHANGED
@@ -12,12 +12,12 @@ input, output = ARGV
12
12
 
13
13
  pro = MyLastCV::Style.new(
14
14
  header_font: "Inter",
15
- header_size: 22,
15
+ header_size: 20,
16
16
  section_font: "Inter",
17
- section_size: 12,
17
+ section_size: 11,
18
18
  body_font: "Lora",
19
- body_size: 10,
20
- page_options: { margin: 36 },
19
+ body_size: 9,
20
+ page_options: { margin: 30 },
21
21
  accent_color: "1F7AE0" # blue
22
22
  )
23
23
 
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,141 @@
1
+ require 'cgi'
2
+
3
+ module MyLastCV
4
+ class Inline
5
+ LINK_COLOR = '0563C1'.freeze
6
+ ESCAPABLE_CHARACTERS = "`*[]()".chars.freeze
7
+
8
+ def self.parse(text)
9
+ new(text).parse
10
+ end
11
+
12
+ def initialize(text)
13
+ @text = text.to_s
14
+ @index = 0
15
+ end
16
+
17
+ def parse
18
+ parse_until(nil).first
19
+ end
20
+
21
+ private
22
+
23
+ def parse_until(stop_token)
24
+ output = +""
25
+
26
+ while @index < @text.length
27
+ if stop_token && token?(stop_token)
28
+ @index += stop_token.length
29
+ return [output, true]
30
+ end
31
+
32
+ output << if escaped_character?
33
+ consume_escaped_character
34
+ elsif token?('**')
35
+ parse_emphasis('**', 'b')
36
+ elsif token?('*')
37
+ parse_emphasis('*', 'i')
38
+ elsif token?('`')
39
+ parse_code
40
+ elsif token?('[')
41
+ parse_link
42
+ else
43
+ consume_plain_character
44
+ end
45
+ end
46
+
47
+ [output, false]
48
+ end
49
+
50
+ def escaped_character?
51
+ current_character == '\\' && (next_character == '\\' || ESCAPABLE_CHARACTERS.include?(next_character))
52
+ end
53
+
54
+ def consume_escaped_character
55
+ character = next_character
56
+ @index += 2
57
+ CGI.escapeHTML(character)
58
+ end
59
+
60
+ def consume_plain_character
61
+ character = current_character
62
+ @index += 1
63
+ CGI.escapeHTML(character)
64
+ end
65
+
66
+ def parse_emphasis(delimiter, tag)
67
+ start_index = @index
68
+ @index += delimiter.length
69
+ content, closed = parse_until(delimiter)
70
+
71
+ return "<#{tag}>#{content}</#{tag}>" if closed && !content.empty?
72
+
73
+ CGI.escapeHTML(@text[start_index...@index])
74
+ end
75
+
76
+ def parse_code
77
+ start_index = @index
78
+ @index += 1
79
+ content = +""
80
+
81
+ while @index < @text.length && !token?('`')
82
+ content << @text[@index]
83
+ @index += 1
84
+ end
85
+
86
+ return CGI.escapeHTML(@text[start_index...@index]) if @index >= @text.length
87
+
88
+ @index += 1
89
+ "<font name=\"Courier\">#{CGI.escapeHTML(content)}</font>"
90
+ end
91
+
92
+ def parse_link
93
+ start_index = @index
94
+ @index += 1
95
+ label, closed = parse_until(']')
96
+ return CGI.escapeHTML(@text[start_index...@index]) unless closed
97
+ return CGI.escapeHTML(@text[start_index...@index]) unless token?('(')
98
+
99
+ @index += 1
100
+ url, closed = parse_link_destination
101
+ return CGI.escapeHTML(@text[start_index...@index]) unless closed
102
+ return CGI.escapeHTML(@text[start_index...@index]) if url.strip.empty?
103
+
104
+ %(<color rgb="#{LINK_COLOR}"><u><link href="#{CGI.escapeHTML(url)}">#{label}</link></u></color>)
105
+ end
106
+
107
+ def parse_link_destination
108
+ start_index = @index
109
+ depth = 0
110
+
111
+ while @index < @text.length
112
+ if token?('(')
113
+ depth += 1
114
+ elsif token?(')')
115
+ if depth.zero?
116
+ url = @text[start_index...@index]
117
+ @index += 1
118
+ return [url, true]
119
+ end
120
+
121
+ depth -= 1
122
+ end
123
+ @index += 1
124
+ end
125
+
126
+ [@text[start_index...@index], false]
127
+ end
128
+
129
+ def token?(value)
130
+ @text[@index, value.length] == value
131
+ end
132
+
133
+ def current_character
134
+ @text[@index]
135
+ end
136
+
137
+ def next_character
138
+ @text[@index + 1]
139
+ end
140
+ end
141
+ end
@@ -129,7 +129,8 @@ module MyLastCV
129
129
  def handle_contact(line)
130
130
  key, value = line.split(":", 2)
131
131
  @result[:contact] ||= {}
132
- @result[:contact][key] = value.strip
132
+ normalized_key = key.to_s.strip.downcase
133
+ @result[:contact][normalized_key] = value.to_s.strip
133
134
  end
134
135
 
135
136
  def handle_section(line)
@@ -2,6 +2,7 @@ require 'prawn'
2
2
  require 'prawn/icon'
3
3
  require 'fileutils'
4
4
  require_relative 'icons'
5
+ require_relative 'inline'
5
6
 
6
7
  module MyLastCV
7
8
  class Renderer
@@ -37,28 +38,38 @@ module MyLastCV
37
38
  fonts_dir = resolved_fonts_dir
38
39
  return unless fonts_dir
39
40
 
40
- # Example : Inter (Regular / Bold)
41
41
  pdf.font_families.update(
42
- "Inter" => {
43
- normal: File.join(fonts_dir, "Inter-Regular.ttf"),
44
- bold: File.join(fonts_dir, "Inter-Bold.ttf")
45
- },
46
- "Lora" => {
47
- normal: File.join(fonts_dir, "Lora-Regular.ttf"),
48
- bold: File.join(fonts_dir, "Lora-Bold.ttf")
49
- }
42
+ "Inter" => font_family_files(fonts_dir, "Inter"),
43
+ "Lora" => font_family_files(fonts_dir, "Lora")
50
44
  )
51
45
  end
52
46
 
47
+ def font_family_files(fonts_dir, family)
48
+ regular = File.join(fonts_dir, "#{family}-Regular.ttf")
49
+ bold = File.join(fonts_dir, "#{family}-Bold.ttf")
50
+ italic = File.join(fonts_dir, "#{family}-Italic.ttf")
51
+ bold_italic = File.join(fonts_dir, "#{family}-BoldItalic.ttf")
52
+
53
+ {
54
+ normal: regular,
55
+ bold: bold,
56
+ italic: File.file?(italic) ? italic : regular,
57
+ bold_italic: File.file?(bold_italic) ? bold_italic : bold
58
+ }
59
+ end
60
+
53
61
  def render_header(pdf)
54
- pdf.move_down 12
62
+ pdf.move_down 4
55
63
  pdf.font(@style.body_font)
56
- @parsed_cv[:contact].each_key do |key|
64
+ contact = @parsed_cv[:contact] || {}
65
+ contact.each do |key, value|
66
+ next if redundant_contact_field?(key, value)
67
+
57
68
  icon = "<icon size=\"#{@style.body_size}\">#{MyLastCV::Icons::MAP[key.to_sym]}</icon>"
58
- pdf.icon("#{icon} #{@parsed_cv[:contact][key]}", inline_format: true)
69
+ pdf.icon("#{icon} #{Inline.parse(value)}", inline_format: true)
59
70
  end
60
71
 
61
- pdf.move_down 6
72
+ pdf.move_down 2
62
73
  pdf.font(@style.header_font)
63
74
  with_color(pdf, @style.accent_color) do
64
75
  pdf.text(@parsed_cv[:title] || '-', size: @style.header_size, align: :center)
@@ -69,29 +80,43 @@ module MyLastCV
69
80
  pdf.stroke_horizontal_rule
70
81
 
71
82
  if @parsed_cv[:intro]&.any?
72
- pdf.move_down 10
83
+ pdf.move_down 4
73
84
  @parsed_cv[:intro].each do |p|
74
- pdf.text(p, size: @style.body_size, leading: 2)
85
+ pdf.text(Inline.parse(p), size: @style.body_size, leading: 2, inline_format: true)
86
+ end
87
+ end
88
+ pdf.move_down 4
89
+ end
90
+
91
+ def redundant_contact_field?(key, value)
92
+ case key
93
+ when "name"
94
+ value.to_s.strip.casecmp?(@parsed_cv[:title].to_s.strip)
95
+ when "title"
96
+ title = value.to_s.strip
97
+ Array(@parsed_cv[:intro]).any? do |paragraph|
98
+ paragraph.to_s.sub(/\A\*+/, "").start_with?(title)
75
99
  end
100
+ else
101
+ false
76
102
  end
77
- pdf.move_down 10
78
103
  end
79
104
 
80
105
  def render_sections(pdf)
81
106
  (@parsed_cv[:sections] || []).each do |section|
82
- pdf.move_down 8
107
+ pdf.move_down 4
83
108
  pdf.font(@style.section_font)
84
109
 
85
110
  with_color(pdf, @style.accent_color) do
86
- pdf.text(section[:title], size: @style.section_size, style: :bold)
111
+ pdf.text(Inline.parse(section[:title]), size: @style.section_size, style: :bold, inline_format: true)
87
112
  end
88
113
 
89
114
  render_items(pdf, section[:items])
90
115
 
91
116
  (section[:elements] || []).each do |el|
92
- pdf.move_down 6
117
+ pdf.move_down 3
93
118
  pdf.font(@style.section_font)
94
- pdf.text(el[:title], size: (@style.section_size - 3), style: :bold)
119
+ pdf.text(Inline.parse(el[:title]), size: (@style.section_size - 3), style: :bold, inline_format: true)
95
120
  render_items(pdf, el[:items])
96
121
  end
97
122
  end
@@ -100,16 +125,16 @@ module MyLastCV
100
125
  def render_items(pdf, items)
101
126
  return if items.nil? || items.empty?
102
127
 
103
- pdf.move_down 4
128
+ pdf.move_down 1
104
129
  pdf.font(@style.body_font)
105
130
 
106
131
  (items || []).each do |item|
107
132
  case item[:type]
108
133
  when :bullet
109
- pdf.text("• #{item[:text]}", size: @style.body_size, indent_paragraphs: 16)
134
+ pdf.text("• #{Inline.parse(item[:text])}", size: @style.body_size, indent_paragraphs: 16, inline_format: true)
110
135
  when :paragraph
111
- pdf.text(item[:text], size: @style.body_size, leading: 2)
112
- pdf.move_down 4
136
+ pdf.text(Inline.parse(item[:text]), size: @style.body_size, leading: 2, inline_format: true)
137
+ pdf.move_down 1
113
138
  else
114
139
  # fallback (au cas où)
115
140
  pdf.text(item.to_s, size: @style.body_size)
@@ -1,3 +1,3 @@
1
1
  module MyLastCV
2
- VERSION = "0.0.7"
2
+ VERSION = "0.0.9"
3
3
  end
data/lib/my_last_cv.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require_relative 'my_last_cv/version'
2
+ require_relative 'my_last_cv/inline'
2
3
  require_relative 'my_last_cv/parser'
3
4
  require_relative 'my_last_cv/style'
4
5
  require_relative 'my_last_cv/renderer'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: my-last-cv
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.7
4
+ version: 0.0.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maxime Hanicotte
@@ -76,8 +76,14 @@ extra_rdoc_files: []
76
76
  files:
77
77
  - README.md
78
78
  - exe/my_last_cv
79
+ - fonts/Inter-Bold.ttf
80
+ - fonts/Inter-Italic.ttf
81
+ - fonts/Inter-Regular.ttf
82
+ - fonts/Lora-Bold.ttf
83
+ - fonts/Lora-Regular.ttf
79
84
  - lib/my_last_cv.rb
80
85
  - lib/my_last_cv/icons.rb
86
+ - lib/my_last_cv/inline.rb
81
87
  - lib/my_last_cv/parser.rb
82
88
  - lib/my_last_cv/renderer.rb
83
89
  - lib/my_last_cv/style.rb
@@ -106,7 +112,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
106
112
  - !ruby/object:Gem::Version
107
113
  version: '0'
108
114
  requirements: []
109
- rubygems_version: 3.7.1
115
+ rubygems_version: 3.7.2
110
116
  specification_version: 4
111
117
  summary: Generate your CV from Markdown to PDF
112
118
  test_files: []