fontisan 0.2.12 → 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.
@@ -2,8 +2,10 @@
2
2
 
3
3
  require_relative "../conversion_options"
4
4
  require_relative "../type1/charstring_converter"
5
+ require_relative "../type1/cff_to_type1_converter"
5
6
  require_relative "../type1/font_dictionary"
6
7
  require_relative "../type1/charstrings"
8
+ require_relative "../type1/seac_expander"
7
9
  require_relative "../type1_font"
8
10
  require_relative "cff_table_builder"
9
11
 
@@ -187,16 +189,29 @@ module Fontisan
187
189
  # Decompose seac composite glyphs to base glyphs
188
190
  #
189
191
  # @param font [Type1Font] Source Type 1 font
190
- def decompose_seac_glyphs(_font)
191
- # Placeholder: Decompose seac composites
192
- # A full implementation would:
193
- # 1. Identify glyphs using seac operator
194
- # 2. Resolve the accent character from encoding
195
- # 3. Extract component outlines recursively
196
- # 4. Merge into single glyph
197
- #
198
- # For now, this is a no-op placeholder
199
- nil
192
+ def decompose_seac_glyphs(font)
193
+ return unless font.charstrings
194
+
195
+ # Create SeacExpander to decompose composite glyphs
196
+ expander = Type1::SeacExpander.new(font.charstrings, font.private_dict)
197
+
198
+ # Get all composite glyphs
199
+ composites = expander.composite_glyphs
200
+ return if composites.empty?
201
+
202
+ # Decompose each composite glyph
203
+ composites.each do |glyph_name|
204
+ decomposed = expander.decompose(glyph_name)
205
+ next if decomposed.nil? || decomposed.empty?
206
+
207
+ # Update the CharString with decomposed version
208
+ # Access the charstrings hash directly and update
209
+ charstrings_hash = font.charstrings.charstrings
210
+ charstrings_hash[glyph_name] = decomposed
211
+
212
+ # Mark as decomposed (no longer a seac composite)
213
+ # The decomposed CharString no longer contains the seac operator
214
+ end
200
215
  end
201
216
 
202
217
  # Detect font format
@@ -303,31 +318,48 @@ module Fontisan
303
318
  # Convert OpenType/CFF font to Type 1
304
319
  #
305
320
  # @param font [OpenTypeFont] Source OpenType font
321
+ # @param options [Hash] Conversion options
306
322
  # @return [Hash<String, String>] Type 1 font data as PFB
307
- def convert_otf_to_type1(font)
323
+ def convert_otf_to_type1(font, _options = {})
308
324
  # Extract CFF table
309
325
  cff_table = font.table("CFF ")
310
326
  raise Fontisan::Error, "CFF table not found" unless cff_table
311
327
 
312
- # Parse CFF table to extract CharStrings
313
- # Note: This is a simplified implementation
314
- # A full implementation would parse CFF INDEX structures
328
+ # Get CharStrings INDEX from CFF
329
+ charstrings_index = cff_table.charstrings_index(0)
330
+ raise Fontisan::Error, "CharStrings INDEX not found" unless charstrings_index
315
331
 
316
- # Convert CFF CharStrings to Type 1 format
332
+ # Get Private DICT for context
333
+ private_dict = cff_table.private_dict(0)
334
+
335
+ # Create CFF to Type 1 converter
336
+ converter = Type1::CffToType1Converter.new(
337
+ nominal_width: private_dict&.nominal_width || 0,
338
+ default_width: private_dict&.default_width || 0
339
+ )
340
+
341
+ # Convert each CFF CharString to Type 1 format
317
342
  type1_charstrings = {}
318
- Type1::CharStringConverter.new
319
-
320
- # Extract glyph outlines from CFF
321
- # For each glyph, convert CFF CharString to Type 1
322
- font.outlines.each_with_index do |outline, index|
323
- glyph_name = font.glyph_name(index) || "glyph#{index}"
324
- # Reverse conversion: CFF → Type 1
325
- # This is a placeholder - full implementation requires CFF parser
326
- type1_charstrings[glyph_name] = convert_cff_to_type1(outline)
343
+ glyph_count = charstrings_index.count
344
+
345
+ glyph_count.times do |glyph_index|
346
+ # Get raw CFF CharString data
347
+ cff_charstring = charstrings_index[glyph_index]
348
+ next unless cff_charstring
349
+
350
+ # Get glyph name
351
+ glyph_name = font.glyph_name(glyph_index) || "glyph#{glyph_index}"
352
+
353
+ # Convert CFF CharString to Type 1 format
354
+ private_dict_hash = build_private_dict_hash(private_dict)
355
+ type1_charstrings[glyph_name] = converter.convert(
356
+ cff_charstring,
357
+ private_dict: private_dict_hash
358
+ )
327
359
  end
