fontisan 0.2.11 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +294 -52
- data/Gemfile +5 -0
- data/README.adoc +163 -2
- data/docs/CONVERSION_GUIDE.adoc +633 -0
- data/docs/TYPE1_FONTS.adoc +445 -0
- data/lib/fontisan/cli.rb +177 -6
- data/lib/fontisan/commands/convert_command.rb +32 -1
- data/lib/fontisan/commands/info_command.rb +83 -2
- data/lib/fontisan/config/conversion_matrix.yml +132 -4
- data/lib/fontisan/constants.rb +12 -0
- data/lib/fontisan/conversion_options.rb +378 -0
- data/lib/fontisan/converters/collection_converter.rb +45 -10
- data/lib/fontisan/converters/format_converter.rb +17 -5
- data/lib/fontisan/converters/outline_converter.rb +78 -4
- data/lib/fontisan/converters/type1_converter.rb +1234 -0
- data/lib/fontisan/font_loader.rb +46 -3
- data/lib/fontisan/hints/hint_converter.rb +4 -1
- data/lib/fontisan/type1/afm_generator.rb +436 -0
- data/lib/fontisan/type1/afm_parser.rb +298 -0
- data/lib/fontisan/type1/agl.rb +456 -0
- data/lib/fontisan/type1/cff_to_type1_converter.rb +302 -0
- data/lib/fontisan/type1/charstring_converter.rb +240 -0
- data/lib/fontisan/type1/charstrings.rb +408 -0
- data/lib/fontisan/type1/conversion_options.rb +243 -0
- data/lib/fontisan/type1/decryptor.rb +183 -0
- data/lib/fontisan/type1/encodings.rb +697 -0
- data/lib/fontisan/type1/font_dictionary.rb +576 -0
- data/lib/fontisan/type1/generator.rb +220 -0
- data/lib/fontisan/type1/inf_generator.rb +332 -0
- data/lib/fontisan/type1/pfa_generator.rb +369 -0
- data/lib/fontisan/type1/pfa_parser.rb +159 -0
- data/lib/fontisan/type1/pfb_generator.rb +314 -0
- data/lib/fontisan/type1/pfb_parser.rb +166 -0
- data/lib/fontisan/type1/pfm_generator.rb +610 -0
- data/lib/fontisan/type1/pfm_parser.rb +433 -0
- data/lib/fontisan/type1/private_dict.rb +342 -0
- data/lib/fontisan/type1/seac_expander.rb +501 -0
- data/lib/fontisan/type1/ttf_to_type1_converter.rb +327 -0
- data/lib/fontisan/type1/upm_scaler.rb +118 -0
- data/lib/fontisan/type1.rb +75 -0
- data/lib/fontisan/type1_font.rb +318 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +2 -0
- metadata +30 -3
- data/docs/DOCUMENTATION_SUMMARY.md +0 -141
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../constants"
|
|
4
|
+
require_relative "upm_scaler"
|
|
5
|
+
require_relative "afm_generator"
|
|
6
|
+
|
|
7
|
+
module Fontisan
|
|
8
|
+
module Type1
|
|
9
|
+
# PFM (Printer Font Metrics) file generator
|
|
10
|
+
#
|
|
11
|
+
# [`PFMGenerator`](lib/fontisan/type1/pfm_generator.rb) generates Printer Font Metrics
|
|
12
|
+
# files from TTF/OTF fonts.
|
|
13
|
+
#
|
|
14
|
+
# PFM files are binary files used by Windows for printer font metrics.
|
|
15
|
+
# They include:
|
|
16
|
+
# - Character widths
|
|
17
|
+
# - Kerning pairs
|
|
18
|
+
# - Font metadata (name, version, copyright, etc.)
|
|
19
|
+
# - Extended text metrics
|
|
20
|
+
#
|
|
21
|
+
# @example Generate PFM from TTF
|
|
22
|
+
# font = Fontisan::FontLoader.load("font.ttf")
|
|
23
|
+
# pfm_data = Fontisan::Type1::PFMGenerator.generate(font)
|
|
24
|
+
# File.binwrite("font.pfm", pfm_data)
|
|
25
|
+
#
|
|
26
|
+
# @example Generate PFM with 1000 UPM scaling
|
|
27
|
+
# pfm_data = Fontisan::Type1::PFMGenerator.generate(font, upm_scale: 1000)
|
|
28
|
+
#
|
|
29
|
+
# @see https://www.adobe.com/devnet/font/pdfs/5005.PFM_Spec.pdf
|
|
30
|
+
class PFMGenerator
|
|
31
|
+
# PFM constants
|
|
32
|
+
PFM_VERSION = 0x0100
|
|
33
|
+
PFM_HEADER_SIZE = 256
|
|
34
|
+
|
|
35
|
+
# Driver info structure
|
|
36
|
+
DRIVER_INFO_SIZE = 118
|
|
37
|
+
|
|
38
|
+
# Extended metrics size
|
|
39
|
+
EXT_METRICS_SIZE = 48
|
|
40
|
+
|
|
41
|
+
# Windows charset constants
|
|
42
|
+
ANSI_CHARSET = 0
|
|
43
|
+
DEFAULT_CHARSET = 1
|
|
44
|
+
SYMBOL_CHARSET = 2
|
|
45
|
+
|
|
46
|
+
# Font pitch and family bits
|
|
47
|
+
FIXED_PITCH = 1
|
|
48
|
+
VARIABLE_PITCH = 0
|
|
49
|
+
|
|
50
|
+
# Family bits (shift left 4)
|
|
51
|
+
FAMILY_DONTCARE = 0 << 4
|
|
52
|
+
FAMILY_ROMAN = 1 << 4
|
|
53
|
+
FAMILY_SWISS = 2 << 4
|
|
54
|
+
FAMILY_MODERN = 3 << 4
|
|
55
|
+
FAMILY_SCRIPT = 4 << 4
|
|
56
|
+
FAMILY_DECORATIVE = 5 << 4
|
|
57
|
+
FAMILY_MODERN_LOWERCASE = 6 << 4
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
# Generate PFM binary data from a font
|
|
61
|
+
#
|
|
62
|
+
# @param font [Fontisan::TrueTypeFont, Fontisan::OpenTypeFont] The font to generate PFM from
|
|
63
|
+
# @param options [Hash] Generation options
|
|
64
|
+
# @option options [Integer, :native] :upm_scale Target UPM (1000 for Type 1, :native for no scaling)
|
|
65
|
+
# @return [String] PFM file binary data
|
|
66
|
+
def generate(font, options = {})
|
|
67
|
+
new(font, options).generate_pfm
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Generate PFM file from a font and write to file
|
|
71
|
+
#
|
|
72
|
+
# @param font [Fontisan::TrueTypeFont, Fontisan::OpenTypeFont] The font to generate PFM from
|
|
73
|
+
# @param path [String] Path to write PFM file
|
|
74
|
+
# @param options [Hash] Generation options
|
|
75
|
+
# @return [void]
|
|
76
|
+
def generate_to_file(font, path, options = {})
|
|
77
|
+
pfm_data = generate(font, options)
|
|
78
|
+
File.binwrite(path, pfm_data)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Initialize a new PFMGenerator
|
|
83
|
+
#
|
|
84
|
+
# @param font [Fontisan::TrueTypeFont, Fontisan::OpenTypeFont] The font to generate PFM from
|
|
85
|
+
# @param options [Hash] Generation options
|
|
86
|
+
def initialize(font, options = {})
|
|
87
|
+
@font = font
|
|
88
|
+
@metrics = MetricsCalculator.new(font)
|
|
89
|
+
|
|
90
|
+
# Set up scaler
|
|
91
|
+
upm_scale = options[:upm_scale] || 1000
|
|
92
|
+
@scaler = if upm_scale == :native
|
|
93
|
+
UPMScaler.native(font)
|
|
94
|
+
else
|
|
95
|
+
UPMScaler.new(font, target_upm: upm_scale)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Generate PFM binary data
|
|
100
|
+
#
|
|
101
|
+
# @return [String] PFM file binary data
|
|
102
|
+
def generate_pfm
|
|
103
|
+
# Collect font data
|
|
104
|
+
char_widths = collect_character_widths
|
|
105
|
+
return "" if char_widths.empty?
|
|
106
|
+
|
|
107
|
+
# Build sections
|
|
108
|
+
header_data = build_header(char_widths)
|
|
109
|
+
face_name_data = build_face_name
|
|
110
|
+
driver_info_data = build_driver_info
|
|
111
|
+
ext_metrics_data = build_extended_metrics
|
|
112
|
+
width_table_data = build_width_table(char_widths)
|
|
113
|
+
kerning_data = build_kerning_table
|
|
114
|
+
|
|
115
|
+
# Calculate offsets
|
|
116
|
+
dfFace_offset = PFM_HEADER_SIZE
|
|
117
|
+
dfExtMetrics_offset = dfFace_offset + face_name_data.length + driver_info_data.length
|
|
118
|
+
dfExtentTable_offset = dfExtMetrics_offset + ext_metrics_data.length
|
|
119
|
+
dfPairKernTable_offset = if kerning_data.empty?
|
|
120
|
+
0
|
|
121
|
+
else
|
|
122
|
+
dfExtentTable_offset + width_table_data.length
|
|
123
|
+
end
|
|
124
|
+
dfDriverInfo_offset = if dfPairKernTable_offset.positive?
|
|
125
|
+
dfPairKernTable_offset + kerning_data.length
|
|
126
|
+
else
|
|
127
|
+
dfExtentTable_offset + width_table_data.length
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Update offsets in header
|
|
131
|
+
update_header_offsets(header_data, dfFace_offset, dfExtMetrics_offset,
|
|
132
|
+
dfExtentTable_offset, dfPairKernTable_offset,
|
|
133
|
+
dfDriverInfo_offset)
|
|
134
|
+
|
|
135
|
+
# Combine all sections: Header + Face Name + Driver Info + Ext Metrics + Width Table + Kerning
|
|
136
|
+
header_data + face_name_data + driver_info_data + ext_metrics_data +
|
|
137
|
+
width_table_data + kerning_data
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# Collect character widths from TTF
|
|
143
|
+
#
|
|
144
|
+
# @return [Hash] Character index to width mapping
|
|
145
|
+
def collect_character_widths
|
|
146
|
+
widths = {}
|
|
147
|
+
|
|
148
|
+
cmap = @font.table(Constants::CMAP_TAG)
|
|
149
|
+
return widths unless cmap
|
|
150
|
+
|
|
151
|
+
# Get Unicode mappings
|
|
152
|
+
mappings = if cmap.respond_to?(:unicode_mappings)
|
|
153
|
+
cmap.unicode_mappings || {}
|
|
154
|
+
else
|
|
155
|
+
{}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Get widths for characters 0-255
|
|
159
|
+
mappings.each do |codepoint, glyph_id|
|
|
160
|
+
next unless codepoint >= 0 && codepoint <= 255
|
|
161
|
+
|
|
162
|
+
width = @metrics.glyph_width(glyph_id)
|
|
163
|
+
next unless width
|
|
164
|
+
|
|
165
|
+
# Scale width
|
|
166
|
+
scaled_width = @scaler.scale_width(width)
|
|
167
|
+
widths[codepoint] = scaled_width
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
widths
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Build face name as Pascal string
|
|
174
|
+
#
|
|
175
|
+
# @return [String] Face name as Pascal string (length byte + string data)
|
|
176
|
+
def build_face_name
|
|
177
|
+
face_name = extract_face_name[0, 255] # Limit to 255 chars
|
|
178
|
+
[face_name.length].pack("C") + face_name
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Build PFM header
|
|
182
|
+
#
|
|
183
|
+
# @param char_widths [Hash] Character widths
|
|
184
|
+
# @return [String] Header binary data (256 bytes)
|
|
185
|
+
def build_header(char_widths)
|
|
186
|
+
header = String.new(encoding: "ASCII-8BIT")
|
|
187
|
+
|
|
188
|
+
# Get font metrics
|
|
189
|
+
hhea = @font.table(Constants::HHEA_TAG)
|
|
190
|
+
head = @font.table(Constants::HEAD_TAG)
|
|
191
|
+
post = @font.table(Constants::POST_TAG)
|
|
192
|
+
@font.table(Constants::OS2_TAG)
|
|
193
|
+
|
|
194
|
+
# Version (2 bytes at offset 0)
|
|
195
|
+
header << [PFM_VERSION].pack("v")
|
|
196
|
+
|
|
197
|
+
# dfSize (4 bytes at offset 2) - placeholder, will update
|
|
198
|
+
header << [0].pack("V")
|
|
199
|
+
|
|
200
|
+
# Copyright (60 bytes at offset 6)
|
|
201
|
+
copyright = extract_copyright[0, 59]
|
|
202
|
+
header << [copyright.length].pack("C")
|
|
203
|
+
header << copyright.ljust(59, "\0")
|
|
204
|
+
|
|
205
|
+
# dfType (2 bytes at offset 66) - 0 for Type 1
|
|
206
|
+
header << [0].pack("v")
|
|
207
|
+
|
|
208
|
+
# dfPoints (2 bytes at offset 68) - Use units_per_em / 2 as approximation
|
|
209
|
+
points = head&.units_per_em ? head.units_per_em / 2 : 500
|
|
210
|
+
header << [points].pack("v")
|
|
211
|
+
|
|
212
|
+
# dfVertRes (2 bytes at offset 70)
|
|
213
|
+
header << [300].pack("v")
|
|
214
|
+
|
|
215
|
+
# dfHorizRes (2 bytes at offset 72)
|
|
216
|
+
header << [300].pack("v")
|
|
217
|
+
|
|
218
|
+
# dfAscent (2 bytes at offset 74)
|
|
219
|
+
ascent = hhea&.ascent || @metrics.ascent || 1000
|
|
220
|
+
header << [clamp_to_u16(ascent)].pack("v")
|
|
221
|
+
|
|
222
|
+
# dfInternalLeading (2 bytes at offset 76)
|
|
223
|
+
internal_leading = hhea&.line_gap || 0
|
|
224
|
+
header << [clamp_to_u16(internal_leading)].pack("v")
|
|
225
|
+
|
|
226
|
+
# dfExternalLeading (2 bytes at offset 78)
|
|
227
|
+
header << [0].pack("v")
|
|
228
|
+
|
|
229
|
+
# dfItalic (1 byte at offset 80)
|
|
230
|
+
italic = (post&.italic_angle || 0).zero? ? 0 : 1
|
|
231
|
+
header << [italic].pack("C")
|
|
232
|
+
|
|
233
|
+
# dfUnderline (1 byte at offset 81)
|
|
234
|
+
header << [1].pack("C")
|
|
235
|
+
|
|
236
|
+
# dfStrikeOut (1 byte at offset 82)
|
|
237
|
+
header << [0].pack("C")
|
|
238
|
+
|
|
239
|
+
# dfWeight (2 bytes at offset 83)
|
|
240
|
+
weight = extract_weight_value
|
|
241
|
+
header << [weight].pack("v")
|
|
242
|
+
|
|
243
|
+
# dfCharSet (1 byte at offset 85)
|
|
244
|
+
header << [DEFAULT_CHARSET].pack("C")
|
|
245
|
+
|
|
246
|
+
# dfPixWidth (2 bytes at offset 86)
|
|
247
|
+
header << [0].pack("v")
|
|
248
|
+
|
|
249
|
+
# dfPixHeight (2 bytes at offset 88)
|
|
250
|
+
header << [0].pack("v")
|
|
251
|
+
|
|
252
|
+
# dfPitchAndFamily (1 byte at offset 90)
|
|
253
|
+
pitch_and_family = pitch_and_family_value
|
|
254
|
+
header << [pitch_and_family].pack("C")
|
|
255
|
+
|
|
256
|
+
# dfAverageWidth (2 bytes at offset 91)
|
|
257
|
+
avg_width = calculate_average_width(char_widths)
|
|
258
|
+
header << [clamp_to_u16(avg_width)].pack("v")
|
|
259
|
+
|
|
260
|
+
# dfMaxWidth (2 bytes at offset 93)
|
|
261
|
+
max_width = char_widths.values.max || 1000
|
|
262
|
+
header << [clamp_to_u16(max_width)].pack("v")
|
|
263
|
+
|
|
264
|
+
# dfFirstChar (1 byte at offset 95)
|
|
265
|
+
first_char = char_widths.keys.min || 0
|
|
266
|
+
header << [clamp_to_u8(first_char)].pack("C")
|
|
267
|
+
|
|
268
|
+
# dfLastChar (1 byte at offset 96)
|
|
269
|
+
last_char = char_widths.keys.max || 255
|
|
270
|
+
header << [clamp_to_u8(last_char)].pack("C")
|
|
271
|
+
|
|
272
|
+
# dfDefaultChar (1 byte at offset 97)
|
|
273
|
+
header << [32].pack("C") # Space
|
|
274
|
+
|
|
275
|
+
# dfBreakChar (1 byte at offset 98)
|
|
276
|
+
header << [32].pack("C") # Space
|
|
277
|
+
|
|
278
|
+
# dfWidthBytes (2 bytes at offset 99)
|
|
279
|
+
width_bytes = ((char_widths.keys.max || 255) + 1) * 2
|
|
280
|
+
header << [width_bytes].pack("v")
|
|
281
|
+
|
|
282
|
+
# dfDevice (4 bytes at offset 101)
|
|
283
|
+
header << [0].pack("V")
|
|
284
|
+
|
|
285
|
+
# dfFace (4 bytes at offset 105) - placeholder
|
|
286
|
+
header << [0].pack("V")
|
|
287
|
+
|
|
288
|
+
# BitsPointer (4 bytes at offset 109)
|
|
289
|
+
header << [0].pack("V")
|
|
290
|
+
|
|
291
|
+
# BitsOffset (4 bytes at offset 113)
|
|
292
|
+
header << [0].pack("V")
|
|
293
|
+
|
|
294
|
+
# dfExtMetricsOffset (4 bytes at offset 117) - placeholder
|
|
295
|
+
header << [0].pack("V")
|
|
296
|
+
|
|
297
|
+
# dfExtentTable (4 bytes at offset 121) - placeholder
|
|
298
|
+
header << [0].pack("V")
|
|
299
|
+
|
|
300
|
+
# dfOriginTable (4 bytes at offset 125)
|
|
301
|
+
header << [0].pack("V")
|
|
302
|
+
|
|
303
|
+
# dfPairKernTable (4 bytes at offset 129) - placeholder
|
|
304
|
+
header << [0].pack("V")
|
|
305
|
+
|
|
306
|
+
# dfTrackKernTable (4 bytes at offset 133)
|
|
307
|
+
header << [0].pack("V")
|
|
308
|
+
|
|
309
|
+
# dfDriverInfo (4 bytes at offset 137) - placeholder
|
|
310
|
+
header << [0].pack("V")
|
|
311
|
+
|
|
312
|
+
# dfReserved (4 bytes at offset 141)
|
|
313
|
+
header << [0].pack("V")
|
|
314
|
+
|
|
315
|
+
# dfSignature (4 bytes at offset 145)
|
|
316
|
+
header << [0x50414D4B].pack("V") # 'PAMK'
|
|
317
|
+
|
|
318
|
+
# Pad to 256 bytes
|
|
319
|
+
header << "\0" * (PFM_HEADER_SIZE - header.length)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Build driver info section
|
|
323
|
+
#
|
|
324
|
+
# @return [String] Driver info binary data
|
|
325
|
+
def build_driver_info
|
|
326
|
+
info = String.new(encoding: "ASCII-8BIT")
|
|
327
|
+
|
|
328
|
+
# Driver info structure (118 bytes)
|
|
329
|
+
# Most fields are reserved/unused
|
|
330
|
+
|
|
331
|
+
# Windows reserved
|
|
332
|
+
info << [0].pack("V") * 22
|
|
333
|
+
|
|
334
|
+
# Offset to Windows reserved fields (not used)
|
|
335
|
+
info << [0].pack("V")
|
|
336
|
+
|
|
337
|
+
# Offset to driver name (not used)
|
|
338
|
+
info << [0].pack("V")
|
|
339
|
+
|
|
340
|
+
# Fill to 118 bytes
|
|
341
|
+
info << "\0" * (DRIVER_INFO_SIZE - info.length)
|
|
342
|
+
|
|
343
|
+
info
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Build extended text metrics
|
|
347
|
+
#
|
|
348
|
+
# @return [String] Extended metrics binary data (48 bytes)
|
|
349
|
+
def build_extended_metrics
|
|
350
|
+
metrics = String.new(encoding: "ASCII-8BIT")
|
|
351
|
+
|
|
352
|
+
os2 = @font.table(Constants::OS2_TAG)
|
|
353
|
+
|
|
354
|
+
# etmSize (4 bytes)
|
|
355
|
+
metrics << [0].pack("V")
|
|
356
|
+
|
|
357
|
+
# etmPointSize (4 bytes)
|
|
358
|
+
metrics << [0].pack("V")
|
|
359
|
+
|
|
360
|
+
# etmOrientation (4 bytes)
|
|
361
|
+
metrics << [0].pack("V")
|
|
362
|
+
|
|
363
|
+
# etmMasterHeight (4 bytes)
|
|
364
|
+
metrics << [0].pack("V")
|
|
365
|
+
|
|
366
|
+
# etmMinScale (4 bytes)
|
|
367
|
+
metrics << [0].pack("V")
|
|
368
|
+
|
|
369
|
+
# etmMaxScale (4 bytes)
|
|
370
|
+
metrics << [0].pack("V")
|
|
371
|
+
|
|
372
|
+
# etmMasterUnits (4 bytes)
|
|
373
|
+
metrics << [0].pack("V")
|
|
374
|
+
|
|
375
|
+
# etmCapHeight (4 bytes)
|
|
376
|
+
cap_height = if os2.respond_to?(:cap_height) && os2.cap_height
|
|
377
|
+
os2.cap_height
|
|
378
|
+
elsif os2.respond_to?(:s_typo_ascender) && os2.s_typo_ascender
|
|
379
|
+
os2.s_typo_ascender
|
|
380
|
+
else
|
|
381
|
+
@metrics.ascent || 1000
|
|
382
|
+
end
|
|
383
|
+
metrics << [@scaler.scale(cap_height)].pack("V")
|
|
384
|
+
|
|
385
|
+
# etmXHeight (4 bytes)
|
|
386
|
+
x_height = if os2.respond_to?(:x_height) && os2.x_height&.positive?
|
|
387
|
+
os2.x_height
|
|
388
|
+
else
|
|
389
|
+
# Fallback: use roughly half the ascent for x-height
|
|
390
|
+
(@metrics.ascent / 2) || 500
|
|
391
|
+
end
|
|
392
|
+
metrics << [@scaler.scale(x_height)].pack("V")
|
|
393
|
+
|
|
394
|
+
# etmLowerCaseAscent (4 bytes)
|
|
395
|
+
metrics << [0].pack("V")
|
|
396
|
+
|
|
397
|
+
# etmLowerCaseDescent (4 bytes)
|
|
398
|
+
metrics << [0].pack("V")
|
|
399
|
+
|
|
400
|
+
# etmSlant (4 bytes)
|
|
401
|
+
metrics << [0].pack("V")
|
|
402
|
+
|
|
403
|
+
# etmSuperScript (4 bytes)
|
|
404
|
+
metrics << [0].pack("V")
|
|
405
|
+
|
|
406
|
+
# etmSubScript (4 bytes)
|
|
407
|
+
metrics << [0].pack("V")
|
|
408
|
+
|
|
409
|
+
# etmSuperScriptSize (4 bytes)
|
|
410
|
+
metrics << [0].pack("V")
|
|
411
|
+
|
|
412
|
+
# etmSubScriptSize (4 bytes)
|
|
413
|
+
metrics << [0].pack("V")
|
|
414
|
+
|
|
415
|
+
# etmUnderlineOffset (4 bytes)
|
|
416
|
+
metrics << [0].pack("V")
|
|
417
|
+
|
|
418
|
+
# etmUnderlineWidth (4 bytes)
|
|
419
|
+
metrics << [0].pack("V")
|
|
420
|
+
|
|
421
|
+
# etmDoubleUpperUnderlineOffset (4 bytes)
|
|
422
|
+
metrics << [0].pack("V")
|
|
423
|
+
|
|
424
|
+
# etmDoubleLowerUnderlineOffset (4 bytes)
|
|
425
|
+
metrics << [0].pack("V")
|
|
426
|
+
|
|
427
|
+
# etmDoubleUpperUnderlineWidth (4 bytes)
|
|
428
|
+
metrics << [0].pack("V")
|
|
429
|
+
|
|
430
|
+
# etmDoubleLowerUnderlineWidth (4 bytes)
|
|
431
|
+
metrics << [0].pack("V")
|
|
432
|
+
|
|
433
|
+
# etmStrikeOutOffset (4 bytes)
|
|
434
|
+
metrics << [0].pack("V")
|
|
435
|
+
|
|
436
|
+
# etmStrikeOutWidth (4 bytes)
|
|
437
|
+
metrics << [0].pack("V")
|
|
438
|
+
|
|
439
|
+
# etmKernPairs (4 bytes)
|
|
440
|
+
metrics << [0].pack("V")
|
|
441
|
+
|
|
442
|
+
# etmKernTracks (4 bytes)
|
|
443
|
+
metrics << [0].pack("V")
|
|
444
|
+
|
|
445
|
+
metrics
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Build width table
|
|
449
|
+
#
|
|
450
|
+
# @param char_widths [Hash] Character widths
|
|
451
|
+
# @return [String] Width table binary data
|
|
452
|
+
def build_width_table(char_widths)
|
|
453
|
+
table = String.new(encoding: "ASCII-8BIT")
|
|
454
|
+
|
|
455
|
+
# Number of extents (2 bytes)
|
|
456
|
+
num_extents = (char_widths.keys.max || 255) + 1
|
|
457
|
+
table << [num_extents].pack("v")
|
|
458
|
+
|
|
459
|
+
# Character widths (2 bytes each)
|
|
460
|
+
(0...num_extents).each do |i|
|
|
461
|
+
width = char_widths[i] || 0
|
|
462
|
+
table << [clamp_to_u16(width)].pack("v")
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
table
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Build kerning table
|
|
469
|
+
#
|
|
470
|
+
# @return [String] Kerning table binary data
|
|
471
|
+
def build_kerning_table
|
|
472
|
+
# For now, return empty kerning data
|
|
473
|
+
# Full implementation would parse GPOS table
|
|
474
|
+
String.new(encoding: "ASCII-8BIT")
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Update offsets in header data
|
|
478
|
+
#
|
|
479
|
+
# @param header [String] Header data (mutable via byteslice)
|
|
480
|
+
def update_header_offsets(header, face_offset, ext_metrics_offset,
|
|
481
|
+
extent_table_offset, kern_table_offset,
|
|
482
|
+
driver_info_offset)
|
|
483
|
+
# dfFace (4 bytes at offset 105)
|
|
484
|
+
header[105, 4] = [face_offset].pack("V")
|
|
485
|
+
|
|
486
|
+
# dfExtMetricsOffset (4 bytes at offset 117)
|
|
487
|
+
header[117, 4] = [ext_metrics_offset].pack("V")
|
|
488
|
+
|
|
489
|
+
# dfExtentTable (4 bytes at offset 121)
|
|
490
|
+
header[121, 4] = [extent_table_offset].pack("V")
|
|
491
|
+
|
|
492
|
+
# dfPairKernTable (4 bytes at offset 129)
|
|
493
|
+
header[129, 4] = [kern_table_offset].pack("V")
|
|
494
|
+
|
|
495
|
+
# dfDriverInfo (4 bytes at offset 137)
|
|
496
|
+
header[137, 4] = [driver_info_offset].pack("V")
|
|
497
|
+
|
|
498
|
+
# Update dfSize (4 bytes at offset 2)
|
|
499
|
+
total_size = face_offset + driver_info_offset + DRIVER_INFO_SIZE
|
|
500
|
+
header[2, 4] = [total_size].pack("V")
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Extract copyright notice
|
|
504
|
+
#
|
|
505
|
+
# @return [String] Copyright notice
|
|
506
|
+
def extract_copyright
|
|
507
|
+
name_table = @font.table(Constants::NAME_TAG)
|
|
508
|
+
return "" unless name_table
|
|
509
|
+
|
|
510
|
+
if name_table.respond_to?(:copyright)
|
|
511
|
+
name_table.copyright(1) || name_table.copyright(3) || ""
|
|
512
|
+
else
|
|
513
|
+
""
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Extract face name from font
|
|
518
|
+
#
|
|
519
|
+
# @return [String] Face name
|
|
520
|
+
def extract_face_name
|
|
521
|
+
name_table = @font.table(Constants::NAME_TAG)
|
|
522
|
+
return "" unless name_table
|
|
523
|
+
|
|
524
|
+
# Try full font name first, then font family, then postscript name
|
|
525
|
+
face_name = if name_table.respond_to?(:full_font_name)
|
|
526
|
+
name_table.full_font_name(1) || name_table.full_font_name(3) || ""
|
|
527
|
+
elsif name_table.respond_to?(:font_family)
|
|
528
|
+
name_table.font_family(1) || name_table.font_family(3) || ""
|
|
529
|
+
elsif name_table.respond_to?(:postscript_name)
|
|
530
|
+
name_table.postscript_name(1) || name_table.postscript_name(3) || ""
|
|
531
|
+
else
|
|
532
|
+
@font.post_script_name || ""
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
face_name.to_s
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Extract weight value (100-900)
|
|
539
|
+
#
|
|
540
|
+
# @return [Integer] Weight value
|
|
541
|
+
def extract_weight_value
|
|
542
|
+
os2 = @font.table(Constants::OS2_TAG)
|
|
543
|
+
return 400 unless os2
|
|
544
|
+
|
|
545
|
+
weight_class = if os2.respond_to?(:us_weight_class)
|
|
546
|
+
os2.us_weight_class
|
|
547
|
+
elsif os2.respond_to?(:weight_class)
|
|
548
|
+
os2.weight_class
|
|
549
|
+
end
|
|
550
|
+
return 400 unless weight_class
|
|
551
|
+
|
|
552
|
+
# Map OS/2 weight class to PFM weight
|
|
553
|
+
case weight_class
|
|
554
|
+
when 100..200 then 100
|
|
555
|
+
when 300 then 300
|
|
556
|
+
when 400 then 400
|
|
557
|
+
when 500 then 500
|
|
558
|
+
when 600 then 600
|
|
559
|
+
when 700 then 700
|
|
560
|
+
when 800 then 800
|
|
561
|
+
when 900 then 900
|
|
562
|
+
else 400
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Calculate pitch and family byte value
|
|
567
|
+
#
|
|
568
|
+
# @return [Integer] Pitch and family byte
|
|
569
|
+
def pitch_and_family_value
|
|
570
|
+
post = @font.table(Constants::POST_TAG)
|
|
571
|
+
is_fixed = post.respond_to?(:is_fixed_pitch) ? post.is_fixed_pitch : false
|
|
572
|
+
|
|
573
|
+
pitch = is_fixed ? FIXED_PITCH : VARIABLE_PITCH
|
|
574
|
+
|
|
575
|
+
# Use Modern as default family
|
|
576
|
+
family = FAMILY_MODERN
|
|
577
|
+
|
|
578
|
+
pitch | family
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Calculate average character width
|
|
582
|
+
#
|
|
583
|
+
# @param char_widths [Hash] Character widths
|
|
584
|
+
# @return [Integer] Average width
|
|
585
|
+
def calculate_average_width(char_widths)
|
|
586
|
+
return 0 if char_widths.empty?
|
|
587
|
+
|
|
588
|
+
widths = char_widths.values
|
|
589
|
+
sum = widths.sum
|
|
590
|
+
sum / widths.length
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# Clamp value to 8-bit unsigned range
|
|
594
|
+
#
|
|
595
|
+
# @param value [Integer] Value to clamp
|
|
596
|
+
# @return [Integer] Clamped value
|
|
597
|
+
def clamp_to_u8(value)
|
|
598
|
+
[[0, value].max, 255].min
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Clamp value to 16-bit unsigned range
|
|
602
|
+
#
|
|
603
|
+
# @param value [Integer] Value to clamp
|
|
604
|
+
# @return [Integer] Clamped value
|
|
605
|
+
def clamp_to_u16(value)
|
|
606
|
+
[[0, value].max, 65535].min
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
end
|