prawn-svg 0.37.0 → 0.38.1

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.
@@ -1,65 +1,21 @@
1
- class Prawn::SVG::Font
2
- GENERIC_CSS_FONT_MAPPING = {
3
- 'serif' => 'Times-Roman',
4
- 'sans-serif' => 'Helvetica',
5
- 'cursive' => 'Times-Roman',
6
- 'fantasy' => 'Times-Roman',
7
- 'monospace' => 'Courier'
8
- }.freeze
1
+ module Prawn::SVG
2
+ class Font
3
+ attr_reader :name, :weight, :style
9
4
 
10
- attr_reader :name, :weight, :style
11
-
12
- def self.weight_for_css_font_weight(weight)
13
- case weight
14
- when '100', '200', '300' then :light
15
- when '400', '500', 'normal' then :normal
16
- when '600' then :semibold
17
- when '700', 'bold' then :bold
18
- when '800' then :extrabold
19
- when '900' then :black
20
- end
21
- end
22
-
23
- def initialize(name, weight, style, font_registry: nil)
24
- @font_registry = font_registry
25
- unless font_registry.installed_fonts.key?(name)
26
- # map generic font name to one of the built-in PDF fonts if not already mapped
27
- name = GENERIC_CSS_FONT_MAPPING[name] || name
5
+ def initialize(name, weight, style)
6
+ @name = name
7
+ @weight = weight
8
+ @style = style
28
9
  end
29
- @name = font_registry.correctly_cased_font_name(name) || name
30
- @weight = weight
31
- @style = style
32
- end
33
-
34
- def installed?
35
- subfamilies = @font_registry.installed_fonts[name]
36
- !subfamilies.nil? && subfamilies.key?(subfamily)
37
- end
38
10
 
39
- # Construct a subfamily name, ensuring that the subfamily is a valid one for the font.
40
- def subfamily
41
- if (subfamilies = @font_registry.installed_fonts[name])
42
- if subfamilies.key?(subfamily_name)
43
- subfamily_name
44
- elsif subfamilies.key?(:normal)
45
- :normal
11
+ def subfamily
12
+ if weight == :normal && style
13
+ style
14
+ elsif weight || style
15
+ [weight, style].compact.join('_').to_sym
46
16
  else
47
- subfamilies.keys.first
17
+ :normal
48
18
  end
49
19
  end
50
20
  end
51
-
52
- private
53
-
54
- # Construct a subfamily name from the weight and style information.
55
- # Note that this name might not actually exist in the font.
56
- def subfamily_name
57
- if weight == :normal && style
58
- style
59
- elsif weight || style
60
- [weight, style].compact.join('_').to_sym
61
- else
62
- :normal
63
- end
64
- end
65
21
  end