328
360
 
329
361
  # Build Type 1 font data
330
- build_type1_data(font, type1_charstrings)
362
+ build_type1_data(font, type1_charstrings, cff_table)
331
363
  end
332
364
 
333
365
  # Convert Type 1 font to TrueType (via OTF)
@@ -456,28 +488,41 @@ module Fontisan
456
488
  super
457
489
  end
458
490
 
459
- # Convert CFF outline to Type 1 CharString
491
+ # Build Type 1 Private dictionary hash from CFF Private dict
460
492
  #
461
- # @param outline [Outline] Glyph outline
462
- # @return [String] Type 1 CharString bytecode
463
- def convert_cff_to_type1(_outline)
464
- # Reverse conversion from CFF to Type 1
465
- # This is a placeholder implementation
466
- # Full implementation requires:
467
- # 1. Parse CFF CharString to commands
468
- # 2. Map CFF operators to Type 1 operators
469
- # 3. Encode numbers in Type 1 format
470
- # 4. Handle hints and subroutines
493
+ # @param private_dict [Tables::Cff::PrivateDict] CFF Private dict
494
+ # @return [Hash] Private dictionary as hash for Type 1
495
+ def build_private_dict_hash(private_dict)
496
+ return {} unless private_dict
471
497
 
472
- String.new(encoding: Encoding::ASCII_8BIT)
498
+ {
499
+ nominal_width: private_dict.nominal_width,
500
+ default_width: private_dict.default_width,
501
+ blue_values: private_dict.blue_values || [],
502
+ other_blues: private_dict.other_blues || [],
503
+ family_blues: private_dict.family_blues || [],
504
+ family_other_blues: private_dict.family_other_blues || [],
505
+ blue_scale: private_dict.blue_scale || 0.039625,
506
+ blue_shift: private_dict.blue_shift || 7,
507
+ blue_fuzz: private_dict.blue_fuzz || 1,
508
+ std_hw: private_dict.std_hw || 0,
509
+ std_vw: private_dict.std_vw || 0,
510
+ stem_snap_h: private_dict.stem_snap_h || [],
511
+ stem_snap_v: private_dict.stem_snap_v || [],
512
+ force_bold: private_dict.force_bold || false,
513
+ language_group: private_dict.language_group || 0,
514
+ expansion_factor: private_dict.expansion_factor || 0.06,
515
+ initial_random_seed: private_dict.initial_random_seed || 0,
516
+ }
473
517
  end
474
518
 
475
519
  # Build Type 1 font data
476
520
  #
477
521
  # @param font [OpenTypeFont] Source OpenType font
478
522
  # @param charstrings [Hash] Type 1 CharStrings
523
+ # @param cff_table [Tables::Cff] CFF table for metadata
479
524
  # @return [Hash] Type 1 font data with :pfb key
480
- def build_type1_data(_font, _charstrings)
525
+ def build_type1_data(_font, _charstrings, _cff_table)
481
526
  # Build PFB format
482
527
  # This is a placeholder implementation
483
528
  # Full implementation requires:
@@ -496,63 +541,693 @@ module Fontisan
496
541
  #
497
542
  # @param font [Type1Font] Source Type 1 font
498
543
  # @return [String] head table binary data
499
- def build_head_table(_font)
500
- # Placeholder: Build actual head table
501
- String.new(encoding: Encoding::ASCII_8BIT)
544
+ def build_head_table(font)
545
+ data = (+"").b
546
+
547
+ # Get font metadata from Type1Font
548
+ font_bbox = font.font_dictionary&.font_bbox || [0, 0, 1000, 1000]
549
+ version_str = font.version || "001.000"
550
+
551
+ # Parse version (e.g., "001.000" => 1.0)
552
+ version_parts = version_str.split(".")
553
+ major = version_parts[0].to_i
554
+ minor = version_parts[1]&.to_i || 0
555
+ version = major + (minor / 1000.0)
556
+
557
+ # Version (Fixed 16.16) - stored as int32
558
+ integer_part = version.to_i
559
+ fractional_part = ((version - integer_part) * 65_536).to_i
560
+ version_raw = (integer_part << 16) | fractional_part
561
+ data << [version_raw].pack("N")
562
+
563
+ # Font Revision (Fixed 16.16) - default to 1.0
564
+ font_revision_raw = 0x00010000
565
+ data << [font_revision_raw].pack("N")
566
+
567
+ # Checksum Adjustment (uint32) - will be calculated later
568
+ data << [0].pack("N")
569
+
570
+ # Magic Number (uint32)
571
+ data << [0x5F0F3CF5].pack("N")
572
+
573
+ # Flags (uint16) - bit 0 indicates y direction (0 = mixed)
574
+ data << [0].pack("n")
575
+
576
+ # Units Per Em (uint16) - Type 1 standard is 1000
577
+ data << [1000].pack("n")
578
+
579
+ # Created (LONGDATETIME) - use current time
580
+ created_seconds = Time.now.to_i + 2_082_844_800
581
+ data << [created_seconds].pack("Q>")
582
+
583
+ # Modified (LONGDATETIME) - use current time
584
+ modified_seconds = Time.now.to_i + 2_082_844_800
585
+ data << [modified_seconds].pack("Q>")
586
+
587
+ # Bounding box (int16 each)
588
+ data << [font_bbox[0]].pack("s>") # x_min
589
+ data << [font_bbox[1]].pack("s>") # y_min
590
+ data << [font_bbox[2]].pack("s>") # x_max
591
+ data << [font_bbox[3]].pack("s>") # y_max
592
+
593
+ # Mac Style (uint16) - no style bits set
594
+ data << [0].pack("n")
595
+
596
+ # Lowest Rec PPEM (uint16) - readable size
597
+ data << [8].pack("n")
598
+
599
+ # Font Direction Hint (int16)
600
+ # 2 = Left to right, mixed glyphs
601
+ data << [2].pack("s>")
602
+
603
+ # Index To Loc Format (int16)
604
+ # 0 = short offsets (for CFF fonts we use this)
605
+ data << [0].pack("s>")
606
+
607
+ # Glyph Data Format (int16)
608
+ data << [0].pack("s>")
609
+
610
+ data
502
611
  end
