prawnx 0.1.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.
- checksums.yaml +7 -0
- data/COPYING +2 -0
- data/GPLv2 +339 -0
- data/GPLv3 +674 -0
- data/LICENSE +65 -0
- data/LICENSE-prawn-svg +21 -0
- data/README.md +107 -0
- data/data/fonts/Courier-Bold.afm +342 -0
- data/data/fonts/Courier-BoldOblique.afm +342 -0
- data/data/fonts/Courier-Oblique.afm +342 -0
- data/data/fonts/Courier.afm +342 -0
- data/data/fonts/Helvetica-Bold.afm +2827 -0
- data/data/fonts/Helvetica-BoldOblique.afm +2827 -0
- data/data/fonts/Helvetica-Oblique.afm +3051 -0
- data/data/fonts/Helvetica.afm +3051 -0
- data/data/fonts/MustRead.html +19 -0
- data/data/fonts/Symbol.afm +213 -0
- data/data/fonts/Times-Bold.afm +2588 -0
- data/data/fonts/Times-BoldItalic.afm +2384 -0
- data/data/fonts/Times-Italic.afm +2667 -0
- data/data/fonts/Times-Roman.afm +2419 -0
- data/data/fonts/ZapfDingbats.afm +225 -0
- data/lib/pdf/core.rb +4 -0
- data/lib/prawn/measurement_extensions.rb +4 -0
- data/lib/prawn/svg/attributes/clip_path.rb +46 -0
- data/lib/prawn/svg/attributes/mask.rb +16 -0
- data/lib/prawn/svg/attributes/opacity.rb +30 -0
- data/lib/prawn/svg/attributes/space.rb +10 -0
- data/lib/prawn/svg/attributes/stroke.rb +50 -0
- data/lib/prawn/svg/attributes/transform.rb +10 -0
- data/lib/prawn/svg/attributes.rb +6 -0
- data/lib/prawn/svg/calculators/arc_to_bezier_curve.rb +57 -0
- data/lib/prawn/svg/calculators/aspect_ratio.rb +60 -0
- data/lib/prawn/svg/calculators/document_sizing.rb +103 -0
- data/lib/prawn/svg/calculators/path_length.rb +168 -0
- data/lib/prawn/svg/calculators/pixels.rb +57 -0
- data/lib/prawn/svg/color.rb +240 -0
- data/lib/prawn/svg/css/font_face_parser.rb +72 -0
- data/lib/prawn/svg/css/font_family_parser.rb +39 -0
- data/lib/prawn/svg/css/font_parser.rb +46 -0
- data/lib/prawn/svg/css/selector_parser.rb +178 -0
- data/lib/prawn/svg/css/stylesheets.rb +233 -0
- data/lib/prawn/svg/css/values_parser.rb +68 -0
- data/lib/prawn/svg/document.rb +170 -0
- data/lib/prawn/svg/elements/anchor.rb +10 -0
- data/lib/prawn/svg/elements/base.rb +312 -0
- data/lib/prawn/svg/elements/bbox_scaling.rb +78 -0
- data/lib/prawn/svg/elements/call_duplicator.rb +37 -0
- data/lib/prawn/svg/elements/circle.rb +25 -0
- data/lib/prawn/svg/elements/clip_path.rb +26 -0
- data/lib/prawn/svg/elements/container.rb +10 -0
- data/lib/prawn/svg/elements/direct_render_base.rb +27 -0
- data/lib/prawn/svg/elements/ellipse.rb +22 -0
- data/lib/prawn/svg/elements/gradient.rb +184 -0
- data/lib/prawn/svg/elements/ignored.rb +5 -0
- data/lib/prawn/svg/elements/image.rb +177 -0
- data/lib/prawn/svg/elements/line.rb +27 -0
- data/lib/prawn/svg/elements/marker.rb +72 -0
- data/lib/prawn/svg/elements/mask.rb +65 -0
- data/lib/prawn/svg/elements/path.rb +294 -0
- data/lib/prawn/svg/elements/pattern.rb +150 -0
- data/lib/prawn/svg/elements/polygon.rb +25 -0
- data/lib/prawn/svg/elements/polyline.rb +23 -0
- data/lib/prawn/svg/elements/rect.rb +64 -0
- data/lib/prawn/svg/elements/root.rb +22 -0
- data/lib/prawn/svg/elements/switch.rb +92 -0
- data/lib/prawn/svg/elements/text.rb +99 -0
- data/lib/prawn/svg/elements/text_component.rb +346 -0
- data/lib/prawn/svg/elements/text_node.rb +308 -0
- data/lib/prawn/svg/elements/text_path.rb +153 -0
- data/lib/prawn/svg/elements/use.rb +142 -0
- data/lib/prawn/svg/elements/viewport.rb +29 -0
- data/lib/prawn/svg/elements.rb +46 -0
- data/lib/prawn/svg/extension.rb +25 -0
- data/lib/prawn/svg/font.rb +20 -0
- data/lib/prawn/svg/font_metrics.rb +144 -0
- data/lib/prawn/svg/font_registry.rb +315 -0
- data/lib/prawn/svg/funciri.rb +14 -0
- data/lib/prawn/svg/gradient_renderer.rb +332 -0
- data/lib/prawn/svg/gradients.rb +53 -0
- data/lib/prawn/svg/interface.rb +72 -0
- data/lib/prawn/svg/length.rb +43 -0
- data/lib/prawn/svg/link_renderer.rb +40 -0
- data/lib/prawn/svg/loaders/data.rb +17 -0
- data/lib/prawn/svg/loaders/file.rb +111 -0
- data/lib/prawn/svg/loaders/web.rb +24 -0
- data/lib/prawn/svg/paint.rb +67 -0
- data/lib/prawn/svg/pathable.rb +153 -0
- data/lib/prawn/svg/pattern_renderer.rb +112 -0
- data/lib/prawn/svg/pdfmatrix.rb +37 -0
- data/lib/prawn/svg/percentage.rb +24 -0
- data/lib/prawn/svg/properties.rb +329 -0
- data/lib/prawn/svg/renderer.rb +249 -0
- data/lib/prawn/svg/state.rb +24 -0
- data/lib/prawn/svg/transform_parser.rb +78 -0
- data/lib/prawn/svg/ttc.rb +35 -0
- data/lib/prawn/svg/ttf.rb +95 -0
- data/lib/prawn/svg/url_loader.rb +46 -0
- data/lib/prawn/svg/version.rb +5 -0
- data/lib/prawn/table.rb +4 -0
- data/lib/prawn-svg.rb +53 -0
- data/lib/prawn.rb +10 -0
- data/lib/prawnx/annotations.rb +21 -0
- data/lib/prawnx/document/bounding_box.rb +193 -0
- data/lib/prawnx/document/column_box.rb +91 -0
- data/lib/prawnx/document/internals.rb +144 -0
- data/lib/prawnx/document/span.rb +25 -0
- data/lib/prawnx/document.rb +253 -0
- data/lib/prawnx/encoding.rb +29 -0
- data/lib/prawnx/errors.rb +37 -0
- data/lib/prawnx/font.rb +214 -0
- data/lib/prawnx/fonts/afm.rb +171 -0
- data/lib/prawnx/fonts/otf.rb +50 -0
- data/lib/prawnx/fonts/ttf.rb +410 -0
- data/lib/prawnx/graphic_state.rb +41 -0
- data/lib/prawnx/graphics/color.rb +148 -0
- data/lib/prawnx/graphics/patterns.rb +100 -0
- data/lib/prawnx/graphics/style.rb +171 -0
- data/lib/prawnx/graphics.rb +208 -0
- data/lib/prawnx/grid.rb +216 -0
- data/lib/prawnx/images/image.rb +244 -0
- data/lib/prawnx/images/png_decoder.rb +65 -0
- data/lib/prawnx/images.rb +115 -0
- data/lib/prawnx/measurement_extensions.rb +40 -0
- data/lib/prawnx/measurements.rb +62 -0
- data/lib/prawnx/outline.rb +146 -0
- data/lib/prawnx/page.rb +125 -0
- data/lib/prawnx/page_geometry.rb +27 -0
- data/lib/prawnx/pdf/outline_node.rb +53 -0
- data/lib/prawnx/pdf/reference.rb +31 -0
- data/lib/prawnx/pdf/writer.rb +105 -0
- data/lib/prawnx/pdf.rb +66 -0
- data/lib/prawnx/pdf_a/srgb_profile.rb +61 -0
- data/lib/prawnx/pdf_a.rb +130 -0
- data/lib/prawnx/pdf_core.rb +25 -0
- data/lib/prawnx/repeater.rb +79 -0
- data/lib/prawnx/security.rb +108 -0
- data/lib/prawnx/stamp.rb +74 -0
- data/lib/prawnx/table/cell/kinds.rb +246 -0
- data/lib/prawnx/table/cell.rb +331 -0
- data/lib/prawnx/table/cells.rb +208 -0
- data/lib/prawnx/table.rb +296 -0
- data/lib/prawnx/text/box.rb +16 -0
- data/lib/prawnx/text/formatted/arranger.rb +217 -0
- data/lib/prawnx/text/formatted/box.rb +312 -0
- data/lib/prawnx/text/formatted/fragment.rb +205 -0
- data/lib/prawnx/text/formatted/line_wrap.rb +204 -0
- data/lib/prawnx/text/formatted/parser.rb +149 -0
- data/lib/prawnx/text/formatted/wrap.rb +103 -0
- data/lib/prawnx/text.rb +281 -0
- data/lib/prawnx/transformation_stack.rb +43 -0
- data/lib/prawnx/true_type/cff_subset.rb +157 -0
- data/lib/prawnx/true_type/file.rb +371 -0
- data/lib/prawnx/true_type/open_type_layout.rb +189 -0
- data/lib/prawnx/true_type/simple_subsets.rb +94 -0
- data/lib/prawnx/true_type/subset.rb +187 -0
- data/lib/prawnx/version.rb +7 -0
- data/lib/prawnx/view.rb +28 -0
- data/lib/prawnx.rb +102 -0
- metadata +251 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PrawnX
|
|
4
|
+
module Fonts
|
|
5
|
+
# The 14 standard PDF fonts, measured from Adobe's AFM files and written in WinAnsiEncoding.
|
|
6
|
+
class AFM < Font
|
|
7
|
+
BUILT_INS = %w[
|
|
8
|
+
Courier Helvetica Times-Roman Symbol ZapfDingbats Courier-Bold Courier-Oblique Courier-BoldOblique Times-Bold
|
|
9
|
+
Times-Italic Times-BoldItalic Helvetica-Bold Helvetica-Oblique Helvetica-BoldOblique
|
|
10
|
+
].freeze
|
|
11
|
+
METRICS_BY_PATH = {}
|
|
12
|
+
METRICS_LOCK = Mutex.new
|
|
13
|
+
|
|
14
|
+
Metrics = Data.define(:width_by_byte, :kerning_by_byte_pair, :attributes)
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
attr_accessor :hide_m17n_warning
|
|
18
|
+
end
|
|
19
|
+
self.hide_m17n_warning = false
|
|
20
|
+
|
|
21
|
+
def self.metrics_path
|
|
22
|
+
@metrics_path ||= ENV["METRICS"]&.split(":") ||
|
|
23
|
+
[".", "/usr/lib/afm", "/usr/local/lib/afm", "/usr/openwin/lib/fonts/afm", "#{PrawnX::DATADIR}/fonts"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :attributes
|
|
27
|
+
|
|
28
|
+
def initialize(document, name, options = {})
|
|
29
|
+
name ||= options[:family]
|
|
30
|
+
raise Errors::UnknownFont, "#{name} (#{options[:style] || "normal"}) is not a known font." unless BUILT_INS.include?(name)
|
|
31
|
+
if document.pdf_a?
|
|
32
|
+
raise Errors::PdfAViolation, "PDF/A embeds every font, which #{name}, a standard PDF font, cannot be: use a TrueType or OpenType font"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
super
|
|
36
|
+
file_name = @name.end_with?(".afm") ? @name : "#{@name}.afm"
|
|
37
|
+
file_name = find_font(file_name) unless file_name.start_with?("/")
|
|
38
|
+
metrics = METRICS_LOCK.synchronize { METRICS_BY_PATH[file_name] ||= parse_afm(file_name) }
|
|
39
|
+
@width_by_byte = metrics.width_by_byte
|
|
40
|
+
@kerning_by_byte_pair = metrics.kerning_by_byte_pair
|
|
41
|
+
@attributes = metrics.attributes
|
|
42
|
+
@ascender = Integer(@attributes.fetch("ascender", "0"), 10)
|
|
43
|
+
@descender = Integer(@attributes.fetch("descender", "0"), 10)
|
|
44
|
+
@line_gap = Float(bbox[3] - bbox[1]) - (@ascender - @descender)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def unicode?
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def bbox
|
|
52
|
+
@bbox ||= @attributes["fontbbox"].split(/\s+/).map { Integer(it) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def compute_width_of(string, options = {})
|
|
56
|
+
scale = (options[:size] || size) / 1000.0
|
|
57
|
+
return unscaled_width_of(string) * scale unless options[:kerning]
|
|
58
|
+
|
|
59
|
+
strings, offsets = kern(string).partition { it.is_a?(String) }
|
|
60
|
+
(unscaled_width_of(strings.join) - offsets.sum) * scale
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def has_kerning_data?
|
|
64
|
+
!@kerning_by_byte_pair.empty?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def normalize_encoding(text)
|
|
68
|
+
text.encode(::Encoding::Windows_1252)
|
|
69
|
+
rescue ::Encoding::InvalidByteSequenceError, ::Encoding::UndefinedConversionError
|
|
70
|
+
raise Errors::IncompatibleStringEncoding,
|
|
71
|
+
"Your document includes text that's not compatible with the Windows-1252 character set.\n" \
|
|
72
|
+
"If you need full UTF-8 support, use external fonts instead of PDF's built-in fonts.\n"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_utf8(text)
|
|
76
|
+
text.encode(::Encoding::UTF_8)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def character_count(string)
|
|
80
|
+
string.length
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def encode_text(text, options = {})
|
|
84
|
+
[[0, options[:kerning] ? kern(text) : text]]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def glyph_present?(character)
|
|
88
|
+
!normalize_encoding(character).nil?
|
|
89
|
+
rescue Errors::IncompatibleStringEncoding
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def register(_subset)
|
|
96
|
+
dictionary = {Type: :Font, Subtype: :Type1, BaseFont: name.to_sym}
|
|
97
|
+
dictionary[:Encoding] = :WinAnsiEncoding unless symbolic?
|
|
98
|
+
@document.ref!(dictionary)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def symbolic?
|
|
102
|
+
attributes["characterset"] == "Special"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def find_font(file)
|
|
106
|
+
directory = self.class.metrics_path.find { File.exist?("#{it}/#{file}") }
|
|
107
|
+
raise Errors::UnknownFont, "Couldn't find the font: #{file} in any of:\n#{self.class.metrics_path.join("\n")}" unless directory
|
|
108
|
+
|
|
109
|
+
"#{directory}/#{file}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Runs of bytes, separated by the kerning offset between two of them, in thousandths of the font size.
|
|
113
|
+
def kern(string)
|
|
114
|
+
runs = [String.new(encoding: ::Encoding::BINARY)]
|
|
115
|
+
last_byte = nil
|
|
116
|
+
string.each_byte do |byte|
|
|
117
|
+
offset = last_byte && @kerning_by_byte_pair[(last_byte << 8) | byte]
|
|
118
|
+
runs << -offset << String.new(encoding: ::Encoding::BINARY) if offset
|
|
119
|
+
runs.last << byte
|
|
120
|
+
last_byte = byte
|
|
121
|
+
end
|
|
122
|
+
runs.map { it.is_a?(String) ? it.force_encoding(::Encoding::Windows_1252) : it }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def unscaled_width_of(string)
|
|
126
|
+
string.each_byte.sum { @width_by_byte[it] }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def parse_afm(file_name)
|
|
130
|
+
width_by_name = {}
|
|
131
|
+
kerning_by_name_pair = {}
|
|
132
|
+
attributes = {}
|
|
133
|
+
sections = []
|
|
134
|
+
File.foreach(file_name) do |line|
|
|
135
|
+
if (section = line[/^Start(\w+)/, 1])
|
|
136
|
+
sections.push(section)
|
|
137
|
+
elsif line.start_with?("End")
|
|
138
|
+
sections.pop
|
|
139
|
+
else
|
|
140
|
+
case sections
|
|
141
|
+
when %w[FontMetrics CharMetrics]
|
|
142
|
+
next unless /^CH?\s/.match?(line)
|
|
143
|
+
|
|
144
|
+
width_by_name[line[/\bN\s+(\.?\w+)\s*;/, 1]] = Integer(line[/\bWX\s+(\d+)\s*;/, 1], 10)
|
|
145
|
+
when %w[FontMetrics KernData KernPairs]
|
|
146
|
+
next unless line =~ /^KPX\s+(\.?\w+)\s+(\.?\w+)\s+(-?\d+)/
|
|
147
|
+
|
|
148
|
+
kerning_by_name_pair[[$1, $2]] = Integer($3, 10)
|
|
149
|
+
when %w[FontMetrics KernData TrackKern], %w[FontMetrics Composites]
|
|
150
|
+
next
|
|
151
|
+
else
|
|
152
|
+
next unless line =~ /(^\w+)\s+(.*)/
|
|
153
|
+
|
|
154
|
+
key = $1.downcase
|
|
155
|
+
attributes[key] = attributes[key] ? [*attributes[key], $2] : $2
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
byte_by_name = Encoding::WinAnsi::CHARACTERS.each_with_index.to_h
|
|
161
|
+
kerning_by_byte_pair = kerning_by_name_pair.each_with_object({}) do |((left, right), offset), table|
|
|
162
|
+
left_byte = byte_by_name[left]
|
|
163
|
+
right_byte = byte_by_name[right]
|
|
164
|
+
table[(left_byte << 8) | right_byte] = offset if left_byte && right_byte
|
|
165
|
+
end
|
|
166
|
+
width_by_byte = Encoding::WinAnsi::CHARACTERS.map { width_by_name.fetch(it, 0) }
|
|
167
|
+
Metrics.new(width_by_byte: width_by_byte.freeze, kerning_by_byte_pair: kerning_by_byte_pair.freeze, attributes: attributes.freeze)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PrawnX
|
|
4
|
+
module Fonts
|
|
5
|
+
# An OpenType font with CFF outlines, embedded as a Type0 font over a CIDFontType0: its character identifiers
|
|
6
|
+
# are glyph indices, as a CFF program that is not CID-keyed requires (ISO 32000-1 §9.7.4.2).
|
|
7
|
+
class OTF < TTF
|
|
8
|
+
def encode_text(text, options = {})
|
|
9
|
+
text = text.chomp
|
|
10
|
+
runs =
|
|
11
|
+
if @layout
|
|
12
|
+
Array(shaped_runs(text, options[:kerning]))
|
|
13
|
+
elsif options[:kerning]
|
|
14
|
+
kern(text).map { it.is_a?(Numeric) ? it : glyph_string(it) }
|
|
15
|
+
else
|
|
16
|
+
[glyph_string(text.codepoints)]
|
|
17
|
+
end
|
|
18
|
+
runs = space_out_words(runs, options[:word_spacing], options[:font_size]) if options[:word_spacing].to_f.nonzero?
|
|
19
|
+
[[0, runs.size == 1 ? runs.first : runs]]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def cluster_string(clusters)
|
|
25
|
+
clusters.map do |glyph, codes|
|
|
26
|
+
@code_by_glyph[glyph] ||= codes
|
|
27
|
+
glyph
|
|
28
|
+
end.pack("n*")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def embed(reference, _subset)
|
|
32
|
+
@code_by_glyph ||= {}
|
|
33
|
+
glyphs = [0, *@code_by_glyph.keys].uniq
|
|
34
|
+
font_name = :"#{subset_tag_for(glyphs)}+#{basename.delete("\0")[0, 33]}"
|
|
35
|
+
font_file = @document.ref!({Subtype: :CIDFontType0C}, TrueType::CffSubset.new(@ttf.table("CFF ")).bytes(glyphs))
|
|
36
|
+
descriptor = @document.ref!(
|
|
37
|
+
Type: :FontDescriptor, FontName: font_name, FontBBox: bbox, Flags: pdf_flags, StemV: stem_v, ItalicAngle: italic_angle,
|
|
38
|
+
Ascent: @ascender, Descent: @descender, CapHeight: cap_height, XHeight: x_height, FontFile3: font_file,
|
|
39
|
+
)
|
|
40
|
+
widths = glyphs.sort.flat_map { [it, [Integer(@ttf.advance_widths[it] * scale_factor)]] }
|
|
41
|
+
descendant = @document.ref!(
|
|
42
|
+
Type: :Font, Subtype: :CIDFontType0, BaseFont: font_name, FontDescriptor: descriptor, W: widths,
|
|
43
|
+
CIDSystemInfo: {Registry: PDF::LiteralString.new("Adobe"), Ordering: PDF::LiteralString.new("Identity"), Supplement: 0},
|
|
44
|
+
)
|
|
45
|
+
reference.data.update(Subtype: :Type0, BaseFont: font_name, Encoding: :"Identity-H", DescendantFonts: [descendant],
|
|
46
|
+
ToUnicode: @document.ref!({}, to_unicode_cmap(@code_by_glyph)))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/md5"
|
|
4
|
+
|
|
5
|
+
module PrawnX
|
|
6
|
+
module Fonts
|
|
7
|
+
# A TrueType font, measured like Prawn measures it, embedded as one composite font per document: a Type0 font
|
|
8
|
+
# with Identity-H encoding over a subset of the glyphs used.
|
|
9
|
+
class TTF < Font
|
|
10
|
+
class Error < StandardError
|
|
11
|
+
DEFAULT_MESSAGE = "TTF font error"
|
|
12
|
+
|
|
13
|
+
def initialize(message = DEFAULT_MESSAGE, font: nil)
|
|
14
|
+
super(font && message == DEFAULT_MESSAGE ? "TTF font error in font #{font}" : message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class NoUnicodeCMap < Error
|
|
19
|
+
def initialize(message = "No unicode cmap found in font", font: nil)
|
|
20
|
+
super(font ? "No unicode cmap found in font #{font}" : message)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class NoPostscriptName < Error
|
|
25
|
+
def initialize(message = "Can not detect a postscript name", font: nil)
|
|
26
|
+
super(font ? "Can not detect a postscript name in font #{font}" : message)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
LINE_FEED = 10
|
|
31
|
+
# FontDescriptor flags, ISO 32000-1 table 123.
|
|
32
|
+
FIXED_PITCH_FLAG = 0x0001
|
|
33
|
+
SERIF_FLAG = 0x0002
|
|
34
|
+
SYMBOLIC_FLAG = 0x0004
|
|
35
|
+
SCRIPT_FLAG = 0x0008
|
|
36
|
+
ITALIC_FLAG = 0x0040
|
|
37
|
+
# OS/2 sFamilyClass high bytes of serif and script families.
|
|
38
|
+
SERIF_FAMILY_CLASSES = [1, 2, 3, 4, 5, 7].freeze
|
|
39
|
+
SCRIPT_FAMILY_CLASS = 10
|
|
40
|
+
TO_UNICODE_ENTRIES_PER_BLOCK = 100
|
|
41
|
+
|
|
42
|
+
attr_reader :ttf
|
|
43
|
+
|
|
44
|
+
def initialize(document, name, options = {})
|
|
45
|
+
super
|
|
46
|
+
@ttf = read_ttf_file
|
|
47
|
+
raise NoUnicodeCMap.new(font: name) unless @ttf.unicode_cmap?
|
|
48
|
+
@code_by_glyph = {}
|
|
49
|
+
|
|
50
|
+
@subset = TrueType::Subset.new(@ttf)
|
|
51
|
+
@cid_by_cluster = {}
|
|
52
|
+
@simple_subsets = TrueType::SimpleSubsets.new(->(codes) { cmap[Array(codes).first] })
|
|
53
|
+
@layout = TrueType::OpenTypeLayout.new(@ttf, options[:features]) if options[:features]&.any?
|
|
54
|
+
@width_by_code = {}
|
|
55
|
+
@ascender = Integer(@ttf.ascent * scale_factor)
|
|
56
|
+
@descender = Integer(@ttf.descent * scale_factor)
|
|
57
|
+
@line_gap = Integer(@ttf.line_gap * scale_factor)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def unicode?
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# One pass over the characters, adding each kerning offset before the character it precedes: the same
|
|
65
|
+
# sums, in the same order, as measuring the runs of #kern.
|
|
66
|
+
def compute_width_of(string, options = {})
|
|
67
|
+
return shaped_width_of(string, options) if @layout
|
|
68
|
+
|
|
69
|
+
scale = (options[:size] || size) / 1000.0
|
|
70
|
+
kerning = options[:kerning]
|
|
71
|
+
width = 0
|
|
72
|
+
previous_glyph = nil
|
|
73
|
+
string.each_codepoint do |code|
|
|
74
|
+
glyph = cmap[code]
|
|
75
|
+
if kerning && previous_glyph && (offset = @ttf.kerning_offset(previous_glyph, glyph))
|
|
76
|
+
width -= -(offset * scale_factor)
|
|
77
|
+
end
|
|
78
|
+
width += character_width_by_code(code)
|
|
79
|
+
previous_glyph = glyph
|
|
80
|
+
end
|
|
81
|
+
width * scale
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def bbox
|
|
85
|
+
@bbox ||= @ttf.header.to_h.values_at(:x_min, :y_min, :x_max, :y_max).map { Integer(it * scale_factor) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def has_kerning_data?
|
|
89
|
+
@ttf.kerning?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def encode_text(text, options = {})
|
|
93
|
+
text = text.chomp
|
|
94
|
+
unless @layout
|
|
95
|
+
return options[:kerning] ? encode_kerned_codepoints(text.codepoints) : @simple_subsets.encode_codepoints(text.codepoints, cmap)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
clusters = @layout ? @layout.shape(text.codepoints) : text.codepoints.map { [cmap[it], it] }
|
|
99
|
+
return @simple_subsets.encode(clusters) unless options[:kerning]
|
|
100
|
+
|
|
101
|
+
encoded = []
|
|
102
|
+
run = []
|
|
103
|
+
clusters.each do |cluster|
|
|
104
|
+
if run.any? && (offset = kerning_between(run.last[0], cluster[0]))
|
|
105
|
+
append_encoded_run(encoded, run)
|
|
106
|
+
encoded.last[1] = Array(encoded.last[1]) << -(offset * scale_factor)
|
|
107
|
+
run = []
|
|
108
|
+
end
|
|
109
|
+
run << cluster
|
|
110
|
+
end
|
|
111
|
+
append_encoded_run(encoded, run)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def basename
|
|
115
|
+
@basename ||= @ttf.postscript_name
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def stem_v
|
|
119
|
+
0
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The post table's Fixed italic angle, read the way Prawn reads it.
|
|
123
|
+
def italic_angle
|
|
124
|
+
@italic_angle ||= begin
|
|
125
|
+
raw = @ttf.italic_angle_fixed
|
|
126
|
+
if raw
|
|
127
|
+
high = raw >> 16
|
|
128
|
+
low = raw & 0xFF
|
|
129
|
+
high = -((high ^ 0xFFFF) + 1) if high & 0x8000 != 0
|
|
130
|
+
Float("#{high}.#{low}")
|
|
131
|
+
else
|
|
132
|
+
0
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def cap_height
|
|
138
|
+
@cap_height ||= @ttf.os2[:cap_height].to_i.then { it.zero? ? @ascender : it }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def x_height
|
|
142
|
+
@ttf.os2[:x_height] || 0
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def family_class
|
|
146
|
+
@family_class ||= @ttf.os2[:family_class].to_i >> 8
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def serif?
|
|
150
|
+
SERIF_FAMILY_CLASSES.include?(family_class)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def script?
|
|
154
|
+
family_class == SCRIPT_FAMILY_CLASS
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def pdf_flags
|
|
158
|
+
flags = SYMBOLIC_FLAG
|
|
159
|
+
flags |= FIXED_PITCH_FLAG if @ttf.fixed_pitch?
|
|
160
|
+
flags |= SERIF_FLAG if serif?
|
|
161
|
+
flags |= SCRIPT_FLAG if script?
|
|
162
|
+
flags |= ITALIC_FLAG if italic_angle != 0
|
|
163
|
+
flags
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def normalize_encoding(text)
|
|
167
|
+
text.encode(::Encoding::UTF_8)
|
|
168
|
+
rescue StandardError
|
|
169
|
+
raise Errors::IncompatibleStringEncoding,
|
|
170
|
+
"Encoding #{text.encoding} can not be transparently converted to UTF-8. " \
|
|
171
|
+
"Please ensure the encoding of the string you are attempting to use is set correctly"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def to_utf8(text)
|
|
175
|
+
text.encode(::Encoding::UTF_8)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def glyph_present?(character)
|
|
179
|
+
cmap[character.codepoints.first].positive?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Glyphs, which character spacing applies to: fewer than characters once ligatures join some.
|
|
183
|
+
def character_count(string)
|
|
184
|
+
@layout ? @layout.shape(string.codepoints).size : string.length
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def read_ttf_file
|
|
190
|
+
TrueType::File.open(@name)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def cmap
|
|
194
|
+
@ttf.glyph_by_code
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def scale_factor
|
|
198
|
+
@scale_factor ||= 1000.0 / @ttf.header.units_per_em
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def character_width_by_code(code)
|
|
202
|
+
return 0.0 if code == LINE_FEED
|
|
203
|
+
|
|
204
|
+
@width_by_code[code] ||= Integer(@ttf.advance_widths[cmap[code]] * scale_factor)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Runs of code points, separated by the kerning offset between two glyphs, in thousandths of the font size.
|
|
208
|
+
def kern(string)
|
|
209
|
+
runs = []
|
|
210
|
+
string.each_codepoint do |code|
|
|
211
|
+
if runs.empty?
|
|
212
|
+
runs << [code]
|
|
213
|
+
elsif (offset = @ttf.kerning_offset(cmap[runs.last.last], cmap[code]))
|
|
214
|
+
runs << -(offset * scale_factor) << [code]
|
|
215
|
+
else
|
|
216
|
+
runs.last << code
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
runs
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def glyph_string(codes)
|
|
223
|
+
cluster_string(codes.map { [cmap[it], it] })
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# One character identifier per cluster of characters, in order of first use (0 stays .notdef): characters
|
|
227
|
+
# sharing a glyph keep their own Unicode value for text extraction, a ligature all of its characters.
|
|
228
|
+
def cluster_string(clusters)
|
|
229
|
+
clusters.map do |glyph, codes|
|
|
230
|
+
@cid_by_cluster[codes] ||= begin
|
|
231
|
+
(@glyph_by_cid ||= [0]) << glyph
|
|
232
|
+
@glyph_by_cid.size - 1
|
|
233
|
+
end
|
|
234
|
+
end.pack("n*")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# PDF word spacing (Tw) only reaches byte 32 of single-byte encodings (ISO 32000-1 §9.3.3): with two-byte
|
|
238
|
+
# codes, each space gets the same shift as a TJ offset instead.
|
|
239
|
+
def space_out_words(runs, word_spacing, font_size)
|
|
240
|
+
space = @layout ? cluster_string([[cmap[32], [32]]]) : glyph_string([32])
|
|
241
|
+
# From the spacing as Tw would write it, rounded to five decimals, so spaces land where Prawn's do.
|
|
242
|
+
offset = -(Float(PDF.real(word_spacing)) * 1000.0 / font_size)
|
|
243
|
+
runs.flat_map do |run|
|
|
244
|
+
next [run] unless run.is_a?(String)
|
|
245
|
+
|
|
246
|
+
run.b.scan(/../mn).slice_after(space).flat_map { |codes| codes.last == space ? [codes.join, offset] : [codes.join] }
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def shaped_width_of(string, options)
|
|
251
|
+
width = 0
|
|
252
|
+
previous_glyph = nil
|
|
253
|
+
@layout.shape(string.codepoints).each do |glyph, codes|
|
|
254
|
+
if options[:kerning] && previous_glyph && (offset = kerning_between(previous_glyph, glyph))
|
|
255
|
+
width += offset * scale_factor
|
|
256
|
+
end
|
|
257
|
+
width += codes == [LINE_FEED] ? 0.0 : Integer(@ttf.advance_widths[glyph] * scale_factor)
|
|
258
|
+
previous_glyph = glyph
|
|
259
|
+
end
|
|
260
|
+
width * ((options[:size] || size) / 1000.0)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def shaped_runs(text, kerning)
|
|
264
|
+
clusters = @layout.shape(text.codepoints)
|
|
265
|
+
return cluster_string(clusters) unless kerning
|
|
266
|
+
|
|
267
|
+
runs = []
|
|
268
|
+
run = []
|
|
269
|
+
clusters.each do |cluster|
|
|
270
|
+
if run.any? && (offset = kerning_between(run.last[0], cluster[0]))
|
|
271
|
+
runs << cluster_string(run) << -(offset * scale_factor)
|
|
272
|
+
run = []
|
|
273
|
+
end
|
|
274
|
+
run << cluster
|
|
275
|
+
end
|
|
276
|
+
runs << cluster_string(run)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def kerning_between(left, right)
|
|
280
|
+
return @ttf.kerning_offset(left, right) unless @layout
|
|
281
|
+
|
|
282
|
+
@layout.kerning(left, right) || (@ttf.kerning_offset(left, right) unless options[:features].include?(:kern))
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def encode_kerned_codepoints(codepoints)
|
|
286
|
+
encoded = []
|
|
287
|
+
run_start = 0
|
|
288
|
+
previous_glyph = nil
|
|
289
|
+
codepoints.each_with_index do |code, index|
|
|
290
|
+
glyph = cmap[code]
|
|
291
|
+
if previous_glyph && (offset = @ttf.kerning_offset(previous_glyph, glyph))
|
|
292
|
+
merge_encoded_parts(encoded, @simple_subsets.encode_codepoints(codepoints[run_start...index], cmap))
|
|
293
|
+
encoded.last[1] = Array(encoded.last[1]) << -(offset * scale_factor)
|
|
294
|
+
run_start = index
|
|
295
|
+
end
|
|
296
|
+
previous_glyph = glyph
|
|
297
|
+
end
|
|
298
|
+
merge_encoded_parts(encoded, @simple_subsets.encode_codepoints(codepoints[run_start..], cmap))
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Runs of one subset merge into the previous part when it is of the same subset, as in Prawn's TJ arrays.
|
|
302
|
+
def append_encoded_run(encoded, run)
|
|
303
|
+
merge_encoded_parts(encoded, @simple_subsets.encode(run))
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def merge_encoded_parts(encoded, parts)
|
|
307
|
+
return encoded if parts.empty?
|
|
308
|
+
|
|
309
|
+
if encoded.any? && parts.first[0] == encoded.last[0]
|
|
310
|
+
encoded.last[1] << parts.shift[1]
|
|
311
|
+
end
|
|
312
|
+
encoded.concat(parts)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def register(subset)
|
|
316
|
+
reference = @document.ref!(Type: :Font, BaseFont: basename.delete("\0").to_sym)
|
|
317
|
+
@document.before_render { embed(reference, subset) }
|
|
318
|
+
reference
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# A simple TrueType font of single-byte codes, as Prawn embeds them: a font program with a Mac Roman cmap
|
|
322
|
+
# from those codes to its glyphs, widths per code, and a ToUnicode map.
|
|
323
|
+
def embed(reference, subset_index)
|
|
324
|
+
subset = @simple_subsets[subset_index]
|
|
325
|
+
program = TrueType::Subset.new(@ttf)
|
|
326
|
+
# Glyphs numbered as TTFunk numbers them, by character code then components: TrueType hinting can depend
|
|
327
|
+
# on glyph indices, and a different order rasterizes differently in MuPDF.
|
|
328
|
+
subset.glyph_by_code.sort.each { |_code, glyph| program.glyph_for(glyph) unless glyph.zero? }
|
|
329
|
+
program.add_components(subset.glyph_by_code.values.uniq.sort)
|
|
330
|
+
glyph_by_code = subset.glyph_by_code.transform_values { program.glyph_for(it) }
|
|
331
|
+
mac_roman_cmap = [0, 1, 1, 0, 12, 0, 262, 0].pack("n4 N n3") + Array.new(256) { glyph_by_code[it] || 0 }.pack("C*")
|
|
332
|
+
font_name = :"#{subset_tag_for(program.original_glyphs)}+#{basename.delete("\0")[0, 33]}"
|
|
333
|
+
font_bytes = program.bytes("cmap" => mac_roman_cmap)
|
|
334
|
+
font_file = @document.ref!({Length1: font_bytes.bytesize}, font_bytes)
|
|
335
|
+
descriptor = @document.ref!(
|
|
336
|
+
Type: :FontDescriptor, FontName: font_name, FontBBox: bbox, Flags: pdf_flags, StemV: stem_v, ItalicAngle: italic_angle,
|
|
337
|
+
Ascent: @ascender, Descent: @descender, CapHeight: cap_height, XHeight: x_height, FontFile2: font_file,
|
|
338
|
+
)
|
|
339
|
+
first_code, last_code = subset.glyph_by_code.keys.minmax
|
|
340
|
+
widths = (first_code..last_code).map do |code|
|
|
341
|
+
glyph = subset.glyph_by_code[code]
|
|
342
|
+
glyph ? Integer(@ttf.advance_widths[glyph] * scale_factor) : 0
|
|
343
|
+
end
|
|
344
|
+
codes_by_code = subset.code_by_cluster.each_with_object({}) { |(codes, code), map| map[code] ||= Array(codes) }
|
|
345
|
+
reference.data.update(Subtype: :TrueType, BaseFont: font_name, FontDescriptor: descriptor, FirstChar: first_code, LastChar: last_code,
|
|
346
|
+
Widths: @document.ref!(widths), ToUnicode: @document.ref!({}, to_unicode_cmap(codes_by_code, code_bytes: 1)))
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Six capital letters derived from the glyphs kept, as ISO 32000-1 §9.6.4 asks of a subset's name.
|
|
350
|
+
def subset_tag
|
|
351
|
+
subset_tag_for(@subset.original_glyphs)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def subset_tag_for(glyphs)
|
|
355
|
+
Digest::MD5.digest(glyphs.pack("n*")).bytes.first(6).map { ("A".ord + (it % 26)).chr }.join
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def to_unicode_cmap(codes_by_cid, code_bytes: 2)
|
|
359
|
+
code_format = "%0#{code_bytes * 2}X"
|
|
360
|
+
entries = codes_by_cid.map { |cid, codes| format("<#{code_format}> <%s>", cid, Array(codes).pack("U*").encode(::Encoding::UTF_16BE).unpack1("H*").upcase) }
|
|
361
|
+
blocks = entries.each_slice(TO_UNICODE_ENTRIES_PER_BLOCK).map { "#{it.size} beginbfchar\n#{it.join("\n")}\nendbfchar" }
|
|
362
|
+
<<~CMAP
|
|
363
|
+
/CIDInit /ProcSet findresource begin
|
|
364
|
+
12 dict begin
|
|
365
|
+
begincmap
|
|
366
|
+
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
|
|
367
|
+
/CMapName /Adobe-Identity-UCS def
|
|
368
|
+
/CMapType 2 def
|
|
369
|
+
1 begincodespacerange
|
|
370
|
+
#{code_bytes == 1 ? "<00> <FF>" : "<0000> <FFFF>"}
|
|
371
|
+
endcodespacerange
|
|
372
|
+
#{blocks.join("\n")}
|
|
373
|
+
endcmap
|
|
374
|
+
CMapName currentdict /CMap defineresource pop
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
CMAP
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
class TTC < TTF
|
|
382
|
+
def self.font_names(file)
|
|
383
|
+
collection = ::File.binread(file)
|
|
384
|
+
Array.new(collection.unpack1("@8 N")) { TrueType::File.new(collection, index: it).font_name }
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
private
|
|
388
|
+
|
|
389
|
+
def read_ttf_file
|
|
390
|
+
index = @options[:font].is_a?(Numeric) ? @options[:font] : self.class.font_names(@name).index(@options[:font]) || 0
|
|
391
|
+
TrueType::File.open(@name, index:)
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
class DFont < TTF
|
|
396
|
+
private
|
|
397
|
+
|
|
398
|
+
def read_ttf_file
|
|
399
|
+
TrueType::File.open(@name, index: @options[:font] || 0, resource: true)
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
class Font
|
|
405
|
+
AFM = Fonts::AFM
|
|
406
|
+
TTF = Fonts::TTF
|
|
407
|
+
TTC = Fonts::TTC
|
|
408
|
+
DFont = Fonts::DFont
|
|
409
|
+
end
|
|
410
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PrawnX
|
|
4
|
+
# What a PDF `q` saves and `Q` restores, as far as PrawnX writes it.
|
|
5
|
+
class GraphicState
|
|
6
|
+
UNDASHED = {dash: nil, space: nil, phase: 0}.freeze
|
|
7
|
+
|
|
8
|
+
attr_accessor :color_space, :dash, :cap_style, :join_style, :line_width, :fill_color, :stroke_color
|
|
9
|
+
|
|
10
|
+
def initialize(previous_state = nil)
|
|
11
|
+
if previous_state
|
|
12
|
+
@color_space = previous_state.color_space.dup
|
|
13
|
+
@fill_color = previous_state.fill_color.dup
|
|
14
|
+
@stroke_color = previous_state.stroke_color.dup
|
|
15
|
+
@dash = previous_state.dash.dup
|
|
16
|
+
@cap_style = previous_state.cap_style
|
|
17
|
+
@join_style = previous_state.join_style
|
|
18
|
+
@line_width = previous_state.line_width
|
|
19
|
+
else
|
|
20
|
+
@color_space = {}
|
|
21
|
+
@fill_color = "000000"
|
|
22
|
+
@stroke_color = "000000"
|
|
23
|
+
@dash = UNDASHED.dup
|
|
24
|
+
@cap_style = :butt
|
|
25
|
+
@join_style = :miter
|
|
26
|
+
@line_width = 1
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def dup
|
|
31
|
+
GraphicState.new(self)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def dash_setting
|
|
35
|
+
return "[] 0 d" unless dash[:dash]
|
|
36
|
+
|
|
37
|
+
lengths = dash[:dash].is_a?(Array) ? dash[:dash] : [dash[:dash], dash[:space]]
|
|
38
|
+
"[#{PDF.real_params(lengths)}] #{PDF.real(dash[:phase])} d"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|