@@ -0,0 +1,97 @@
1
+ class Prawn::SVG::FontMetrics
2
+ class << self
3
+ # Default x-height as a fraction of font size (typical for most fonts)
4
+ DEFAULT_X_HEIGHT_RATIO = 0.5
5
+
6
+ def x_height_in_points(pdf, font_size)
7
+ @x_height_cache ||= {}
8
+
9
+ cache_key = cache_key_for(pdf)
10
+
11
+ @x_height_cache[cache_key] ||= calculate_x_height_ratio(pdf)
12
+ @x_height_cache[cache_key] * font_size
13
+ end
14
+
15
+ def underline_metrics(pdf, size)
16
+ @underline_metrics_cache ||= {}
17
+
18
+ cache_key = cache_key_for(pdf)
19
+
20
+ @underline_metrics_cache[cache_key] ||= fetch_underline_metrics(pdf, size)
21
+ @underline_metrics_cache[cache_key]
22
+ end
23
+
24
+ private
25
+
26
+ def cache_key_for(pdf)
27
+ return 'default' unless pdf && pdf.font.is_a?(Prawn::Fonts::TTF)
28
+
29
+ ttf = pdf.font.ttf
30
+ return 'default' unless ttf
31
+
32
+ # Use font family name from TTF metadata, which doesn't include size
33
+ ttf.name&.font_family&.first || pdf&.font&.name || 'default'
34
+ end
35
+
36
+ def calculate_x_height_ratio(pdf)
37
+ return DEFAULT_X_HEIGHT_RATIO unless pdf && pdf.font.is_a?(Prawn::Fonts::TTF)
38
+
39
+ ttf = pdf.font.ttf
40
+ return DEFAULT_X_HEIGHT_RATIO unless ttf
41
+
42
+ units_per_em = ttf.header&.units_per_em&.to_f
43
+ return DEFAULT_X_HEIGHT_RATIO unless units_per_em&.positive?
44
+
45
+ cmap = ttf.cmap&.unicode&.first
46
+ return DEFAULT_X_HEIGHT_RATIO unless cmap
47
+
48
+ xid = cmap['x'.ord]
49
+ return DEFAULT_X_HEIGHT_RATIO unless xid
50
+
51
+ bbox = ttf.glyph_outlines&.for(xid)
52
+ return DEFAULT_X_HEIGHT_RATIO unless bbox
53
+
54
+ y_max = bbox.y_max
55
+ y_min = bbox.y_min
56
+ return DEFAULT_X_HEIGHT_RATIO unless y_max && y_min
57
+
58
+ glyph_height_units = y_max - y_min
59
+ return DEFAULT_X_HEIGHT_RATIO if glyph_height_units <= 0
60
+
61
+ glyph_height_units / units_per_em
62
+ end
63
+
64
+ def fetch_underline_metrics(pdf, size)
65
+ units_per_em = nil
66
+ pos_units = thick_units = nil
67
+ if pdf.font.is_a?(Prawn::Font::TTF)
68
+ ttf = begin
69
+ pdf.font.ttf
70
+ rescue StandardError
71
+ nil
72
+ end
73
+ if ttf.respond_to?(:post) && ttf.post && ttf.respond_to?(:header) && ttf.header
74
+ units_per_em = ttf.header.units_per_em.to_f
75
+ pos_units = ttf.post.underline_position.to_f
76
+ thick_units = ttf.post.underline_thickness.to_f
77
+ end
78
+ end
79
+
80
+ offset =
81
+ if units_per_em && pos_units
82
+ (pos_units / units_per_em) * size
83
+ else
84
+ -0.12 * size
85
+ end
86
+
87
+ thick =
88
+ if units_per_em && thick_units&.positive?
89
+ [(thick_units / units_per_em) * size, 0.5].max
90
+ else
91
+ [size * 0.06, 0.5].max
92
+ end
93
+
94
+ [offset, thick]
95
+ end
96
+ end
97
+ end
@@ -1,75 +1,140 @@
1
- class Prawn::SVG::FontRegistry
2
- DEFAULT_FONT_PATHS = [
3
- '/Library/Fonts',
4
- '/System/Library/Fonts',
5
- "#{Dir.home}/Library/Fonts",
6
- '/usr/share/fonts/truetype',
7
- '/mnt/c/Windows/Fonts' # Bash on Ubuntu on Windows
8
- ].freeze
9
-
10
- @font_path = DEFAULT_FONT_PATHS.select { |path| Dir.exist?(path) }
11
-
12
- def initialize(font_families)
13
- @font_families = font_families
14
- end
1
+ module Prawn::SVG
2
+ class FontRegistry
3
+ GENERIC_CSS_FONT_MAPPING = {
4
+ 'serif' => 'Times-Roman',
5
+ 'sans-serif' => 'Helvetica',
6
+ 'cursive' => 'Times-Roman',
7
+ 'fantasy' => 'Times-Roman',
8
+ 'monospace' => 'Courier'
9
+ }.freeze
15
10
 
16
- def installed_fonts
17
- merge_external_fonts
18
- @font_families
19
- end
11
+ FONT_WEIGHT_FALLBACKS = {
12
+ light: :normal,
13
+ normal: nil,
14
+ semibold: :bold,
15
+ bold: :normal,
16
+ extrabold: :bold,
17
+ black: :extrabold
18
+ }.freeze
20
19
 
21
- def correctly_cased_font_name(name)
22
- merge_external_fonts
23
- @font_case_mapping[name.downcase]
24
- end
20
+ FONT_WEIGHTS = FONT_WEIGHT_FALLBACKS.keys.freeze
21
+
22
+ DEFAULT_FONT_PATHS = [
23
+ '/Library/Fonts',
24
+ '/System/Library/Fonts',
25
+ "#{Dir.home}/Library/Fonts",
26
+ '/usr/share/fonts/truetype',
27
+ '/mnt/c/Windows/Fonts' # Bash on Ubuntu on Windows
28
+ ].freeze
25
29
 