503
612
 
504
613
  # Build hhea table from Type 1 font
505
614
  #
506
615
  # @param font [Type1Font] Source Type 1 font
507
616
  # @return [String] hhea table binary data
508
- def build_hhea_table(_font)
509
- # Placeholder: Build actual hhea table
510
- String.new(encoding: Encoding::ASCII_8BIT)
617
+ def build_hhea_table(font)
618
+ data = (+"").b
619
+
620
+ # Get font metrics from Type1Font
621
+ font_bbox = font.font_dictionary&.font_bbox || [0, 0, 1000, 1000]
622
+ blue_values = font.private_dict&.blue_values || []
623
+
624
+ # Version (Fixed 16.16) - 0x00010000 (1.0)
625
+ data << [0x00010000].pack("N")
626
+
627
+ # Ascent (int16) - Distance from baseline to highest ascender
628
+ # Use BlueValues[2] or [3] if available, otherwise font_bbox[3]
629
+ if blue_values.length >= 4
630
+ ascent = blue_values[3] # Top zone top
631
+ elsif blue_values.length >= 3
632
+ ascent = blue_values[2] # Top zone bottom
633
+ else
634
+ ascent = font_bbox[3] # y_max
635
+ end
636
+ data << [ascent].pack("s>")
637
+
638
+ # Descent (int16) - Distance from baseline to lowest descender (negative)
639
+ # Use BlueValues[0] or [1] if available, otherwise font_bbox[1]
640
+ if blue_values.length >= 2
641
+ descent = blue_values[0] # Bottom zone bottom (negative)
642
+ elsif blue_values.length >= 1
643
+ descent = blue_values[0]
644
+ else
645
+ descent = font_bbox[1] # y_min (should be negative)
646
+ end
647
+ data << [descent].pack("s>")
648
+
649
+ # Line Gap (int16) - Additional space between lines
650
+ # Use typical value of 0 for Type 1 fonts
651
+ data << [0].pack("s>")
652
+
653
+ # Advance Width Max (uint16)
654
+ # Type 1 standard is typically 1000, use font_bbox width + padding
655
+ advance_max = (font_bbox[2] - font_bbox[0]) + 100
656
+ data << [advance_max].pack("n")
657
+
658
+ # Min Left Side Bearing (int16)
659
+ # Use font_bbox[0] (x_min) as reasonable default
660
+ data << [font_bbox[0]].pack("s>")
661
+
662
+ # Min Right Side Bearing (int16)
663
+ # Estimate as 0 (will be updated if actual metrics available)
664
+ data << [0].pack("s>")
665
+
666
+ # x Max Extent (int16) - Max(lsb + xMax)
667
+ # Use font_bbox[2] (x_max) as reasonable default
668
+ data << [font_bbox[2]].pack("s>")
669
+
670
+ # Caret Slope Rise (int16)
671
+ # 1 for upright fonts (not italic)
672
+ data << [1].pack("s>")
673
+
674
+ # Caret Slope Run (int16)
675
+ # 0 for upright fonts
676
+ data << [0].pack("s>")
677
+
678
+ # Caret Offset (int16)
679
+ # Set to 0 for standard fonts
680
+ data << [0].pack("s>")
681
+
682
+ # Reserved (int64) - 8 bytes of zeros
683
+ data << [0, 0].pack("Q>")
684
+
685
+ # Metric Data Format (int16)
686
+ # 0 for current format
687
+ data << [0].pack("s>")
688
+
689
+ # Number of HMetrics (uint16)
690
+ # Number of glyphs with explicit metrics (typically all glyphs)
691
+ num_glyphs = font.charstrings&.count || 1
692
+ data << [[num_glyphs, 1].max].pack("n")
693
+
694
+ data
511
695
  end
