my-last-cv 0.0.6 → 0.0.8
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 +4 -4
- data/README.md +115 -6
- data/fonts/Inter-Bold.ttf +0 -0
- data/fonts/Inter-Regular.ttf +0 -0
- data/fonts/Lora-Bold.ttf +0 -0
- data/fonts/Lora-Regular.ttf +0 -0
- data/lib/my_last_cv/icons.rb +14 -0
- data/lib/my_last_cv/inline.rb +141 -0
- data/lib/my_last_cv/parser.rb +149 -95
- data/lib/my_last_cv/renderer.rb +26 -18
- data/lib/my_last_cv/version.rb +1 -1
- data/lib/my_last_cv.rb +1 -0
- metadata +22 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 031a4dc86e72946d0fb8673b77738a6cbb3d30c2404c028267a88a78b8ae1463
|
|
4
|
+
data.tar.gz: b2ac4623552d0ae8f597f8dd58cb9bcb481b09f201ff899335054767cfcd1620
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bcd26c3ab542f8b2ddf18dc894351bab8c1332181609164b47e2b79ef433d83ec6298509844f20907ee91caf1a6aceea43f451e76567dd48afc457fedd74f889
|
|
7
|
+
data.tar.gz: d38f99b4d9fab6a4c700628282ef21cc125590d8e4a4a1ab48ad625c5ebbed132cc1c89cc1b1b5cbf306c13bf503d4fa780d0c637c425388d8c0728345706c0f
|
data/README.md
CHANGED
|
@@ -1,14 +1,123 @@
|
|
|
1
1
|
# my-last-cv
|
|
2
|
-
A simple gem to generate beautiful CV from a markdown file
|
|
3
2
|
|
|
4
|
-
|
|
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
|
+
|
|
31
|
+
## Markdown format
|
|
32
|
+
|
|
33
|
+
Use YAML front matter for contact information, a level-one heading for the name,
|
|
34
|
+
level-two headings for sections, and level-three headings for entries within a
|
|
35
|
+
section:
|
|
36
|
+
|
|
37
|
+
```markdown
|
|
38
|
+
---
|
|
39
|
+
title: "Ruby developer"
|
|
40
|
+
email: "jane@example.com"
|
|
41
|
+
phone: "+33 6 12 34 56 78"
|
|
42
|
+
linkedin: "https://www.linkedin.com/in/janedoe"
|
|
43
|
+
github: "https://github.com/janedoe"
|
|
44
|
+
website: "https://janedoe.dev"
|
|
45
|
+
location: "Paris, France"
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
# Jane Doe
|
|
49
|
+
Short professional introduction displayed below the header.
|
|
50
|
+
|
|
51
|
+
## Experience
|
|
52
|
+
### Senior Developer — Example Corp
|
|
53
|
+
Introductory paragraph for this role.
|
|
54
|
+
- Built and maintained Ruby applications.
|
|
55
|
+
- Mentored other developers.
|
|
56
|
+
|
|
57
|
+
## Skills
|
|
58
|
+
- Ruby
|
|
59
|
+
- PostgreSQL
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Supported front-matter contact keys are `name`, `title`, `email`, `phone`,
|
|
63
|
+
`linkedin`, `github`, `website`, and `location`. They are rendered with icons.
|
|
64
|
+
|
|
65
|
+
Text immediately below the main heading becomes the introduction. Section and
|
|
66
|
+
entry content can contain paragraphs and bullet lists. A `###` entry without a
|
|
67
|
+
preceding `##` section is placed in a section named `Divers`.
|
|
68
|
+
|
|
69
|
+
## Inline Markdown support
|
|
70
|
+
|
|
71
|
+
Contact fields, paragraphs, and bullet items support:
|
|
72
|
+
|
|
73
|
+
- `**bold**`
|
|
74
|
+
- `*italic*`
|
|
75
|
+
- `` `code` ``
|
|
76
|
+
- `[links](https://example.com)`
|
|
77
|
+
|
|
78
|
+
Escape Markdown punctuation with a backslash when it should remain literal, for
|
|
79
|
+
example `\*not italic\*`.
|
|
80
|
+
|
|
81
|
+
## Ruby API and styling
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
require "my_last_cv"
|
|
85
|
+
|
|
86
|
+
style = MyLastCV::Style.new(
|
|
87
|
+
header_font: "Inter",
|
|
88
|
+
header_size: 22,
|
|
89
|
+
section_font: "Inter",
|
|
90
|
+
section_size: 12,
|
|
91
|
+
body_font: "Lora",
|
|
92
|
+
body_size: 10,
|
|
93
|
+
page_options: { margin: 36 },
|
|
94
|
+
accent_color: "1F7AE0"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
MyLastCV.generate("cv.md", "output/cv.pdf", style: style)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`MyLastCV::Style` defaults to Helvetica, sizes `18`, `12`, and `10`, a page
|
|
101
|
+
margin of `48`, and a black accent color (`000000`). `page_options` is passed to
|
|
102
|
+
`Prawn::Document`, so Prawn page settings such as `page_size` and `layout` can
|
|
103
|
+
also be supplied.
|
|
104
|
+
|
|
11
105
|
## Custom fonts
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
106
|
+
|
|
107
|
+
Inter and Lora are bundled with the gem. To override them, provide a directory
|
|
108
|
+
containing `Inter-Regular.ttf`, `Inter-Bold.ttf`, `Lora-Regular.ttf`, and
|
|
109
|
+
`Lora-Bold.ttf`:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
MyLastCV::Style.new(fonts_dir: "/path/to/fonts")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Alternatively, set `MY_LAST_CV_FONTS_DIR=/path/to/fonts`. Font directories are
|
|
116
|
+
resolved in this order: `fonts_dir`, `MY_LAST_CV_FONTS_DIR`, `./fonts`, then the
|
|
117
|
+
fonts bundled with the gem.
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
bundle exec rake spec
|
|
123
|
+
```
|
|
Binary file
|
|
Binary file
|
data/fonts/Lora-Bold.ttf
ADDED
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module MyLastCV
|
|
2
|
+
module Icons
|
|
3
|
+
MAP = {
|
|
4
|
+
name: "fas-user",
|
|
5
|
+
title: "fas-briefcase",
|
|
6
|
+
email: "fas-envelope",
|
|
7
|
+
phone: "fas-phone",
|
|
8
|
+
linkedin: "fab-linkedin",
|
|
9
|
+
github: "fab-github",
|
|
10
|
+
website: "fas-globe",
|
|
11
|
+
location: "fas-location-dot"
|
|
12
|
+
}.freeze
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -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
|
data/lib/my_last_cv/parser.rb
CHANGED
|
@@ -1,116 +1,170 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
|
|
1
3
|
module MyLastCV
|
|
2
4
|
class Parser
|
|
5
|
+
LineHandler = Struct.new(:pattern, :method)
|
|
6
|
+
|
|
7
|
+
HANDLERS = [
|
|
8
|
+
LineHandler.new(/^(name|title|email|phone|linkedin|github|website|location):\s*(.+)/i, :handle_contact),
|
|
9
|
+
LineHandler.new(/^#\s+(.*)/, :handle_title),
|
|
10
|
+
LineHandler.new(/^##\s+(.*)/, :handle_section),
|
|
11
|
+
LineHandler.new(/^###\s+(.*)/, :handle_element),
|
|
12
|
+
LineHandler.new(/^[-*]\s+(.*)/, :handle_bullet)
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
CONTACT_KEYS = %w[name title email phone linkedin github website location].freeze
|
|
16
|
+
|
|
3
17
|
def initialize(markdown)
|
|
4
18
|
@markdown = markdown
|
|
5
19
|
end
|
|
6
20
|
|
|
7
21
|
def parse
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return if text.empty?
|
|
23
|
-
target[:items] ||= []
|
|
24
|
-
target[:items] << { type: :paragraph, text: text }
|
|
22
|
+
setup_state
|
|
23
|
+
extract_front_matter!
|
|
24
|
+
@markdown.each_line do |raw|
|
|
25
|
+
line = raw.rstrip
|
|
26
|
+
|
|
27
|
+
stripped = line.strip
|
|
28
|
+
next if stripped.empty? || stripped == '---'
|
|
29
|
+
|
|
30
|
+
handler = HANDLERS.find { |h| h.pattern.match?(line) }
|
|
31
|
+
if handler
|
|
32
|
+
send(handler.method, line)
|
|
33
|
+
else
|
|
34
|
+
handle_text(line)
|
|
35
|
+
end
|
|
25
36
|
end
|
|
26
37
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
38
|
+
finalize
|
|
39
|
+
@result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def setup_state
|
|
45
|
+
@result = { sections: [] }
|
|
46
|
+
@current_section = nil
|
|
47
|
+
@current_element = nil
|
|
48
|
+
@pending = []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def extract_front_matter!
|
|
52
|
+
lines = @markdown.lines
|
|
53
|
+
return if lines.empty?
|
|
54
|
+
return unless lines.first&.strip == '---'
|
|
55
|
+
|
|
56
|
+
closing_index = lines[1..]&.find_index { |l| l.strip == '---' }
|
|
57
|
+
return unless closing_index
|
|
58
|
+
|
|
59
|
+
closing_index += 1
|
|
60
|
+
|
|
61
|
+
yaml_content = lines[1...closing_index].join
|
|
62
|
+
rest = lines[(closing_index + 1)..] || []
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
data = YAML.safe_load(yaml_content) || {}
|
|
66
|
+
rescue StandardError
|
|
67
|
+
data = {}
|
|
34
68
|
end
|
|
35
69
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (m = line.match(/^#\s+(.*)/)) # New header
|
|
41
|
-
if current_element
|
|
42
|
-
flush_pending_to.call(current_element)
|
|
43
|
-
elsif current_section
|
|
44
|
-
flush_pending_to.call(current_section)
|
|
45
|
-
else
|
|
46
|
-
flush_pending_nowhere.call
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
result[:name] = m[1].strip
|
|
50
|
-
current_section = nil
|
|
51
|
-
current_element = nil
|
|
52
|
-
|
|
53
|
-
elsif (m = line.match(/^(email|phone|location):\s*(.+)/i))
|
|
54
|
-
(result[:contact] ||= []) << m[2].strip
|
|
55
|
-
|
|
56
|
-
elsif (m = line.match(/^##\s+(.*)/)) # New section
|
|
57
|
-
if current_element
|
|
58
|
-
flush_pending_to.call(current_element)
|
|
59
|
-
elsif current_section
|
|
60
|
-
flush_pending_to.call(current_section)
|
|
61
|
-
else
|
|
62
|
-
flush_pending_nowhere.call
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
current_section = { title: m[1].strip, items: [], elements: [] }
|
|
66
|
-
result[:sections] << current_section
|
|
67
|
-
current_element = nil
|
|
68
|
-
|
|
69
|
-
elsif (m = line.match(/^###\s+(.*)/)) # New element
|
|
70
|
-
if current_element
|
|
71
|
-
flush_pending_to.call(current_element)
|
|
72
|
-
elsif current_section
|
|
73
|
-
flush_pending_to.call(current_section)
|
|
74
|
-
else
|
|
75
|
-
flush_pending_nowhere.call
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
current_section ||= { title: "Divers", items: [], elements: [] }
|
|
79
|
-
result[:sections] << current_section unless result[:sections].include?(current_section)
|
|
80
|
-
|
|
81
|
-
current_element = { title: m[1].strip, items: [] }
|
|
82
|
-
current_section[:elements] ||= []
|
|
83
|
-
current_section[:elements] << current_element
|
|
84
|
-
|
|
85
|
-
elsif (m = line.match(/^[-*]\s+(.*)/)) # Bullet point
|
|
86
|
-
item = { type: :bullet, text: m[1].strip }
|
|
87
|
-
if current_element
|
|
88
|
-
flush_pending_to.call(current_element)
|
|
89
|
-
current_element[:items] << item
|
|
90
|
-
elsif current_section
|
|
91
|
-
flush_pending_to.call(current_section)
|
|
92
|
-
current_section[:items] << item
|
|
93
|
-
else
|
|
94
|
-
current_section = { title: "Divers", items: [item], elements: [] }
|
|
95
|
-
result[:sections] << current_section
|
|
96
|
-
end
|
|
70
|
+
if data.is_a?(Hash)
|
|
71
|
+
# Prefer explicit YAML title if present
|
|
72
|
+
@result[:title] = data['title'].to_s.strip unless data['title'].to_s.strip.empty?
|
|
97
73
|
|
|
98
|
-
|
|
99
|
-
|
|
74
|
+
CONTACT_KEYS.each do |k|
|
|
75
|
+
v = data[k]
|
|
76
|
+
next if v.nil? || v.to_s.strip.empty?
|
|
77
|
+
@result[:contact] ||= {}
|
|
78
|
+
@result[:contact][k] = v.to_s.strip
|
|
100
79
|
end
|
|
101
80
|
end
|
|
102
81
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
82
|
+
@markdown = rest.join
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def flush_pending_to(target)
|
|
86
|
+
return if @pending.empty?
|
|
87
|
+
text = @pending.join(" ").strip
|
|
88
|
+
@pending.clear
|
|
89
|
+
return if text.empty?
|
|
90
|
+
target[:items] ||= []
|
|
91
|
+
target[:items] << { type: :paragraph, text: text }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def flush_pending_nowhere
|
|
95
|
+
return if @pending.empty?
|
|
96
|
+
text = @pending.join(" ").strip
|
|
97
|
+
@pending.clear
|
|
98
|
+
return if text.empty?
|
|
99
|
+
(@result[:intro] ||= []) << text
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def finalize
|
|
103
|
+
if @current_element
|
|
104
|
+
flush_pending_to(@current_element)
|
|
105
|
+
elsif @current_section
|
|
106
|
+
flush_pending_to(@current_section)
|
|
108
107
|
else
|
|
109
|
-
flush_pending_nowhere
|
|
108
|
+
flush_pending_nowhere
|
|
110
109
|
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def flush_pending_current
|
|
113
|
+
if @current_element
|
|
114
|
+
flush_pending_to(@current_element)
|
|
115
|
+
elsif @current_section
|
|
116
|
+
flush_pending_to(@current_section)
|
|
117
|
+
else
|
|
118
|
+
flush_pending_nowhere
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def handle_title(line)
|
|
123
|
+
flush_pending_current
|
|
124
|
+
@result[:title] = line.sub(/^#\s+/, "").strip
|
|
125
|
+
@current_section = nil
|
|
126
|
+
@current_element = nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def handle_contact(line)
|
|
130
|
+
key, value = line.split(":", 2)
|
|
131
|
+
@result[:contact] ||= {}
|
|
132
|
+
normalized_key = key.to_s.strip.downcase
|
|
133
|
+
@result[:contact][normalized_key] = value.to_s.strip
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def handle_section(line)
|
|
137
|
+
flush_pending_current
|
|
138
|
+
@current_section = { title: line.sub(/^##\s+/, "").strip, items: [], elements: [] }
|
|
139
|
+
@result[:sections] << @current_section
|
|
140
|
+
@current_element = nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def handle_element(line)
|
|
144
|
+
flush_pending_current
|
|
145
|
+
@current_section ||= { title: "Divers", items: [], elements: [] }
|
|
146
|
+
@result[:sections] << @current_section unless @result[:sections].include?(@current_section)
|
|
147
|
+
@current_element = { title: line.sub(/^###\s+/, "").strip, items: [] }
|
|
148
|
+
@current_section[:elements] ||= []
|
|
149
|
+
@current_section[:elements] << @current_element
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def handle_bullet(line)
|
|
153
|
+
item = { type: :bullet, text: line.sub(/^[-*]\s+/, "").strip }
|
|
154
|
+
if @current_element
|
|
155
|
+
flush_pending_to(@current_element)
|
|
156
|
+
@current_element[:items] << item
|
|
157
|
+
elsif @current_section
|
|
158
|
+
flush_pending_to(@current_section)
|
|
159
|
+
@current_section[:items] << item
|
|
160
|
+
else
|
|
161
|
+
@current_section = { title: "Divers", items: [item], elements: [] }
|
|
162
|
+
@result[:sections] << @current_section
|
|
163
|
+
end
|
|
164
|
+
end
|
|
111
165
|
|
|
112
|
-
|
|
113
|
-
|
|
166
|
+
def handle_text(line)
|
|
167
|
+
@pending << line.strip
|
|
114
168
|
end
|
|
115
169
|
end
|
|
116
170
|
end
|
data/lib/my_last_cv/renderer.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
require 'prawn'
|
|
2
|
+
require 'prawn/icon'
|
|
2
3
|
require 'fileutils'
|
|
4
|
+
require_relative 'icons'
|
|
5
|
+
require_relative 'inline'
|
|
3
6
|
|
|
4
7
|
module MyLastCV
|
|
5
8
|
class Renderer
|
|
@@ -35,7 +38,7 @@ module MyLastCV
|
|
|
35
38
|
fonts_dir = resolved_fonts_dir
|
|
36
39
|
return unless fonts_dir
|
|
37
40
|
|
|
38
|
-
#
|
|
41
|
+
# Example : Inter (Regular / Bold)
|
|
39
42
|
pdf.font_families.update(
|
|
40
43
|
"Inter" => {
|
|
41
44
|
normal: File.join(fonts_dir, "Inter-Regular.ttf"),
|
|
@@ -49,36 +52,41 @@ module MyLastCV
|
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
def render_header(pdf)
|
|
52
|
-
pdf.font(@style.header_font)
|
|
53
55
|
pdf.move_down 12
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
pdf.font(@style.body_font)
|
|
57
|
+
contact = @parsed_cv[:contact] || {}
|
|
58
|
+
contact.each_key do |key|
|
|
59
|
+
icon = "<icon size=\"#{@style.body_size}\">#{MyLastCV::Icons::MAP[key.to_sym]}</icon>"
|
|
60
|
+
pdf.icon("#{icon} #{Inline.parse(contact[key])}", inline_format: true)
|
|
56
61
|
end
|
|
62
|
+
|
|
57
63
|
pdf.move_down 6
|
|
58
|
-
pdf.font(@style.
|
|
59
|
-
pdf
|
|
64
|
+
pdf.font(@style.header_font)
|
|
65
|
+
with_color(pdf, @style.accent_color) do
|
|
66
|
+
pdf.text(@parsed_cv[:title] || '-', size: @style.header_size, align: :center)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
pdf.move_down 4
|
|
70
|
+
pdf.stroke_color(@style.accent_color)
|
|
71
|
+
pdf.stroke_horizontal_rule
|
|
60
72
|
|
|
61
73
|
if @parsed_cv[:intro]&.any?
|
|
62
74
|
pdf.move_down 10
|
|
63
75
|
@parsed_cv[:intro].each do |p|
|
|
64
|
-
pdf.text(p, size: @style.body_size, leading: 2)
|
|
65
|
-
pdf.move_down 4
|
|
76
|
+
pdf.text(Inline.parse(p), size: @style.body_size, leading: 2, inline_format: true)
|
|
66
77
|
end
|
|
67
|
-
else
|
|
68
|
-
pdf.move_down 4
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
with_color(pdf, @style.accent_color) do
|
|
72
|
-
pdf.stroke_horizontal_rule
|
|
73
78
|
end
|
|
74
|
-
pdf.move_down
|
|
79
|
+
pdf.move_down 10
|
|
75
80
|
end
|
|
76
81
|
|
|
77
82
|
def render_sections(pdf)
|
|
78
83
|
(@parsed_cv[:sections] || []).each do |section|
|
|
79
84
|
pdf.move_down 8
|
|
80
85
|
pdf.font(@style.section_font)
|
|
81
|
-
|
|
86
|
+
|
|
87
|
+
with_color(pdf, @style.accent_color) do
|
|
88
|
+
pdf.text(section[:title], size: @style.section_size, style: :bold)
|
|
89
|
+
end
|
|
82
90
|
|
|
83
91
|
render_items(pdf, section[:items])
|
|
84
92
|
|
|
@@ -100,9 +108,9 @@ module MyLastCV
|
|
|
100
108
|
(items || []).each do |item|
|
|
101
109
|
case item[:type]
|
|
102
110
|
when :bullet
|
|
103
|
-
pdf.text("• #{item[:text]}", size: @style.body_size, indent_paragraphs: 16)
|
|
111
|
+
pdf.text("• #{Inline.parse(item[:text])}", size: @style.body_size, indent_paragraphs: 16, inline_format: true)
|
|
104
112
|
when :paragraph
|
|
105
|
-
pdf.text(item[:text], size: @style.body_size, leading: 2)
|
|
113
|
+
pdf.text(Inline.parse(item[:text]), size: @style.body_size, leading: 2, inline_format: true)
|
|
106
114
|
pdf.move_down 4
|
|
107
115
|
else
|
|
108
116
|
# fallback (au cas où)
|
data/lib/my_last_cv/version.rb
CHANGED
data/lib/my_last_cv.rb
CHANGED
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.
|
|
4
|
+
version: 0.0.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Maxime Hanicotte
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '2.5'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: prawn-icon
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '4.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '4.1'
|
|
26
40
|
- !ruby/object:Gem::Dependency
|
|
27
41
|
name: rake
|
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -62,7 +76,13 @@ extra_rdoc_files: []
|
|
|
62
76
|
files:
|
|
63
77
|
- README.md
|
|
64
78
|
- exe/my_last_cv
|
|
79
|
+
- fonts/Inter-Bold.ttf
|
|
80
|
+
- fonts/Inter-Regular.ttf
|
|
81
|
+
- fonts/Lora-Bold.ttf
|
|
82
|
+
- fonts/Lora-Regular.ttf
|
|
65
83
|
- lib/my_last_cv.rb
|
|
84
|
+
- lib/my_last_cv/icons.rb
|
|
85
|
+
- lib/my_last_cv/inline.rb
|
|
66
86
|
- lib/my_last_cv/parser.rb
|
|
67
87
|
- lib/my_last_cv/renderer.rb
|
|
68
88
|
- lib/my_last_cv/style.rb
|
|
@@ -91,7 +111,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
91
111
|
- !ruby/object:Gem::Version
|
|
92
112
|
version: '0'
|
|
93
113
|
requirements: []
|
|
94
|
-
rubygems_version: 3.7.
|
|
114
|
+
rubygems_version: 3.7.2
|
|
95
115
|
specification_version: 4
|
|
96
116
|
summary: Generate your CV from Markdown to PDF
|
|
97
117
|
test_files: []
|