26
- def load(family, weight = nil, style = nil)
27
- Prawn::SVG::CSS::FontFamilyParser.parse(family).detect do |name|
28
- name = name.gsub(/\s{2,}/, ' ').downcase
30
+ @font_path = DEFAULT_FONT_PATHS.select { |path| Dir.exist?(path) }
29
31
 
30
- font = Prawn::SVG::Font.new(name, weight, style, font_registry: self)
31
- break font if font.installed?
32
+ def initialize(font_families)
33
+ @font_families = font_families
32
34
  end
33
- end
34
35
 
35
- private
36
+ def installed_fonts
37
+ merge_external_fonts
38
+ @font_families
39
+ end
36
40
 
37
- def merge_external_fonts
38
- if @font_case_mapping.nil?
39
- self.class.load_external_fonts unless self.class.external_font_families
40
- @font_families.merge!(self.class.external_font_families) do |_key, v1, _v2|
41
- v1
42
- end
43
- @font_case_mapping = @font_families.keys.each.with_object({}) do |key, result|
44
- result[key.downcase] = key
41
+ def correctly_cased_font_name(name)
42
+ merge_external_fonts
43
+ @font_case_mapping[name.downcase]
44
+ end
45
+
46
+ def load(family, weight = nil, style = nil)
47
+ weight = weight_for_css_font_weight(weight) unless FONT_WEIGHTS.include?(weight)
48
+
49
+ CSS::FontFamilyParser.parse(family).detect do |name|
50
+ name = name.gsub(/\s{2,}/, ' ')
51
+
52
+ font = find_suitable_font(name, weight, style)
53
+ break font if font
45
54
  end
46
55
  end
47
- end
48
56
 
49
- class << self
50
- attr_reader :external_font_families, :font_path
57
+ private
58
+
59
+ def find_suitable_font(name, weight, style)
60
+ name = correctly_cased_font_name(name) || name
61
+
62
+ name = GENERIC_CSS_FONT_MAPPING[name] if GENERIC_CSS_FONT_MAPPING.key?(name) && !installed_fonts.key?(name)
63
+
64
+ return unless (subfamilies = installed_fonts[name])
65
+ return if subfamilies.empty?
51
66
 
52
- def load_external_fonts
53
- @external_font_families = {}
67
+ while weight
68
+ font = Font.new(name, weight, style)
69
+ return font if installed?(font)
54
70
 
55
- external_font_paths.each do |filename|
56
- ttf = Prawn::SVG::TTF.new(filename)
57
- next unless ttf.family
71
+ weight = FONT_WEIGHT_FALLBACKS[weight]
72
+ end
58
73
 
59
- subfamily = (ttf.subfamily || 'normal').gsub(/\s+/, '_').downcase.to_sym
60
- subfamily = :normal if subfamily == :regular
61
- (external_font_families[ttf.family] ||= {})[subfamily] ||= filename
74
+ if style
75
+ find_suitable_font(name, weight, nil) unless style.nil?
76
+ else
77
+ Font.new(name, subfamilies.keys.first, nil)
62
78
  end
63
79
  end
64
80
 
65
- private
81
+ def installed?(font)
82
+ subfamilies = installed_fonts[font.name]
83
+ !subfamilies.nil? && subfamilies.key?(font.subfamily)
84
+ end
85
+
86
+ def weight_for_css_font_weight(weight)
87
+ case weight
88
+ when '100', '200', '300' then :light
89
+ when '400', '500', 'normal' then :normal
90
+ when '600' then :semibold
91
+ when '700', 'bold' then :bold
92
+ when '800' then :extrabold
93
+ when '900' then :black
94
+ else :normal # rubocop:disable Lint/DuplicateBranch
95
+ end
96
+ end
97
+
98
+ def merge_external_fonts
99
+ if @font_case_mapping.nil?
100
+ self.class.load_external_fonts unless self.class.external_font_families
101
+ @font_families.merge!(self.class.external_font_families) do |_key, v1, _v2|
102
+ v1
103
+ end
104
+ @font_case_mapping = @font_families.keys.each.with_object({}) do |key, result|
105
+ result[key.downcase] = key
106
+ end
107
+ GENERIC_CSS_FONT_MAPPING.each_key do |generic|
108
+ @font_case_mapping[generic] = generic
109
+ end
110
+ end
111
+ end
112
+
113
+ class << self
114
+ attr_reader :external_font_families, :font_path
115
+
116
+ def load_external_fonts
117
+ @external_font_families = {}
118
+
119
+ external_font_paths.each do |filename|
120
+ ttf = TTF.new(filename)
121
+ next unless ttf.family
66
122
 