512
696
 
513
697
  # Build maxp table from Type 1 font
514
698
  #
515
699
  # @param font [Type1Font] Source Type 1 font
516
700
  # @return [String] maxp table binary data
517
- def build_maxp_table(_font)
518
- # Placeholder: Build actual maxp table
519
- String.new(encoding: Encoding::ASCII_8BIT)
701
+ def build_maxp_table(font)
702
+ data = (+"").b
703
+
704
+ # Get number of glyphs from Type1Font
705
+ num_glyphs = font.charstrings&.count || 1
706
+
707
+ # Version (Fixed 16.16)
708
+ # For CFF fonts (OTF output), use version 0.5 (0x00005000)
709
+ # For TrueType fonts (TTF output), would use version 1.0 (0x00010000)
710
+ # Type 1 fonts convert to CFF-based OTF, so use version 0.5
711
+ data << [0x00005000].pack("N")
712
+
713
+ # Number of Glyphs (uint16)
714
+ # Must be >= 1 (at minimum, .notdef must be present)
715
+ data << [[num_glyphs, 1].max].pack("n")
716
+
717
+ data
520
718
  end
521
719
 
522
720
  # Build name table from Type 1 font
523
721
  #
524
722
  # @param font [Type1Font] Source Type 1 font
525
723
  # @return [String] name table binary data
526
- def build_name_table(_font)
527
- # Placeholder: Build actual name table
528
- String.new(encoding: Encoding::ASCII_8BIT)
724
+ def build_name_table(font)
725
+ # Get font metadata from Type1Font
726
+ font_dict = font.font_dictionary
727
+ font_info = font_dict&.font_info
728
+
729
+ # Extract font names with fallbacks
730
+ font_name = font.font_name || font_dict&.font_name || "Unnamed"
731
+ family_name = if font_info&.respond_to?(:family_name)
732
+ font_info.family_name || font_dict&.family_name || font_name
733
+ else
734
+ font_dict&.family_name || font_name
735
+ end
736
+ full_name = if font_info&.respond_to?(:full_name)
737
+ font_info.full_name || font_dict&.full_name || family_name
738
+ else
739
+ font_dict&.full_name || family_name
740
+ end
741
+ version = if font_info&.respond_to?(:version)
742
+ font_info.version || font.version || "001.000"
743
+ else
744
+ font.version || "001.000"
745
+ end
746
+ copyright = if font_info&.respond_to?(:copyright)
747
+ font_info.copyright || font_dict&.raw_data&.dig(:copyright) || ""
748
+ else
749
+ font_dict&.raw_data&.dig(:copyright) || ""
750
+ end
751
+ postscript_name = font_name
752
+ weight = if font_info&.respond_to?(:weight)
753
+ font_info.weight
754
+ else
755
+ "Regular"
756
+ end
757
+ notice = if font_info&.respond_to?(:notice)
758
+ font_info.notice
759
+ else
760
+ ""
761
+ end
762
+
763
+ # Build name records (Windows Unicode, English US)
764
+ # Platform ID 3 (Windows), Encoding ID 1 (Unicode BMP), Language ID 0x0409 (US English)
765
+ name_records = [
766
+ # Copyright (name ID 0)
767
+ { name_id: 0, string: copyright },
768
+ # Family Name (name ID 1)
769
+ { name_id: 1, string: family_name },
770
+ # Subfamily Name (name ID 2) - derive from weight or default to Regular
771
+ { name_id: 2, string: weight || "Regular" },
772
+ # Unique ID (name ID 3) - format: version;copyright;postscript_name
773
+ { name_id: 3, string: "#{version};#{copyright};#{postscript_name}" },
774
+ # Full Name (name ID 4)
775
+ { name_id: 4, string: full_name },
776
+ # Version (name ID 5)
777
+ { name_id: 5, string: version },
778
+ # PostScript Name (name ID 6)
779
+ { name_id: 6, string: postscript_name },
780
+ # Trademark (name ID 7) - use notice if available
781
+ { name_id: 7, string: notice || "" },
782
+ ]
783
+
784
+ # Filter out empty strings and build string storage
785
+ name_records = name_records.select { |r| !r[:string].nil? && !r[:string].empty? }
786
+
787
+ # Build string storage (UTF-16BE encoded for Windows platform)
788
+ string_storage = (+"").b
789
+ name_records.each do |record|
790
+ encoded_string = record[:string].encode("UTF-16BE").force_encoding("ASCII-8BIT")
791
+ record[:encoded] = encoded_string
792
+ record[:offset] = string_storage.bytesize
793
+ string_storage << encoded_string
794
+ end
795
+
796
+ # Build name table
797
+ data = (+"").b
798
+
799
+ # Format selector (uint16) - 0 for basic
800
+ data << [0].pack("n")
801
+
802
+ # Count (uint16) - number of name records
803
+ data << [name_records.size].pack("n")
804
+
805
+ # String offset (uint16) - offset to string storage from start of table
806
+ # Header is 6 bytes, each name record is 12 bytes
807
+ string_data_offset = 6 + (name_records.size * 12)
808
+ data << [string_data_offset].pack("n")
809
+
810
+ # Write name records
811
+ platform_id = 3 # Windows
812
+ encoding_id = 1 # Unicode BMP
813
+ language_id = 0x0409 # US English
814
+
815
+ name_records.each do |record|
816
+ data << [platform_id].pack("n") # platform ID
817
+ data << [encoding_id].pack("n") # encoding ID
818
+ data << [language_id].pack("n") # language ID
819
+ data << [record[:name_id]].pack("n") # name ID
820
+ data << [record[:encoded].bytesize].pack("n") # string length
821
+ data << [record[:offset]].pack("n") # string offset
822
+ end
823
+
824
+ # Write string storage
825
+ data << string_storage
826
+
827
+ data
529
828
  end
