fontisan 0.2.11 → 0.2.12

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +214 -51
  3. data/README.adoc +160 -0
  4. data/lib/fontisan/cli.rb +177 -6
  5. data/lib/fontisan/commands/convert_command.rb +32 -1
  6. data/lib/fontisan/config/conversion_matrix.yml +132 -4
  7. data/lib/fontisan/constants.rb +12 -0
  8. data/lib/fontisan/conversion_options.rb +378 -0
  9. data/lib/fontisan/converters/collection_converter.rb +45 -10
  10. data/lib/fontisan/converters/format_converter.rb +2 -0
  11. data/lib/fontisan/converters/outline_converter.rb +78 -4
  12. data/lib/fontisan/converters/type1_converter.rb +559 -0
  13. data/lib/fontisan/font_loader.rb +46 -3
  14. data/lib/fontisan/type1/afm_generator.rb +436 -0
  15. data/lib/fontisan/type1/afm_parser.rb +298 -0
  16. data/lib/fontisan/type1/agl.rb +456 -0
  17. data/lib/fontisan/type1/charstring_converter.rb +240 -0
  18. data/lib/fontisan/type1/charstrings.rb +408 -0
  19. data/lib/fontisan/type1/conversion_options.rb +243 -0
  20. data/lib/fontisan/type1/decryptor.rb +183 -0
  21. data/lib/fontisan/type1/encodings.rb +697 -0
  22. data/lib/fontisan/type1/font_dictionary.rb +514 -0
  23. data/lib/fontisan/type1/generator.rb +220 -0
  24. data/lib/fontisan/type1/inf_generator.rb +332 -0
  25. data/lib/fontisan/type1/pfa_generator.rb +343 -0
  26. data/lib/fontisan/type1/pfa_parser.rb +158 -0
  27. data/lib/fontisan/type1/pfb_generator.rb +291 -0
  28. data/lib/fontisan/type1/pfb_parser.rb +166 -0
  29. data/lib/fontisan/type1/pfm_generator.rb +610 -0
  30. data/lib/fontisan/type1/pfm_parser.rb +433 -0
  31. data/lib/fontisan/type1/private_dict.rb +285 -0
  32. data/lib/fontisan/type1/ttf_to_type1_converter.rb +327 -0
  33. data/lib/fontisan/type1/upm_scaler.rb +118 -0
  34. data/lib/fontisan/type1.rb +73 -0
  35. data/lib/fontisan/type1_font.rb +331 -0
  36. data/lib/fontisan/version.rb +1 -1
  37. data/lib/fontisan.rb +2 -0
  38. metadata +26 -2
@@ -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