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,371 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PrawnX
|
|
4
|
+
module TrueType
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# The tables of a TrueType font that layout and embedding need, read once from its bytes: from a .ttf, one
|
|
9
|
+
# font of a .ttc collection, or one sfnt resource of a Mac .dfont.
|
|
10
|
+
class File
|
|
11
|
+
TRUE_TYPE_VERSIONS = ["\x00\x01\x00\x00".b, "true".b].freeze
|
|
12
|
+
COLLECTION_TAG = "ttcf".b
|
|
13
|
+
UNICODE_CMAP_FORMATS = [4, 6, 10, 12].freeze
|
|
14
|
+
REQUIRED_TABLES = %w[head hhea maxp hmtx cmap].freeze
|
|
15
|
+
# Reading past the data a malformed font declares raises these: they become a TrueType::Error.
|
|
16
|
+
MALFORMED_FONT_ERRORS = [ArgumentError, TypeError, RangeError, NoMethodError, FloatDomainError].freeze
|
|
17
|
+
MAXIMUM_CODE_POINT = 0x10FFFF
|
|
18
|
+
|
|
19
|
+
Header = Data.define(:units_per_em, :x_min, :y_min, :x_max, :y_max, :index_to_location_format)
|
|
20
|
+
|
|
21
|
+
FILES_BY_KEY = {}
|
|
22
|
+
FILES_LOCK = Mutex.new
|
|
23
|
+
|
|
24
|
+
# Parsed fonts are shared by every document of the process, until their file changes.
|
|
25
|
+
def self.open(path, index: 0, resource: false)
|
|
26
|
+
key = [::File.expand_path(path), index, resource, ::File.mtime(path)]
|
|
27
|
+
FILES_LOCK.synchronize { FILES_BY_KEY[key] ||= new(::File.binread(path), index:, resource:) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_reader :bytes, :offset
|
|
31
|
+
|
|
32
|
+
def initialize(bytes, index: 0, resource: false)
|
|
33
|
+
@bytes = bytes.b.freeze
|
|
34
|
+
@offset = resource ? resource_offset(index) : 0
|
|
35
|
+
@offset = collection_offset(index) if @bytes.byteslice(@offset, 4) == COLLECTION_TAG
|
|
36
|
+
@location_by_tag = read_table_directory
|
|
37
|
+
validate!
|
|
38
|
+
rescue *MALFORMED_FONT_ERRORS => exception
|
|
39
|
+
raise Error, "Malformed font: #{exception.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def table?(tag)
|
|
43
|
+
@location_by_tag.key?(tag)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def table(tag)
|
|
47
|
+
offset, length = @location_by_tag[tag]
|
|
48
|
+
@bytes.byteslice(offset, length) if offset
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def outline_format
|
|
52
|
+
if table?("glyf") then :true_type
|
|
53
|
+
elsif table?("CFF ") then :open_type
|
|
54
|
+
else :unknown
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def header
|
|
59
|
+
@header ||= begin
|
|
60
|
+
units_per_em, x_min, y_min, x_max, y_max = table("head").unpack("@18 n x16 s>4")
|
|
61
|
+
Header.new(units_per_em:, x_min:, y_min:, x_max:, y_max:, index_to_location_format: table("head").unpack1("@50 s>"))
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def glyph_count
|
|
66
|
+
@glyph_count ||= table("maxp").unpack1("@4 n")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The OS/2 typographic metric when present and not zero, the hhea one otherwise.
|
|
70
|
+
def ascent
|
|
71
|
+
@ascent ||= nonzero_os2(:ascent) || horizontal_header[0]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def descent
|
|
75
|
+
@descent ||= nonzero_os2(:descent) || horizontal_header[1]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def line_gap
|
|
79
|
+
@line_gap ||= nonzero_os2(:line_gap) || horizontal_header[2]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def os2
|
|
83
|
+
@os2 ||= begin
|
|
84
|
+
os2 = table("OS/2")
|
|
85
|
+
if os2
|
|
86
|
+
version = os2.unpack1("n")
|
|
87
|
+
family_class = os2.unpack1("@30 s>")
|
|
88
|
+
ascent, descent, line_gap = os2.unpack("@68 s>3")
|
|
89
|
+
x_height, cap_height = os2.unpack("@86 s>2") if version >= 2 && os2.bytesize >= 90
|
|
90
|
+
{family_class:, ascent:, descent:, line_gap:, x_height:, cap_height:}
|
|
91
|
+
else
|
|
92
|
+
{}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def italic_angle_fixed
|
|
98
|
+
table("post")&.unpack1("@4 N")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fixed_pitch?
|
|
102
|
+
table("post")&.unpack1("@12 N").to_i != 0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def postscript_name
|
|
106
|
+
@postscript_name ||= name_string(6) || name_string(1) || "unnamed"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def font_name
|
|
110
|
+
name_string(4)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def family_name
|
|
114
|
+
name_string(1)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Underline position and thickness from the post table, in font units.
|
|
118
|
+
def underline_metrics
|
|
119
|
+
table("post").unpack("@8 s>2")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def strikeout_position
|
|
123
|
+
table("OS/2").unpack1("@28 s>")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The lowest and highest points of a glyph's outline, in font units, from its glyf header.
|
|
127
|
+
def glyph_vertical_bounds(glyph)
|
|
128
|
+
data = glyph_data(glyph)
|
|
129
|
+
return [0, 0] if data.bytesize < 10
|
|
130
|
+
|
|
131
|
+
data.unpack("@4 s> x2 s>")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Advance widths in font units, one per glyph: glyphs past the last metric share its advance.
|
|
135
|
+
def advance_widths
|
|
136
|
+
@advance_widths ||= begin
|
|
137
|
+
metric_count = horizontal_header[3]
|
|
138
|
+
raise Error, "Malformed font: no horizontal metrics" unless metric_count.between?(1, table("hmtx").bytesize / 4)
|
|
139
|
+
widths = table("hmtx").unpack("n*").each_slice(2).first(metric_count).map(&:first)
|
|
140
|
+
widths + ([widths.last] * (glyph_count - metric_count))
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def glyph_by_code
|
|
145
|
+
@glyph_by_code ||= read_unicode_cmap || raise(Error, "No unicode cmap found in font")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def unicode_cmap?
|
|
149
|
+
!read_unicode_cmap.nil?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The first kerning subtable's pairs of glyphs, in font units.
|
|
153
|
+
def kerning_by_glyph_pair
|
|
154
|
+
@kerning_by_glyph_pair ||= begin
|
|
155
|
+
kern = table("kern")
|
|
156
|
+
next_pairs = nil
|
|
157
|
+
if kern
|
|
158
|
+
version = kern.unpack1("n")
|
|
159
|
+
if version == 1
|
|
160
|
+
length, coverage = kern.unpack("@8 N n")
|
|
161
|
+
next_pairs = kern.byteslice(16, length - 8) if (coverage & 0xFF).zero?
|
|
162
|
+
elsif kern.unpack1("@2 n").positive?
|
|
163
|
+
coverage = kern.unpack1("@8 n")
|
|
164
|
+
next_pairs = kern.byteslice(10..) if (coverage >> 8).zero?
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
next_pairs ? format0_pairs(next_pairs) : {}
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def kerning?
|
|
172
|
+
!kerning_by_glyph_pair.empty?
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Kerning between two glyphs, in font units, or nil.
|
|
176
|
+
def kerning_offset(left, right)
|
|
177
|
+
@kerning_by_glyph_key ||= kerning_by_glyph_pair.transform_keys { |(first, second)| (first << 16) | second }
|
|
178
|
+
@kerning_by_glyph_key[(left << 16) | right]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def glyph_data(glyph)
|
|
182
|
+
offsets = glyph_offsets
|
|
183
|
+
start = offsets[glyph]
|
|
184
|
+
table_bytes = glyf_table
|
|
185
|
+
table_bytes.byteslice(start, offsets[glyph + 1] - start) || "".b
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
# Fonts may come from users: everything layout reads later is read and checked once, here.
|
|
191
|
+
def validate!
|
|
192
|
+
missing = REQUIRED_TABLES.reject { table?(it) }
|
|
193
|
+
raise Error, "Malformed font: no #{missing.join(", ")} table" unless missing.empty?
|
|
194
|
+
raise Error, "Malformed font: no outlines" if outline_format == :unknown
|
|
195
|
+
|
|
196
|
+
@location_by_tag.each do |tag, (offset, length)|
|
|
197
|
+
raise Error, "Malformed font: the #{tag} table lies outside the file" if offset + length > @bytes.bytesize
|
|
198
|
+
end
|
|
199
|
+
raise Error, "Malformed font: no glyphs" unless glyph_count.positive?
|
|
200
|
+
|
|
201
|
+
header
|
|
202
|
+
advance_widths
|
|
203
|
+
read_unicode_cmap
|
|
204
|
+
kerning_by_glyph_pair
|
|
205
|
+
validate_glyph_offsets! if outline_format == :true_type
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def validate_glyph_offsets!
|
|
209
|
+
offsets = glyph_offsets
|
|
210
|
+
glyf_size = glyf_table.bytesize
|
|
211
|
+
valid = offsets.size == glyph_count + 1 && offsets.each_cons(2).all? { |start, finish| start <= finish } && offsets.last <= glyf_size
|
|
212
|
+
raise Error, "Malformed font: its glyph locations do not fit its glyph table" unless valid
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def horizontal_header
|
|
216
|
+
@horizontal_header ||= begin
|
|
217
|
+
ascent, descent, line_gap = table("hhea").unpack("@4 s>3")
|
|
218
|
+
[ascent, descent, line_gap, table("hhea").unpack1("@34 n")]
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def nonzero_os2(metric)
|
|
223
|
+
value = os2[metric]
|
|
224
|
+
value if value&.nonzero?
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def glyf_table
|
|
228
|
+
@glyf_table ||= table("glyf")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def glyph_offsets
|
|
232
|
+
@glyph_offsets ||=
|
|
233
|
+
if header.index_to_location_format.zero?
|
|
234
|
+
table("loca").unpack("n#{glyph_count + 1}").map { it * 2 }
|
|
235
|
+
else
|
|
236
|
+
table("loca").unpack("N#{glyph_count + 1}")
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def format0_pairs(subtable)
|
|
241
|
+
pair_count = subtable.unpack1("n")
|
|
242
|
+
values = subtable.unpack("@8 n#{pair_count * 3}")
|
|
243
|
+
values.each_slice(3).each_with_object({}) do |(left, right, value), pairs|
|
|
244
|
+
break pairs if value.nil?
|
|
245
|
+
|
|
246
|
+
pairs[[left, right]] = value >= 0x8000 ? value - 0x10000 : value
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def read_table_directory
|
|
251
|
+
version, table_count = @bytes.unpack("@#{@offset} a4 n")
|
|
252
|
+
raise Error, "Not a TrueType font" unless TRUE_TYPE_VERSIONS.include?(version) || version == "OTTO"
|
|
253
|
+
|
|
254
|
+
Array.new(table_count) do |index|
|
|
255
|
+
tag, _checksum, offset, length = @bytes.unpack("@#{@offset + 12 + (index * 16)} a4 N3")
|
|
256
|
+
[tag, [offset + (@offset_base || 0), length]]
|
|
257
|
+
end.to_h
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def collection_offset(index)
|
|
261
|
+
font_count = @bytes.unpack1("@8 N")
|
|
262
|
+
raise Error, "Font index #{index} out of a collection of #{font_count}" if index >= font_count
|
|
263
|
+
|
|
264
|
+
@bytes.unpack1("@#{12 + (index * 4)} N")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# A Mac resource fork: table offsets in its sfnt resources count from the resource's own start.
|
|
268
|
+
def resource_offset(index)
|
|
269
|
+
data_offset, map_offset = @bytes.unpack("N2")
|
|
270
|
+
type_list_offset, name_list_offset = @bytes.unpack("@#{map_offset + 24} n2")
|
|
271
|
+
type_list = map_offset + type_list_offset
|
|
272
|
+
type_count = @bytes.unpack1("@#{type_list} n") + 1
|
|
273
|
+
type_count.times do |type_index|
|
|
274
|
+
type, count, reference_list_offset = @bytes.unpack("@#{type_list + 2 + (type_index * 8)} a4 n2")
|
|
275
|
+
next unless type == "sfnt"
|
|
276
|
+
|
|
277
|
+
references = Array.new(count + 1) do |reference_index|
|
|
278
|
+
reference = type_list + reference_list_offset + (reference_index * 12)
|
|
279
|
+
_id, name_offset, attributes_and_offset = @bytes.unpack("@#{reference} n2 N")
|
|
280
|
+
name = (@bytes.byteslice(map_offset + name_list_offset + name_offset + 1, @bytes.getbyte(map_offset + name_list_offset + name_offset)) unless name_offset == 0xFFFF)
|
|
281
|
+
[name, data_offset + (attributes_and_offset & 0xFFFFFF) + 4]
|
|
282
|
+
end
|
|
283
|
+
chosen = index.is_a?(String) ? references.find { it[0] == index } : references[index]
|
|
284
|
+
raise Error, "No font #{index.inspect} in this resource file" unless chosen
|
|
285
|
+
|
|
286
|
+
@offset_base = chosen[1]
|
|
287
|
+
return chosen[1]
|
|
288
|
+
end
|
|
289
|
+
raise Error, "No sfnt resource in this resource file"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def read_unicode_cmap
|
|
293
|
+
return @unicode_cmap if defined?(@unicode_cmap)
|
|
294
|
+
|
|
295
|
+
cmap = table("cmap")
|
|
296
|
+
subtable_count = cmap.unpack1("@2 n")
|
|
297
|
+
candidates = Array.new(subtable_count) do |index|
|
|
298
|
+
platform, encoding, offset = cmap.unpack("@#{4 + (index * 8)} n2 N")
|
|
299
|
+
format = cmap.unpack1("@#{offset} n")
|
|
300
|
+
next unless UNICODE_CMAP_FORMATS.include?(format)
|
|
301
|
+
next unless (platform == 3 && [1, 10].include?(encoding)) || platform.zero?
|
|
302
|
+
|
|
303
|
+
[format, offset]
|
|
304
|
+
end.compact
|
|
305
|
+
format, offset = candidates.max_by(&:first)
|
|
306
|
+
@unicode_cmap = format && parse_cmap(cmap, format, offset)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def parse_cmap(cmap, format, offset)
|
|
310
|
+
glyph_by_code = {}
|
|
311
|
+
case format
|
|
312
|
+
when 4
|
|
313
|
+
segment_count = cmap.unpack1("@#{offset + 6} n") / 2
|
|
314
|
+
ends = cmap.unpack("@#{offset + 14} n#{segment_count}")
|
|
315
|
+
starts_offset = offset + 16 + (segment_count * 2)
|
|
316
|
+
starts = cmap.unpack("@#{starts_offset} n#{segment_count}")
|
|
317
|
+
deltas = cmap.unpack("@#{starts_offset + (segment_count * 2)} n#{segment_count}")
|
|
318
|
+
range_offsets_offset = starts_offset + (segment_count * 4)
|
|
319
|
+
range_offsets = cmap.unpack("@#{range_offsets_offset} n#{segment_count}")
|
|
320
|
+
segment_count.times do |segment|
|
|
321
|
+
(starts[segment]..ends[segment]).each do |code|
|
|
322
|
+
glyph =
|
|
323
|
+
if range_offsets[segment].zero?
|
|
324
|
+
(code + deltas[segment]) & 0xFFFF
|
|
325
|
+
else
|
|
326
|
+
glyph_offset = range_offsets_offset + (segment * 2) + range_offsets[segment] + ((code - starts[segment]) * 2)
|
|
327
|
+
raw = cmap.unpack1("@#{glyph_offset} n").to_i
|
|
328
|
+
raw.zero? ? 0 : (raw + deltas[segment]) & 0xFFFF
|
|
329
|
+
end
|
|
330
|
+
glyph_by_code[code] = glyph
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
when 6
|
|
334
|
+
first_code, count = cmap.unpack("@#{offset + 6} n2")
|
|
335
|
+
count = [count, (cmap.bytesize - offset - 10) / 2].min
|
|
336
|
+
cmap.unpack("@#{offset + 10} n#{count}").each_with_index { glyph_by_code[first_code + it[1]] = it[0] }
|
|
337
|
+
when 10
|
|
338
|
+
first_code, count = cmap.unpack("@#{offset + 12} N2")
|
|
339
|
+
count = [count, (cmap.bytesize - offset - 20) / 2].min
|
|
340
|
+
cmap.unpack("@#{offset + 20} n#{count}").each_with_index { glyph_by_code[first_code + it[1]] = it[0] }
|
|
341
|
+
when 12
|
|
342
|
+
group_count = [cmap.unpack1("@#{offset + 12} N"), (cmap.bytesize - offset - 16) / 12].min
|
|
343
|
+
cmap.unpack("@#{offset + 16} N#{group_count * 3}").each_slice(3) do |start_code, end_code, start_glyph|
|
|
344
|
+
(start_code..[end_code, MAXIMUM_CODE_POINT].min).each { glyph_by_code[it] = start_glyph + (it - start_code) }
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
glyph_by_code.each { |code, glyph| glyph_by_code[code] = 0 if glyph >= glyph_count }
|
|
348
|
+
glyph_by_code.default = 0
|
|
349
|
+
glyph_by_code.freeze
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def name_string(name_id)
|
|
353
|
+
name = table("name")
|
|
354
|
+
return unless name
|
|
355
|
+
|
|
356
|
+
count, strings_offset = name.unpack("@2 n2")
|
|
357
|
+
records = Array.new(count) { name.unpack("@#{6 + (it * 12)} n6") }
|
|
358
|
+
record = records.find { |platform, _, _, id| id == name_id && platform == 3 } ||
|
|
359
|
+
records.find { |platform, _, _, id| id == name_id && platform == 1 } ||
|
|
360
|
+
records.find { |_, _, _, id| id == name_id }
|
|
361
|
+
return unless record
|
|
362
|
+
|
|
363
|
+
platform, _encoding, _language, _id, length, offset = record
|
|
364
|
+
raw = name.byteslice(strings_offset + offset, length)
|
|
365
|
+
platform == 1 ? raw.force_encoding(::Encoding::MacRoman).encode(::Encoding::UTF_8) : raw.force_encoding(::Encoding::UTF_16BE).encode(::Encoding::UTF_8)
|
|
366
|
+
rescue ::Encoding::UndefinedConversionError, ::Encoding::InvalidByteSequenceError
|
|
367
|
+
nil
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PrawnX
|
|
4
|
+
module TrueType
|
|
5
|
+
# The parts of OpenType's GSUB and GPOS tables (OpenType specification, "OpenType Layout") PrawnX applies on
|
|
6
|
+
# request: ligatures (GSUB lookup type 4) and pair kerning (GPOS lookup type 2), for the font's Latin script.
|
|
7
|
+
class OpenTypeLayout
|
|
8
|
+
LIGATURE_SUBSTITUTION = 4
|
|
9
|
+
GSUB_EXTENSION = 7
|
|
10
|
+
PAIR_ADJUSTMENT = 2
|
|
11
|
+
GPOS_EXTENSION = 9
|
|
12
|
+
X_ADVANCE = 0x0004
|
|
13
|
+
PREFERRED_SCRIPTS = ["latn", "DFLT"].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(file, features)
|
|
16
|
+
@file = file
|
|
17
|
+
@features = features.map(&:to_s)
|
|
18
|
+
@ligatures_by_first_glyph = read_ligatures
|
|
19
|
+
@pair_lookups = read_pair_lookups
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Glyphs and the characters each stands for, ligatures applied: [[glyph, [code, …]], …].
|
|
23
|
+
def shape(codes)
|
|
24
|
+
clusters = codes.map { [@file.glyph_by_code[it], [it]] }
|
|
25
|
+
return clusters if @ligatures_by_first_glyph.empty?
|
|
26
|
+
|
|
27
|
+
shaped = []
|
|
28
|
+
index = 0
|
|
29
|
+
while index < clusters.size
|
|
30
|
+
ligature = (@ligatures_by_first_glyph[clusters[index][0]] || []).find do |components, _|
|
|
31
|
+
components.each_with_index.all? { |component, offset| clusters[index + 1 + offset]&.first == component }
|
|
32
|
+
end
|
|
33
|
+
if ligature
|
|
34
|
+
components, glyph = ligature
|
|
35
|
+
shaped << [glyph, clusters[index, components.size + 1].flat_map(&:last)]
|
|
36
|
+
index += components.size + 1
|
|
37
|
+
else
|
|
38
|
+
shaped << clusters[index]
|
|
39
|
+
index += 1
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
shaped
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The horizontal advance adjustment between two glyphs, in font units, or nil.
|
|
46
|
+
def kerning(left, right)
|
|
47
|
+
@pair_lookups.each do |lookup|
|
|
48
|
+
adjustment = lookup.call(left, right)
|
|
49
|
+
return adjustment if adjustment
|
|
50
|
+
end
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def read_ligatures
|
|
57
|
+
table = @file.table("GSUB")
|
|
58
|
+
return {} unless table && @features.include?("liga")
|
|
59
|
+
|
|
60
|
+
ligatures = Hash.new { |hash, glyph| hash[glyph] = [] }
|
|
61
|
+
lookups(table, "liga", LIGATURE_SUBSTITUTION, GSUB_EXTENSION).each do |subtable|
|
|
62
|
+
next unless table.unpack1("@#{subtable} n") == 1
|
|
63
|
+
|
|
64
|
+
coverage = coverage_glyphs(table, subtable + table.unpack1("@#{subtable + 2} n"))
|
|
65
|
+
set_count = table.unpack1("@#{subtable + 4} n")
|
|
66
|
+
table.unpack("@#{subtable + 6} n#{set_count}").each_with_index do |set_offset, index|
|
|
67
|
+
set = subtable + set_offset
|
|
68
|
+
table.unpack("@#{set + 2} n#{table.unpack1("@#{set} n")}").each do |ligature_offset|
|
|
69
|
+
ligature = set + ligature_offset
|
|
70
|
+
glyph, component_count = table.unpack("@#{ligature} n2")
|
|
71
|
+
ligatures[coverage[index]] << [table.unpack("@#{ligature + 4} n#{component_count - 1}"), glyph]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
ligatures.transform_values { |list| list.sort_by { -it[0].size } }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def read_pair_lookups
|
|
79
|
+
table = @file.table("GPOS")
|
|
80
|
+
return [] unless table && @features.include?("kern")
|
|
81
|
+
|
|
82
|
+
lookups(table, "kern", PAIR_ADJUSTMENT, GPOS_EXTENSION).filter_map { pair_lookup(table, it) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def pair_lookup(table, subtable)
|
|
86
|
+
format, coverage_offset, first_format, second_format = table.unpack("@#{subtable} n4")
|
|
87
|
+
return unless (first_format & X_ADVANCE).nonzero?
|
|
88
|
+
|
|
89
|
+
coverage = coverage_index(table, subtable + coverage_offset)
|
|
90
|
+
advance_position = 2 * (first_format & (X_ADVANCE - 1)).digits(2).count(1)
|
|
91
|
+
record_size = 2 * (first_format.digits(2).count(1) + second_format.digits(2).count(1))
|
|
92
|
+
case format
|
|
93
|
+
when 1
|
|
94
|
+
set_offsets = table.unpack("@#{subtable + 10} n#{table.unpack1("@#{subtable + 8} n")}")
|
|
95
|
+
lambda do |left, right|
|
|
96
|
+
index = coverage[left]
|
|
97
|
+
next unless index
|
|
98
|
+
|
|
99
|
+
set = subtable + set_offsets[index]
|
|
100
|
+
count = table.unpack1("@#{set} n")
|
|
101
|
+
record = (0...count).find { table.unpack1("@#{set + 2 + (it * (record_size + 2))} n") == right }
|
|
102
|
+
record && table.unpack1("@#{set + 4 + (record * (record_size + 2)) + advance_position} s>")
|
|
103
|
+
end
|
|
104
|
+
when 2
|
|
105
|
+
first_classes = class_definition(table, subtable + table.unpack1("@#{subtable + 8} n"))
|
|
106
|
+
second_classes = class_definition(table, subtable + table.unpack1("@#{subtable + 10} n"))
|
|
107
|
+
_first_count, second_count = table.unpack("@#{subtable + 12} n2")
|
|
108
|
+
lambda do |left, right|
|
|
109
|
+
next unless coverage[left]
|
|
110
|
+
|
|
111
|
+
record = (first_classes[left] * second_count) + second_classes[right]
|
|
112
|
+
table.unpack1("@#{subtable + 16 + (record * record_size) + advance_position} s>").then { it.zero? ? nil : it }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Subtables of the lookups a feature uses for the preferred script's default language, of the given type.
|
|
118
|
+
def lookups(table, feature_tag, type, extension_type)
|
|
119
|
+
script_list, feature_list, lookup_list = table.unpack("@4 n3")
|
|
120
|
+
feature_indices = default_language_features(table, script_list)
|
|
121
|
+
feature_count = table.unpack1("@#{feature_list} n")
|
|
122
|
+
lookup_indices = feature_indices.flat_map do |feature_index|
|
|
123
|
+
next [] if feature_index >= feature_count
|
|
124
|
+
|
|
125
|
+
tag, offset = table.unpack("@#{feature_list + 2 + (feature_index * 6)} a4 n")
|
|
126
|
+
next [] unless tag == feature_tag
|
|
127
|
+
|
|
128
|
+
feature = feature_list + offset
|
|
129
|
+
table.unpack("@#{feature + 4} n#{table.unpack1("@#{feature + 2} n")}")
|
|
130
|
+
end
|
|
131
|
+
lookup_indices.uniq.flat_map do |lookup_index|
|
|
132
|
+
lookup = lookup_list + table.unpack1("@#{lookup_list + 2 + (lookup_index * 2)} n")
|
|
133
|
+
lookup_type, _flags, subtable_count = table.unpack("@#{lookup} n3")
|
|
134
|
+
table.unpack("@#{lookup + 6} n#{subtable_count}").filter_map do |offset|
|
|
135
|
+
subtable = lookup + offset
|
|
136
|
+
if lookup_type == extension_type
|
|
137
|
+
actual_type, extension_offset = table.unpack("@#{subtable + 2} n N")
|
|
138
|
+
subtable + extension_offset if actual_type == type
|
|
139
|
+
elsif lookup_type == type
|
|
140
|
+
subtable
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def default_language_features(table, script_list)
|
|
147
|
+
script_count = table.unpack1("@#{script_list} n")
|
|
148
|
+
offset_by_tag = Array.new(script_count) { table.unpack("@#{script_list + 2 + (it * 6)} a4 n") }.to_h
|
|
149
|
+
script_offset = offset_by_tag.values_at(*PREFERRED_SCRIPTS).compact.first || offset_by_tag.values.first
|
|
150
|
+
return [] unless script_offset
|
|
151
|
+
|
|
152
|
+
script = script_list + script_offset
|
|
153
|
+
language_offset = table.unpack1("@#{script} n")
|
|
154
|
+
return [] if language_offset.zero?
|
|
155
|
+
|
|
156
|
+
language = script + language_offset
|
|
157
|
+
table.unpack("@#{language + 6} n#{table.unpack1("@#{language + 4} n")}")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def coverage_glyphs(table, offset)
|
|
161
|
+
coverage_index(table, offset).sort_by(&:last).map(&:first)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def coverage_index(table, offset)
|
|
165
|
+
format, count = table.unpack("@#{offset} n2")
|
|
166
|
+
if format == 1
|
|
167
|
+
table.unpack("@#{offset + 4} n#{count}").each_with_index.to_h
|
|
168
|
+
else
|
|
169
|
+
table.unpack("@#{offset + 4} n#{count * 3}").each_slice(3).each_with_object({}) do |(first, last, start_index), index|
|
|
170
|
+
(first..last).each { index[it] = start_index + (it - first) }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def class_definition(table, offset)
|
|
176
|
+
classes = Hash.new(0)
|
|
177
|
+
format = table.unpack1("@#{offset} n")
|
|
178
|
+
if format == 1
|
|
179
|
+
start, count = table.unpack("@#{offset + 2} n2")
|
|
180
|
+
table.unpack("@#{offset + 6} n#{count}").each_with_index { classes[start + it[1]] = it[0] }
|
|
181
|
+
else
|
|
182
|
+
count = table.unpack1("@#{offset + 2} n")
|
|
183
|
+
table.unpack("@#{offset + 4} n#{count * 3}").each_slice(3) { |first, last, value| (first..last).each { classes[it] = value } }
|
|
184
|
+
end
|
|
185
|
+
classes
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PrawnX
|
|
4
|
+
module TrueType
|
|
5
|
+
# Characters of a TrueType font split into simple fonts of single-byte codes, laid out as Prawn lays them
|
|
6
|
+
# (through TTFunk) so every reader renders them the same way: the first subset codes characters in Mac Roman,
|
|
7
|
+
# keeping space at 32 for word spacing; the next ones take 223 characters each, from code 33.
|
|
8
|
+
class SimpleSubsets
|
|
9
|
+
Subset = Struct.new(:code_by_cluster, :glyph_by_code, :next_code) do
|
|
10
|
+
def full?
|
|
11
|
+
next_code > 255
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
SPACE = 32
|
|
16
|
+
FIRST_FREE_CODE = 33
|
|
17
|
+
|
|
18
|
+
def initialize(glyph_for_cluster)
|
|
19
|
+
@glyph_for_cluster = glyph_for_cluster
|
|
20
|
+
@location_by_cluster = {}
|
|
21
|
+
@subsets = [Subset.new({SPACE => SPACE}, {SPACE => glyph_for_cluster.call([SPACE])}, nil)]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def size
|
|
25
|
+
@subsets.size
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def [](index)
|
|
29
|
+
@subsets[index]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# [[subset index, bytes], …] for clusters of characters, each cluster [glyph, codes].
|
|
33
|
+
def encode(clusters)
|
|
34
|
+
encode_keys(clusters) { |cluster| [cluster[1], cluster[0]] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The same for single characters, by code point: the common case, without building a cluster per character.
|
|
38
|
+
def encode_codepoints(codepoints, glyph_by_code)
|
|
39
|
+
encode_keys(codepoints) { |code| [code, glyph_by_code[code]] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# A character keeps its subset and code once given: later lookups are a single hash access.
|
|
45
|
+
def encode_keys(items)
|
|
46
|
+
parts = []
|
|
47
|
+
items.each do |item|
|
|
48
|
+
key, glyph = yield item
|
|
49
|
+
index, code = @location_by_cluster[key] ||= code_for([glyph, key])
|
|
50
|
+
if parts.empty? || parts.last[0] != index
|
|
51
|
+
parts << [index, code.chr.b]
|
|
52
|
+
else
|
|
53
|
+
parts.last[1] << code
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
parts
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def code_for(cluster)
|
|
60
|
+
glyph, codes = cluster
|
|
61
|
+
key = codes
|
|
62
|
+
mac_roman = mac_roman_code(codes)
|
|
63
|
+
if mac_roman
|
|
64
|
+
@subsets[0].code_by_cluster[key] ||= mac_roman.tap { @subsets[0].glyph_by_code[mac_roman] = glyph }
|
|
65
|
+
return [0, mac_roman]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@subsets.each_with_index.drop(1).each do |subset, index|
|
|
69
|
+
code = subset.code_by_cluster[key]
|
|
70
|
+
return [index, code] if code
|
|
71
|
+
end
|
|
72
|
+
subset_index = @subsets.rindex { it.next_code && !it.full? } || begin
|
|
73
|
+
@subsets << Subset.new({[SPACE] => SPACE, SPACE => SPACE}, {SPACE => @glyph_for_cluster.call([SPACE])}, FIRST_FREE_CODE)
|
|
74
|
+
@subsets.size - 1
|
|
75
|
+
end
|
|
76
|
+
subset = @subsets[subset_index]
|
|
77
|
+
code = subset.next_code
|
|
78
|
+
subset.code_by_cluster[key] = code
|
|
79
|
+
subset.glyph_by_code[code] = glyph
|
|
80
|
+
subset.next_code += 1
|
|
81
|
+
[subset_index, code]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def mac_roman_code(codes)
|
|
85
|
+
codes = Array(codes)
|
|
86
|
+
return unless codes.size == 1
|
|
87
|
+
|
|
88
|
+
[codes.first].pack("U").encode(::Encoding::MacRoman).getbyte(0)
|
|
89
|
+
rescue ::Encoding::UndefinedConversionError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|