530
829
 
531
830
  # Build OS/2 table from Type 1 font
532
831
  #
533
832
  # @param font [Type1Font] Source Type 1 font
534
833
  # @return [String] OS/2 table binary data
535
- def build_os2_table(_font)
536
- # Placeholder: Build actual OS/2 table
537
- String.new(encoding: Encoding::ASCII_8BIT)
834
+ def build_os2_table(font)
835
+ data = (+"").b
836
+
837
+ # Get font metadata from Type1Font
838
+ font_bbox = font.font_dictionary&.font_bbox || [0, 0, 1000, 1000]
839
+ blue_values = font.private_dict&.blue_values || []
840
+ font_info = font.font_dictionary&.font_info || {}
841
+ weight = font_info.weight || "Medium"
842
+
843
+ # Determine weight class (100-900)
844
+ # Order matters - more specific patterns must come first
845
+ weight_class = case weight.to_s.downcase
846
+ when /thin/ then 100
847
+ when /extralight/ then 200
848
+ when /light/ then 300
849
+ when /regular|normal/ then 400
850
+ when /medium/ then 400
851
+ when /semibold|semib/ then 600
852
+ when /extrabold/ then 800
853
+ when /bold/ then 700
854
+ when /black|heavy/ then 900
855
+ else 400
856
+ end
857
+
858
+ # Version (uint16) - Use version 4 for modern fonts
859
+ data << [4].pack("n")
860
+
861
+ # xAvgCharWidth (int16) - Average character width
862
+ # Use font width estimate
863
+ avg_width = ((font_bbox[2] - font_bbox[0]) * 0.5).to_i
864
+ data << [avg_width].pack("s>")
865
+
866
+ # usWeightClass (uint16)
867
+ data << [weight_class].pack("n")
868
+
869
+ # usWidthClass (uint16) - 1 = Ultra-condensed to 9 = Ultra-expanded
870
+ # Default to 5 (Medium)
871
+ data << [5].pack("n")
872
+
873
+ # fsType (uint16) - Embedding permissions
874
+ # 0 = Installable embedding, 8 = Restricted (use 0 as default)
875
+ data << [0].pack("n")
876
+
877
+ # ySubscriptXSize (int16)
878
+ data << [650].pack("s>")
879
+
880
+ # ySubscriptYSize (int16)
881
+ data << [600].pack("s>")
882
+
883
+ # ySubscriptXOffset (int16)
884
+ data << [0].pack("s>")
885
+
886
+ # ySubscriptYOffset (int16)
887
+ data << [75].pack("s>")
888
+
889
+ # ySuperscriptXSize (int16)
890
+ data << [650].pack("s>")
891
+
892
+ # ySuperscriptYSize (int16)
893
+ data << [600].pack("s>")
894
+
895
+ # ySuperscriptXOffset (int16)
896
+ data << [0].pack("s>")
897
+
898
+ # ySuperscriptYOffset (int16)
899
+ data << [350].pack("s>")
900
+
901
+ # yStrikeoutSize (int16)
902
+ data << [50].pack("s>")
903
+
904
+ # yStrikeoutPosition (int16)
905
+ data << [300].pack("s>")
906
+
907
+ # sFamilyClass (int16) - Family class and subclass
908
+ # 0 = No classification
909
+ data << [0].pack("s>")
910
+
911
+ # PANOSE (10 bytes) - Use default Latin Text family
912
+ # Family: 2 (Text and Display), Serif Style: 11 (Normal Sans)
913
+ panose = [
914
+ 2, # Family kind: Latin Text
915
+ 11, # Serif style: Normal Sans
916
+ 5, # Weight: Medium
917
+ 5, # Proportion: Modern
918
+ 2, # Contrast: Medium Low
919
+ 5, # Stroke variation: Medium
920
+ 5, # Arm style: Straight arms/serifs
921
+ 5, # Letter form: Normal
922
+ 4, # Midline: Standard
923
+ 3, # X-height: Medium
924
+ ]
925
+ data << panose.pack("C*")
926
+
927
+ # Unicode ranges (4 x uint32) - Basic Latin + Latin-1
928
+ # Bits 0-31: Basic Latin, Latin-1, Latin Extended-A/B, etc.
929
+ data << [0x00000001].pack("N") # Basic Latin (0-7F)
930
+ data << [0x00000000].pack("N")
931
+ data << [0x00000000].pack("N")
932
+ data << [0x00000000].pack("N")
933
+
934
+ # achVendID (4 bytes) - Vendor ID
935
+ data << "UKWN" # Unknown
936
+
937
+ # fsSelection (uint16) - Font selection flags
938
+ # Bit 6 (0x40) = Regular weight if 400-500
939
+ fs_selection = if weight_class >= 400 && weight_class <= 500
940
+ 0x40 # REGULAR
941
+ elsif weight_class >= 700
942
+ 0x20 # BOLD
943
+ else
944
+ 0
945
+ end
946
+ data << [fs_selection].pack("n")
947
+
948
+ # usFirstCharIndex (uint16) - First Unicode character
949
+ data << [32].pack("n") # Space
950
+
951
+ # usLastCharIndex (uint16) - Last Unicode character
952
+ data << [0xFFFD].pack("n") # Replacement character
953
+
954
+ # sTypoAscender (int16) - Use BlueValues or font bbox
955
+ if blue_values.length >= 4
956
+ typo_ascender = blue_values[3]
957
+ else
958
+ typo_ascender = font_bbox[3]
959
+ end
960
+ data << [typo_ascender].pack("s>")
961
+
962
+ # sTypoDescender (int16) - Use BlueValues or font bbox (negative)
963
+ if blue_values.length >= 2
964
+ typo_descender = blue_values[0]
965
+ else
966
+ typo_descender = font_bbox[1]
967
+ end
968
+ data << [typo_descender].pack("s>")
969
+
970
+ # sTypoLineGap (int16)
971
+ data << [0].pack("s>")
972
+
973
+ # usWinAscent (uint16)
974
+ data << [[font_bbox[3], 1000].max].pack("n")
975
+
976
+ # usWinDescent (uint16)
977
+ data << [[-font_bbox[1], 200].max].pack("n")
978
+
979
+ # ulCodePageRange1 (uint32) - Latin 1
980
+ data << [0x00000001].pack("N")
981
+
982
+ # ulCodePageRange2 (uint32)
983
+ data << [0x00000000].pack("N")
984
+
985
+ # sxHeight (int16) - x-height, approximate as 500 for 1000 UPM
986
+ data << [500].pack("s>")
987
+
988
+ # sCapHeight (int16) - Cap height, approximate as 700 for 1000 UPM
989
+ data << [700].pack("s>")
990
+
991
+ # usDefaultChar (uint16)
992
+ data << [0].pack("n")
993
+
994
+ # usBreakChar (uint16) - Space
995
+ data << [32].pack("n")
996
+
997
+ # usMaxContext (uint16)
998
+ data << [0].pack("n")
999
+
1000
+ data
538
1001
  end