67
- def external_font_paths
68
- font_path
69
- .uniq
70
- .flat_map { |path| Dir["#{path}/**/*"] }
71
- .uniq
72
- .select { |path| File.file?(path) }
123
+ subfamily = (ttf.subfamily || 'normal').gsub(/\s+/, '_').downcase.to_sym
124
+ subfamily = :normal if subfamily == :regular
125
+ (external_font_families[ttf.family] ||= {})[subfamily] ||= filename
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def external_font_paths
132
+ font_path
133
+ .uniq
134
+ .flat_map { |path| Dir["#{path}/**/*"] }
135
+ .uniq
136
+ .select { |path| File.file?(path) }
137
+ end
73
138
  end
74
139
  end
75
140
  end
@@ -50,6 +50,10 @@ module Prawn
50
50
  options[:at] || [x_based_on_requested_alignment, y_based_on_requested_alignment]
51
51
  end
52
52
 
53
+ def render_calls(prawn, calls)
54
+ issue_prawn_command(prawn, calls)
55
+ end
56
+
53
57
  private
54
58
 
55
59
  def x_based_on_requested_alignment
@@ -100,80 +104,38 @@ module Prawn
100
104
  if skip
101
105
  # the call has been overridden
102
106
  elsif children.empty? && call != 'transparent' # some prawn calls complain if they aren't supplied a block
103
- if RUBY_VERSION >= '2.7' || !kwarguments.empty?
104
- prawn.send(call, *arguments, **kwarguments)
105
- else
107
+ if kwarguments.empty?
106
108
  prawn.send(call, *arguments)
109
+ else
110
+ prawn.send(call, *arguments, **kwarguments)
107
111
  end
108
- elsif RUBY_VERSION >= '2.7' || !kwarguments.empty?
109
- prawn.send(call, *arguments, **kwarguments, &proc_creator(prawn, children))
110
- else
112
+ elsif kwarguments.empty?
111
113
  prawn.send(call, *arguments, &proc_creator(prawn, children))
114
+ else
115
+ prawn.send(call, *arguments, **kwarguments, &proc_creator(prawn, children))
112
116
  end
113
117
  end
114
118
  end
115
119
 
116
120
  def rewrite_call_arguments(prawn, call, arguments, kwarguments)
117
121
  case call
118
- when 'text_group'
119
- @cursor = [0, sizing.output_height]
120
- yield
121
-
122
- when 'draw_text'
123
- text = arguments.first
124
- options = kwarguments
125
-
126
- at = options.fetch(:at)
127
-
128
- at[0] = @cursor[0] if at[0] == :relative
129
- at[1] = @cursor[1] if at[1] == :relative
130
-
131
- case options.delete(:dominant_baseline)
132
- when 'middle'
133
- height = prawn.font.height
134
- at[1] -= height / 2.0
135
- @cursor = [at[0], at[1]]
122
+ when 'svg:render'
123
+ element = arguments.first
124
+ raise ArgumentError, "Expected a Prawn::SVG::Elements::DirectRenderBase, got #{element.class}" unless element.is_a?(Prawn::SVG::Elements::DirectRenderBase)
125
+
126
+ begin
127
+ element.render(prawn, self)
128
+ rescue Prawn::SVG::Elements::Base::SkipElementQuietly
129
+ rescue Prawn::SVG::Elements::Base::SkipElementError => e
130
+ @document.warnings << e.message
136
131
  end
137
132
 
138
- if (offset = options.delete(:offset))
139
- at[0] += offset[0]
140
- at[1] -= offset[1]
141
- end
142
-
143
- width = prawn.width_of(text, options.merge(kerning: true))
144
-
145
- if (stretch_to_width = options.delete(:stretch_to_width))
146
- factor = stretch_to_width.to_f * 100 / width.to_f
147
- prawn.add_content "#{factor} Tz"
148
- width = stretch_to_width.to_f
149
- end
150
-
151
- if (pad_to_width = options.delete(:pad_to_width))
152
- padding_required = pad_to_width.to_f - width.to_f
153
- padding_per_character = padding_required / text.length.to_f
154
- prawn.add_content "#{padding_per_character} Tc"
155
- width = pad_to_width.to_f
156
- end
157
-
158
- case options.delete(:text_anchor)
159
- when 'middle'
160
- at[0] -= width / 2
161
- @cursor = [at[0] + (width / 2), at[1]]
162
- when 'end'
163
- at[0] -= width
164
- @cursor = at.dup
165
- else
166
- @cursor = [at[0] + width, at[1]]
167
- end
133
+ yield
168
134
 
