prawn-svg 0.36.2 → 0.38.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.
@@ -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,139 @@
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
+ name = GENERIC_CSS_FONT_MAPPING[name] if GENERIC_CSS_FONT_MAPPING.key?(name)
51
62
 
52
- def load_external_fonts
53
- @external_font_families = {}
63
+ return unless (subfamilies = installed_fonts[name])
64
+ return if subfamilies.empty?
54
65
 
55
- external_font_paths.each do |filename|
56
- ttf = Prawn::SVG::TTF.new(filename)
57
- next unless ttf.family
66
+ while weight
67
+ font = Font.new(name, weight, style)
68
+ return font if installed?(font)
69
+
70
+ weight = FONT_WEIGHT_FALLBACKS[weight]
71
+ end
58
72
 
59
- subfamily = (ttf.subfamily || 'normal').gsub(/\s+/, '_').downcase.to_sym
60
- subfamily = :normal if subfamily == :regular
61
- (external_font_families[ttf.family] ||= {})[subfamily] ||= filename
73
+ if style
74
+ find_suitable_font(name, weight, nil) unless style.nil?
75
+ else
76
+ Font.new(name, subfamilies.keys.first, nil)
62
77
  end
63
78
  end
64
79
 
65
- private
80
+ def installed?(font)
81
+ subfamilies = installed_fonts[font.name]
82
+ !subfamilies.nil? && subfamilies.key?(font.subfamily)
83
+ end
84
+
85
+ def weight_for_css_font_weight(weight)
86
+ case weight
87
+ when '100', '200', '300' then :light
88
+ when '400', '500', 'normal' then :normal
89
+ when '600' then :semibold
90
+ when '700', 'bold' then :bold
91
+ when '800' then :extrabold
92
+ when '900' then :black
93
+ else :normal # rubocop:disable Lint/DuplicateBranch
94
+ end
95
+ end
96
+
97
+ def merge_external_fonts
98
+ if @font_case_mapping.nil?
99
+ self.class.load_external_fonts unless self.class.external_font_families
100
+ @font_families.merge!(self.class.external_font_families) do |_key, v1, _v2|
101
+ v1
102
+ end
103
+ @font_case_mapping = @font_families.keys.each.with_object({}) do |key, result|
104
+ result[key.downcase] = key
105
+ end
106
+ GENERIC_CSS_FONT_MAPPING.each_key do |generic|
107
+ @font_case_mapping[generic] = generic
108
+ end
109
+ end
110
+ end
66
111
 
67
- def external_font_paths
68
- font_path
69
- .uniq
70
- .flat_map { |path| Dir["#{path}/**/*"] }
71
- .uniq
72
- .select { |path| File.file?(path) }
112
+ class << self
113
+ attr_reader :external_font_families, :font_path
114
+
115
+ def load_external_fonts
116
+ @external_font_families = {}
117
+
118
+ external_font_paths.each do |filename|
119
+ ttf = TTF.new(filename)
120
+ next unless ttf.family
121
+
122
+ subfamily = (ttf.subfamily || 'normal').gsub(/\s+/, '_').downcase.to_sym
123
+ subfamily = :normal if subfamily == :regular
124
+ (external_font_families[ttf.family] ||= {})[subfamily] ||= filename
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ def external_font_paths
131
+ font_path
132
+ .uniq
133
+ .flat_map { |path| Dir["#{path}/**/*"] }
134
+ .uniq
135
+ .select { |path| File.file?(path) }
136
+ end
73
137
  end
74
138
  end
75
139
  end
@@ -1,6 +1,6 @@
1
1
  module Prawn::SVG
2
2
  class Properties
3
- Config = Struct.new(:default, :inheritable?, :valid_values, :attr, :ivar)
3
+ Config = Struct.new(:default, :inheritable?, :valid_values, :attr, :ivar, :id)
4
4
 
5
5
  EM = 16