539
1002
 
540
1003
  # Build post table from Type 1 font
541
1004
  #
542
1005
  # @param font [Type1Font] Source Type 1 font
543
1006
  # @return [String] post table binary data
544
- def build_post_table(_font)
545
- # Placeholder: Build actual post table
546
- String.new(encoding: Encoding::ASCII_8BIT)
1007
+ def build_post_table(font)
1008
+ data = (+"").b
1009
+
1010
+ # Get font metadata from Type1Font
1011
+ font_info = font.font_dictionary&.font_info || {}
1012
+
1013
+ # Version (Fixed 16.16) - Use version 3.0 for CFF fonts (no glyph names)
1014
+ # Version 2.0 would include glyph names, but for OTF output version 3.0 is fine
1015
+ # since CFF table contains the glyph names
1016
+ data << [0x00030000].pack("N") # Version 3.0
1017
+
1018
+ # Italic Angle (Fixed 16.16)
1019
+ # Get from FontInfo if available, otherwise default to 0
1020
+ italic_angle = font_info.italic_angle || 0
1021
+ angle_raw = (italic_angle * 65_536).to_i
1022
+ data << [angle_raw].pack("N")
1023
+
1024
+ # Underline Position (int16)
1025
+ underline_position = font_info.underline_position || -100
1026
+ data << [underline_position].pack("s>")
1027
+
1028
+ # Underline Thickness (int16)
1029
+ underline_thickness = font_info.underline_thickness || 50
1030
+ data << [underline_thickness].pack("s>")
1031
+
1032
+ # Fixed Pitch (uint32) - Boolean for monospace
1033
+ is_fixed_pitch = (font_info.is_fixed_pitch || false) ? 1 : 0
1034
+ data << [is_fixed_pitch].pack("N")
1035
+
1036
+ # Min/Max Memory for Type 42 (uint32 each) - Not used for CFF, set to 0
1037
+ data << [0].pack("N") # min_mem_type42
1038
+ data << [0].pack("N") # max_mem_type42
1039
+
1040
+ # Min/Max Memory for Type 1 (uint32 each) - Not used for CFF, set to 0
1041
+ data << [0].pack("N") # min_mem_type1
1042
+ data << [0].pack("N") # max_mem_type1
1043
+
1044
+ data
547
1045
  end