169
- decoration = options.delete(:decoration)
170
- if decoration == 'underline'
171
- prawn.save_graphics_state do
172
- prawn.line_width 1
173
- prawn.line [at[0], at[1] - 1.25], [at[0] + width, at[1] - 1.25]
174
- prawn.stroke
175
- end
176
- end
135
+ when 'svg:yield'
136
+ block = arguments.first
137
+ block.call
138
+ yield
177
139
 
178
140
  when 'transformation_matrix'
179
141
  left = prawn.bounds.absolute_left
@@ -1,5 +1,5 @@
1
1
  module Prawn
2
2
  module SVG
3
- VERSION = '0.37.0'.freeze
3
+ VERSION = '0.38.1'.freeze
4
4
  end
5
5
  end
data/lib/prawn-svg.rb CHANGED
@@ -6,6 +6,7 @@ require 'prawn/svg/version'
6
6
  require 'css_parser'
7
7
 
8
8
  require 'prawn/svg/font_registry'
9
+ require 'prawn/svg/font_metrics'
9
10
  require 'prawn/svg/calculators/arc_to_bezier_curve'
10
11
  require 'prawn/svg/calculators/aspect_ratio'
11
12
  require 'prawn/svg/calculators/document_sizing'
data/prawn-svg.gemspec CHANGED
@@ -22,5 +22,5 @@ Gem::Specification.new do |gem|
22
22
  gem.add_dependency 'css_parser', '~> 1.6'
23
23
  gem.add_dependency 'matrix', '~> 0.4.2'
24
24
  gem.add_dependency 'prawn', '>= 0.11.1', '< 3'
25
- gem.add_dependency 'rexml', '>= 3.3.9', '< 4'
25
+ gem.add_dependency 'rexml', '>= 3.4.2', '< 4'
26
26
  end
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env fish
2
+
3
+ if test (count $argv) -ne 2
4
+ echo "Usage: compare_samples [file containing sample output files] [new branch name]"
5
+ echo
6
+ echo A handy script that generates a sample SVG on both the main branch and the specified branch, and displays
7
+ echo them alongside the original SVG as rendered by Arc. Used to see whether there have been regressions or
8
+ echo improvements in rendering code changes.
9
+ exit 1
10
+ end
11
+
12
+ set files $argv[1]
13
+ set branch $argv[2]
14
+
15
+ set i (cat $files | string trim | fzf)
16
+ and echo $i
17
+ and git switch main
18
+ and bundle exec rspec -e (echo $i | string replace 'spec/sample_output/' '' | string replace '.pdf' '')
19
+ and cp $i original.pdf
20
+ and git switch $branch
21
+ and bundle exec rspec -e (echo $i | string replace 'spec/sample_output/' '' | string replace '.pdf' '')
22
+ and cp $i new.pdf
23
+ and open -n original.pdf
24
+ and open -n new.pdf
25
+ and open -a Arc (echo $i | string replace sample_output sample_svg | string replace .pdf '')
@@ -10,6 +10,10 @@ describe Prawn::SVG::Attributes::Transform do
10
10
  @warnings = []
11
11
  @attributes = {}
12
12
  end
13
+
14
+ def transformable?
15
+ true
16
+ end
13
17
  end
14
18
 
15
19
  let(:element) { TransformTestElement.new }
@@ -73,7 +73,7 @@ RSpec.describe Prawn::SVG::CSS::Stylesheets do
73
73
  ]
74
74
 
75
75
  expected << [5,
76
- [['fill', '#ff0000', false], ['fill', '#330000', false], ['fill', '#330000', false], ['fill', '#440000', false],
76
+ [['fill', '#ff0000', false], ['fill', '#330000', false], ['fill', '#440000', false],
77
77
  ['fill', '#00ff00', false]]]
78
78
 
79
79
  expected.push(