6
6
  FONT_SIZES = {
@@ -49,6 +49,7 @@ module Prawn::SVG
49
49
 
50
50
  PROPERTIES.each do |name, value|
51
51
  value.attr = name.gsub('-', '_')
52
+ value.id = value.attr.to_sym
52
53
  value.ivar = "@#{value.attr}"
53
54
  end
54
55
 
@@ -57,9 +58,11 @@ module Prawn::SVG
57
58
  ATTR_NAMES = PROPERTIES.keys.map { |name| name.gsub('-', '_') }
58
59
 
59
60
  attr_accessor(*ATTR_NAMES)
61
+ attr_reader :important_ids
60
62
 
61
63
  def initialize
62
64
  @numeric_font_size = EM
65
+ @important_ids = []
63
66
  end
64
67
 
65
68
  def load_default_stylesheet
@@ -70,10 +73,11 @@ module Prawn::SVG
70
73
  self
71
74
  end
72
75
 
73
- def set(name, value)
76
+ def set(name, value, important: false)
74
77
  name = name.to_s.downcase
75
78
  if (config = PROPERTIES[name])
76
- if (value = parse_value(config, value.strip))
79
+ if (value = parse_value(config, value.strip)) && (important || !@important_ids.include?(config.id))
80
+ @important_ids << config.id if important
77
81
  instance_variable_set(config.ivar, value)
78
82
  end
79
83
  elsif name == 'font'
@@ -99,7 +103,7 @@ module Prawn::SVG
99
103
  PROPERTY_CONFIGS.each do |config|
100
104
  value = other.send(config.attr)
101
105
 
102
- if value && value != 'inherit'
106
+ if value && value != 'inherit' && (!@important_ids.include?(config.id) || other.important_ids.include?(config.id))
103
107
  instance_variable_set(config.ivar, value)
104
108
 
105
109
  elsif value.nil? && !config.inheritable?
@@ -107,6 +111,7 @@ module Prawn::SVG
107
111
  end
108
112
  end
109
113
 
114
+ @important_ids += other.important_ids
110
115
  @numeric_font_size = calculate_numeric_font_size
111
116
  nil
112
117
  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,32 @@ 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
106
- prawn.send(call, *arguments)
107
- end
108
- elsif RUBY_VERSION >= '2.7' || !kwarguments.empty?
109
- prawn.send(call, *arguments, **kwarguments, &proc_creator(prawn, children))
107
+ prawn.send(call, *arguments, **kwarguments)
110
108
  else
111
- prawn.send(call, *arguments, &proc_creator(prawn, children))
109
+ prawn.send(call, *arguments, **kwarguments, &proc_creator(prawn, children))
112
110
  end
113
111
  end
114
112
  end
115
113
 
116
114
  def rewrite_call_arguments(prawn, call, arguments, kwarguments)
117
115
  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]]
136
- end
137
-
138
- if (offset = options.delete(:offset))
139
- at[0] += offset[0]
140
- at[1] -= offset[1]
116
+ when 'svg:render'
117
+ element = arguments.first
118
+ raise ArgumentError, "Expected a Prawn::SVG::Elements::DirectRenderBase, got #{element.class}" unless element.is_a?(Prawn::SVG::Elements::DirectRenderBase)
119
+
120
+ begin
121
+ element.render(prawn, self)
122
+ rescue Prawn::SVG::Elements::Base::SkipElementQuietly
123
+ rescue Prawn::SVG::Elements::Base::SkipElementError => e
124
+ @document.warnings << e.message
141
125
  end
142
126
 
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
127
+ yield
168
128
 
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
129
+ when 'svg:yield'
130
+ block = arguments.first
131
+ block.call
132
+ yield
177
133
 
178
134
  when 'transformation_matrix'
179
135
  left = prawn.bounds.absolute_left
@@ -1,5 +1,5 @@
1
1
  module Prawn
2
2
  module SVG
3
- VERSION = '0.36.2'.freeze
3
+ VERSION = '0.38.0'.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'
@@ -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 }
@@ -62,7 +62,6 @@ RSpec.describe Prawn::SVG::CSS::Stylesheets do
62
62
  width_and_styles = result.map { |k, v| [k.attributes['width'].to_i, v] }.sort_by(&:first)
63
63
 
64
64
  expected = [
65
- [0, [['overflow', 'hidden', false]]],
66
65
  [1, [['fill', '#ff0000', false]]],
67
66
  [2,
68
67
  [['fill', '#ff0000', false], ['fill', '#330000', false], ['fill', '#440000', false],
@@ -121,7 +120,6 @@ RSpec.describe Prawn::SVG::CSS::Stylesheets do
121
120
  it 'scans the document for style tags and adds the style information to the css parser' do
122
121
  css_parser = instance_double(CssParser::Parser)
123
122
 
124
- expect(css_parser).to receive(:add_block!).with('svg, symbol, image, marker, pattern, foreignObject { overflow: hidden }')
125
123
  expect(css_parser).to receive(:add_block!).with("a\n before>\n x y\n inside <>&gt;\n k j\n after\nz")
126
124
  expect(css_parser).to receive(:add_block!).with('hello')
127
125
  allow(css_parser).to receive(:each_rule_set)
@@ -175,8 +175,8 @@ describe Prawn::SVG::Elements::Base do
175
175
  </style>
176
176
  <rect width="100" height="100"></rect>
177
177
  <g class="special">
178
- <rect width="100" height="100"></rect>
179
- <rect width="100" height="100" style="fill: yellow;"></rect>
178
+ <rect width="100" fill="blue" height="100"></rect>
179
+ <rect width="100" height="100" fill="blue" style="fill: yellow;"></rect>
180
180
  </g>
181
181
  </svg>
182
182
  SVG