548
1046
 
549
1047
  # Build cmap table from Type 1 font
550
1048
  #
551
1049
  # @param font [Type1Font] Source Type 1 font
552
1050
  # @return [String] cmap table binary data
553
- def build_cmap_table(_font)
554
- # Placeholder: Build actual cmap table
555
- String.new(encoding: Encoding::ASCII_8BIT)
1051
+ def build_cmap_table(font)
1052
+ require_relative "../type1/agl"
1053
+
1054
+ data = (+"").b
1055
+
1056
+ # Get encoding from Type1Font
1057
+ encoding = font.charstrings&.encoding || {}
1058
+ glyph_names = font.charstrings&.glyph_names || encoding.keys
1059
+
1060
+ # Build Unicode mapping from glyph names using AGL
1061
+ unicode_to_glyph = {}
1062
+ glyph_index = 0
1063
+
1064
+ glyph_names.each do |glyph_name|
1065
+ # Get Unicode code point from AGL
1066
+ unicode = Type1::AGL.unicode_for_glyph_name(glyph_name)
1067
+
1068
+ # If no Unicode mapping, try to derive from encoding position
1069
+ if unicode.nil?
1070
+ # For standard encoding, try to map from position
1071
+ # This is a simplified approach - real implementation would be more robust
1072
+ unicode = glyph_index if glyph_index < 128
1073
+ end
1074
+
1075
+ # Map Unicode to glyph index
1076
+ if unicode && unicode <= 0xFFFF
1077
+ unicode_to_glyph[unicode] ||= glyph_index
1078
+ end
1079
+
1080
+ glyph_index += 1
1081
+ end
1082
+
1083
+ # Ensure at least .notdef (glyph 0) maps to something
1084
+ unicode_to_glyph[0x0000] ||= 0
1085
+
1086
+ # Build Format 4 subtable (Segment mapping to delta values)
1087
+ # This is the most common format for BMP Unicode fonts
1088
+ subtable_data = build_cmap_format_4(unicode_to_glyph)
1089
+
1090
+ # Calculate offsets
1091
+ encoding_records_offset = 4 # After version (2) + num_tables (2)
1092
+ subtable_offset = encoding_records_offset + 8 # After one encoding record (8 bytes)
1093
+
1094
+ # Build cmap table header
1095
+ # Version (uint16)
1096
+ data << [0].pack("n")
1097
+
1098
+ # Number of encoding records (uint16)
1099
+ data << [1].pack("n") # One encoding record
1100
+
1101
+ # Encoding record: Platform ID (uint16), Encoding ID (uint16), Subtable offset (uint32)
1102
+ # Platform 3 (Windows), Encoding 1 (Unicode BMP)
1103
+ data << [3].pack("n") # Platform ID: Windows
1104
+ data << [1].pack("n") # Encoding ID: Unicode BMP
1105
+ data << [subtable_offset].pack("N") # Subtable offset
1106
+
1107
+ # Append subtable data
1108
+ data << subtable_data
1109
+
1110
+ data
1111
+ end
1112
+
1113
+ # Build cmap format 4 subtable
1114
+ #
1115
+ # @param unicode_to_glyph [Hash<Integer, Integer>] Unicode to glyph index mapping
1116
+ # @return [String] Format 4 subtable binary data
1117
+ def build_cmap_format_4(unicode_to_glyph)
1118
+ data = (+"").b
1119
+
1120
+ # Get sorted Unicode values
1121
+ unicode_values = unicode_to_glyph.keys.sort
1122
+ return data if unicode_values.empty?
1123
+
1124
+ # For simplicity, create segments for continuous ranges
1125
+ # A more sophisticated implementation would optimize this
1126
+ segments = []
1127
+ current_segment = nil
1128
+
1129
+ unicode_values.each do |unicode|
1130
+ glyph_id = unicode_to_glyph[unicode]
1131
+
1132
+ if current_segment.nil?
1133
+ current_segment = {
1134
+ start: unicode,
1135
+ end: unicode,
1136
+ start_glyph: glyph_id,
1137
+ glyphs: [glyph_id],
1138
+ }
1139
+ elsif unicode == current_segment[:end] + 1 && glyph_id == current_segment[:glyphs].last + 1
1140
+ # Continue current segment (sequential)
1141
+ current_segment[:end] = unicode
1142
+ current_segment[:glyphs] << glyph_id
1143
+ else
1144
+ # Start new segment
1145
+ segments << current_segment
1146
+ current_segment = {
1147
+ start: unicode,
1148
+ end: unicode,
1149
+ start_glyph: glyph_id,
1150
+ glyphs: [glyph_id],
1151
+ }
1152
+ end
1153
+ end
1154
+
1155
+ segments << current_segment if current_segment
1156
+
1157
+ # Add end segment marker (0xFFFF)
1158
+ segments << { start: 0xFFFF, end: 0xFFFF, start_glyph: 0, glyphs: [0] }
1159
+
1160
+ # Calculate segment count and related values
1161
+ seg_count = segments.length
1162
+ seg_count_x2 = seg_count * 2
1163
+ search_range = 2 ** (Math.log2(seg_count).to_i) * 2
1164
+ entry_selector = Math.log2(search_range / 2).to_i
1165
+ range_shift = (seg_count - search_range / 2) * 2
1166
+
1167
+ # Build format 4 subtable header (14 bytes)
1168
+ data << [4].pack("n") # Format
1169
+ data << [calculate_cmap4_length(segments)].pack("n") # Length (placeholder)
1170
+ data << [0].pack("n") # Language (0 = independent)
1171
+ data << [seg_count_x2].pack("n") # segCountX2
1172
+ data << [search_range].pack("n") # searchRange
1173
+ data << [entry_selector].pack("n") # entrySelector
1174
+ data << [range_shift].pack("n") # rangeShift
1175
+
1176
+ # Build segment arrays
1177
+ end_codes = []
1178
+ start_codes = []
1179
+ id_deltas = []
1180
+ id_range_offsets = []
1181
+ glyph_id_array = []
1182
+
1183
+ segments.each do |seg|
1184
+ end_codes << seg[:end]
1185
+ start_codes << seg[:start]
1186
+
1187
+ # For sequential glyphs, use delta
1188
+ if seg[:start] == 0xFFFF
1189
+ # End segment marker
1190
+ id_deltas << 1
1191
+ id_range_offsets << 0
1192
+ elsif seg[:end] - seg[:start] == seg[:glyphs].length - 1
1193
+ # Sequential: use delta
1194
+ id_deltas << (seg[:start_glyph] - seg[:start])
1195
+ id_range_offsets << 0
1196
+ else
1197
+ # Non-sequential: use glyph ID array
1198
+ id_deltas << 0
1199
+ id_range_offsets << (glyph_id_array.length * 2 + 2)
1200
+ glyph_id_array.concat(seg[:glyphs])
1201
+ end
1202
+ end
1203
+
1204
+ # Write arrays (padded to even length)
1205
+ end_codes.each { |code| data << [code].pack("n") }
1206
+ data << [0].pack("n") # Reserved padding
1207
+ start_codes.each { |code| data << [code].pack("n") }
1208
+ id_deltas.each { |delta| data << [delta].pack("s>") } # Signed
1209
+ id_range_offsets.each { |offset| data << [offset].pack("n") }
1210
+ glyph_id_array.each { |gid| data << [gid].pack("n") }
1211
+
1212
+ # Update length in header
1213
+ length = data.bytesize
1214
+ data[2..3] = [length].pack("n")
1215
+
1216
+ data
1217
+ end
1218
+
1219
+ # Calculate length for format 4 subtable
1220
+ #
1221
+ # @param segments [Array<Hash>] Segment definitions
1222
+ # @return [Integer] Estimated length
1223
+ def calculate_cmap4_length(segments)
1224
+ # Header: 14 bytes
1225
+ # Arrays: seg_count * 2 bytes each
1226
+ # Glyph ID array: variable
1227
+ seg_count = segments.length
1228
+
1229
+ # Rough estimate (actual calculation done during construction)
1230
+ 14 + (seg_count * 8) + (seg_count * 2) + 100 # 100 for glyph ID array estimate
556
1231
  end
557
1232
  end
558
1233
  end