fontisan 0.4.22 → 0.4.24

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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +240 -24
  3. data/Gemfile +6 -0
  4. data/Rakefile +9 -6
  5. data/benchmark/compile_benchmark.rb +13 -5
  6. data/docs/.vitepress/config.ts +1 -0
  7. data/docs/STITCHER_GUIDE.adoc +47 -3
  8. data/docs/api/layer.md +89 -0
  9. data/docs/cli/subset.md +25 -6
  10. data/docs/guide/cli/subset.md +31 -0
  11. data/docs/guide/color-fonts/bitmaps.md +17 -0
  12. data/lib/fontisan/cli.rb +2 -1
  13. data/lib/fontisan/collection/writer.rb +2 -1
  14. data/lib/fontisan/commands/validate_collection_command.rb +7 -1
  15. data/lib/fontisan/commands.rb +2 -1
  16. data/lib/fontisan/config/subset_profiles.yml +2 -0
  17. data/lib/fontisan/converters/collection_converter.rb +6 -1
  18. data/lib/fontisan/converters/svg_generator.rb +3 -1
  19. data/lib/fontisan/stitcher/cbdt_propagator.rb +44 -22
  20. data/lib/fontisan/stitcher/collection_result.rb +2 -1
  21. data/lib/fontisan/stitcher/glyph_cloner.rb +41 -0
  22. data/lib/fontisan/stitcher/glyph_copier.rb +5 -34
  23. data/lib/fontisan/stitcher/partition_strategy/by_block.rb +3 -1
  24. data/lib/fontisan/stitcher/partition_strategy/by_script.rb +6 -2
  25. data/lib/fontisan/stitcher/source.rb +2 -1
  26. data/lib/fontisan/stitcher/unique_glyph_name.rb +46 -0
  27. data/lib/fontisan/stitcher.rb +4 -1
  28. data/lib/fontisan/subset/shared_state.rb +42 -0
  29. data/lib/fontisan/subset/subset_context.rb +20 -0
  30. data/lib/fontisan/subset/table_strategy/cbdt.rb +30 -0
  31. data/lib/fontisan/subset/table_strategy/cblc.rb +27 -0
  32. data/lib/fontisan/subset/table_strategy/cmap.rb +146 -0
  33. data/lib/fontisan/subset/table_strategy/color_bitmap_placement.rb +20 -0
  34. data/lib/fontisan/subset/table_strategy/color_bitmap_strike_plan.rb +45 -0
  35. data/lib/fontisan/subset/table_strategy/color_bitmap_subset_plan.rb +62 -0
  36. data/lib/fontisan/subset/table_strategy/color_bitmap_subsetter.rb +182 -0
  37. data/lib/fontisan/subset/table_strategy/color_bitmap_subtable_plan.rb +59 -0
  38. data/lib/fontisan/subset/table_strategy/glyf.rb +29 -0
  39. data/lib/fontisan/subset/table_strategy/glyf_loca_builder.rb +146 -0
  40. data/lib/fontisan/subset/table_strategy/head.rb +42 -0
  41. data/lib/fontisan/subset/table_strategy/hhea.rb +67 -0
  42. data/lib/fontisan/subset/table_strategy/hmtx.rb +52 -0
  43. data/lib/fontisan/subset/table_strategy/loca.rb +41 -0
  44. data/lib/fontisan/subset/table_strategy/maxp.rb +23 -0
  45. data/lib/fontisan/subset/table_strategy/name.rb +19 -0
  46. data/lib/fontisan/subset/table_strategy/os2.rb +21 -0
  47. data/lib/fontisan/subset/table_strategy/pass_through.rb +20 -0
  48. data/lib/fontisan/subset/table_strategy/post.rb +37 -0
  49. data/lib/fontisan/subset/table_strategy.rb +75 -0
  50. data/lib/fontisan/subset/table_subsetter.rb +49 -616
  51. data/lib/fontisan/subset.rb +3 -0
  52. data/lib/fontisan/svg/standalone_glyph.rb +4 -1
  53. data/lib/fontisan/svg_to_glyf/assembler.rb +6 -3
  54. data/lib/fontisan/svg_to_glyf/geometry/transform_parser.rb +11 -4
  55. data/lib/fontisan/svg_to_glyf/geometry.rb +4 -2
  56. data/lib/fontisan/tables/cbdt.rb +66 -121
  57. data/lib/fontisan/tables/cblc.rb +110 -231
  58. data/lib/fontisan/tables/cblc_big_glyph_metrics.rb +28 -0
  59. data/lib/fontisan/tables/cblc_bitmap_size.rb +67 -0
  60. data/lib/fontisan/tables/cblc_glyph_bitmap_location.rb +28 -0
  61. data/lib/fontisan/tables/cblc_index_subtable.rb +104 -0
  62. data/lib/fontisan/tables/cblc_index_subtable_array_entry.rb +20 -0
  63. data/lib/fontisan/tables/cblc_index_subtable_format_parser.rb +150 -0
  64. data/lib/fontisan/tables/cblc_index_subtable_header.rb +18 -0
  65. data/lib/fontisan/tables/cblc_sbit_line_metrics.rb +28 -0
  66. data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +11 -5
  67. data/lib/fontisan/tables/cff.rb +2 -1
  68. data/lib/fontisan/tables/cff2/index_builder.rb +2 -1
  69. data/lib/fontisan/tables.rb +12 -0
  70. data/lib/fontisan/ufo/cli.rb +4 -1
  71. data/lib/fontisan/ufo/compile/cbdt_cblc.rb +2 -1
  72. data/lib/fontisan/ufo/compile/cff.rb +6 -2
  73. data/lib/fontisan/ufo/compile/cmap.rb +11 -5
  74. data/lib/fontisan/ufo/compile/cpal.rb +2 -1
  75. data/lib/fontisan/ufo/compile/feature_writers/gdef.rb +3 -1
  76. data/lib/fontisan/ufo/compile/feature_writers/mark_family_base.rb +3 -1
  77. data/lib/fontisan/ufo/compile/feature_writers.rb +2 -1
  78. data/lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb +4 -2
  79. data/lib/fontisan/ufo/compile/filters/remove_overlaps.rb +2 -1
  80. data/lib/fontisan/ufo/compile/fvar.rb +7 -3
  81. data/lib/fontisan/ufo/compile/glyf_loca.rb +4 -2
  82. data/lib/fontisan/ufo/compile/gvar.rb +5 -2
  83. data/lib/fontisan/ufo/compile/head.rb +2 -1
  84. data/lib/fontisan/ufo/compile/item_variation_store.rb +2 -1
  85. data/lib/fontisan/ufo/compile/name.rb +3 -1
  86. data/lib/fontisan/ufo/compile/sbix.rb +2 -1
  87. data/lib/fontisan/ufo/compile/stat.rb +6 -3
  88. data/lib/fontisan/ufo/convert/from_bin_data.rb +6 -2
  89. data/lib/fontisan/ufo/convert/to_dfont.rb +6 -2
  90. data/lib/fontisan/ufo/convert/to_otc.rb +4 -1
  91. data/lib/fontisan/ufo/convert/to_postscript.rb +8 -3
  92. data/lib/fontisan/ufo/convert/to_woff.rb +8 -3
  93. data/lib/fontisan/ufo/convert/to_woff2.rb +8 -3
  94. data/lib/fontisan/ufo/convert.rb +4 -1
  95. data/lib/fontisan/ufo/glyph_exists_error.rb +23 -0
  96. data/lib/fontisan/ufo/info.rb +4 -2
  97. data/lib/fontisan/ufo/layer.rb +42 -2
  98. data/lib/fontisan/ufo/plist.rb +7 -2
  99. data/lib/fontisan/ufo/reader.rb +3 -1
  100. data/lib/fontisan/ufo/writer.rb +13 -5
  101. data/lib/fontisan/ufo.rb +2 -1
  102. data/lib/fontisan/version.rb +1 -1
  103. data/lib/fontisan/woff2/collection_decoder.rb +4 -1
  104. data/lib/fontisan/woff2/collection_encoder.rb +4 -1
  105. data/lib/fontisan/woff2/glyf_canonicalizer.rb +2 -1
  106. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +5 -2
  107. data/lib/fontisan/woff2/sfnt_checksum.rb +2 -1
  108. data/lib/fontisan.rb +0 -1
  109. metadata +36 -4
  110. data/lib/fontisan/tasks/fixture_downloader.rb +0 -162
  111. data/lib/fontisan/tasks.rb +0 -11
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ module TableStrategy
6
+ # Head strategy: recompute xMin/yMin/xMax/yMax (offsets 36–43) from
7
+ # the subset's actual glyphs. The source TTC's bbox covers every
8
+ # donor font in the collection and is far larger than any per-block
9
+ # subset needs; an inflated bbox makes browsers render glyphs at
10
+ # the wrong visual size. The remaining head fields pass through
11
+ # unchanged — checksumAdjustment is rewritten by FontWriter.
12
+ class Head
13
+ # @param context [SubsetContext]
14
+ # @param tag [String] "head"
15
+ # @param table [Head] parsed head table (unused)
16
+ # @return [String] binary head bytes for the subset
17
+ def self.call(context:, tag:, table:)
18
+ ensure_glyf_built!(context)
19
+
20
+ data = context.font.table_data["head"].dup
21
+ bbox = context.state.subset_bbox
22
+ return data unless bbox
23
+
24
+ x_min, y_min, x_max, y_max = bbox
25
+ data[36, 8] = [x_min, y_min, x_max, y_max].pack("n4")
26
+ data
27
+ end
28
+
29
+ def self.ensure_glyf_built!(context)
30
+ return if context.state.glyf_data
31
+
32
+ builder = GlyfLocaBuilder.new(font: context.font,
33
+ mapping: context.mapping).build
34
+ context.state.glyf_data = builder.glyf_data
35
+ context.state.loca_offsets = builder.loca_offsets
36
+ context.state.subset_bbox = builder.bbox
37
+ end
38
+ private_class_method :ensure_glyf_built!
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ module TableStrategy
6
+ # Hhea strategy: rewrite numberOfHMetrics (offset 34, uint16) and
7
+ # advanceWidthMax (offset 10, uint16). The source TTC's
8
+ # advanceWidthMax covers every donor font and can be far larger
9
+ # than any per-block subset needs; recomputing from the subset's
10
+ # actual hmtx keeps the value honest.
11
+ #
12
+ # Hhea runs alphabetically before Hmtx, so the Hmtx strategy's
13
+ # SharedState cache hasn't been populated yet — we walk the source
14
+ # hmtx directly to find the max.
15
+ class Hhea
16
+ # @param context [SubsetContext]
17
+ # @param tag [String] "hhea"
18
+ # @param table [Hhea] parsed hhea table
19
+ # @return [String] binary hhea bytes for the subset
20
+ def self.call(context:, tag:, table:)
21
+ data = table.to_binary_s.dup
22
+
23
+ new_num_h_metrics = calculate_number_of_h_metrics(context, table)
24
+ data[34, 2] = [new_num_h_metrics].pack("n")
25
+
26
+ new_max = compute_subset_max_advance(context)
27
+ data[10, 2] = [new_max].pack("n") if new_max.positive?
28
+
29
+ data
30
+ end
31
+
32
+ def self.calculate_number_of_h_metrics(context, _table)
33
+ hmtx = context.font.table("hmtx")
34
+ if hmtx&.parsed? && hmtx.h_metrics
35
+ hmtx.h_metrics.size
36
+ else
37
+ context.mapping.size
38
+ end
39
+ end
40
+
41
+ def self.compute_subset_max_advance(context)
42
+ hmtx = context.font.table("hmtx")
43
+ return 0 unless hmtx
44
+
45
+ unless hmtx.parsed?
46
+ hhea = context.font.table("hhea")
47
+ maxp = context.font.table("maxp")
48
+ hmtx.parse_with_context(hhea.number_of_h_metrics,
49
+ maxp.num_glyphs)
50
+ end
51
+
52
+ max_advance = 0
53
+ context.mapping.old_ids.each do |old_id|
54
+ metric = hmtx.metric_for(old_id)
55
+ next unless metric
56
+
57
+ advance = metric[:advance_width]
58
+ max_advance = advance if advance && advance > max_advance
59
+ end
60
+ max_advance
61
+ end
62
+ private_class_method :calculate_number_of_h_metrics,
63
+ :compute_subset_max_advance
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ module TableStrategy
6
+ # Hmtx strategy: emit one LongHorMetric per glyph in the subset's
7
+ # glyph mapping, in the same order as the mapping. Also records
8
+ # the maximum advanceWidth into [SharedState] so Hhea (which is
9
+ # processed alphabetically before Hmtx) can read it.
10
+ #
11
+ # Tables are processed in profile order — Hhea comes before Hmtx
12
+ # alphabetically — so Hhea reads its max advance via a private
13
+ # helper that walks the source hmtx directly. Hmtx then stashes
14
+ # the value into SharedState for any later consumer.
15
+ class Hmtx
16
+ # @param context [SubsetContext]
17
+ # @param tag [String] "hmtx"
18
+ # @param table [Hmtx] parsed hmtx table
19
+ # @return [String] binary hmtx bytes for the subset
20
+ def self.call(context:, tag:, table:)
21
+ ensure_parsed!(context, table)
22
+
23
+ data = String.new(encoding: Encoding::BINARY)
24
+ max_advance = 0
25
+
26
+ context.mapping.old_ids.each do |old_id|
27
+ metric = table.metric_for(old_id)
28
+ next unless metric
29
+
30
+ advance = metric[:advance_width]
31
+ max_advance = advance if advance && advance > max_advance
32
+ data << [advance].pack("n")
33
+ data << [metric[:lsb]].pack("n")
34
+ end
35
+
36
+ context.state.subset_max_advance = max_advance
37
+ data
38
+ end
39
+
40
+ def self.ensure_parsed!(context, table)
41
+ return if table.parsed?
42
+
43
+ hhea = context.font.table("hhea")
44
+ maxp = context.font.table("maxp")
45
+ table.parse_with_context(hhea.number_of_h_metrics,
46
+ maxp.num_glyphs)
47
+ end
48
+ private_class_method :ensure_parsed!
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ module TableStrategy
6
+ # Loca strategy: emit one entry per subset glyph + a final offset,
7
+ # using short or long format based on the source head.index_to_loc_format.
8
+ # Glyph offsets come from [SharedState], populated earlier by the
9
+ # Glyf strategy. If Glyf hasn't run yet (e.g. direct invocation
10
+ # from a spec), this strategy triggers the build lazily.
11
+ class Loca
12
+ # @param context [SubsetContext]
13
+ # @param tag [String] "loca"
14
+ # @param table [Loca] parsed loca table (unused)
15
+ # @return [String] binary loca bytes for the subset
16
+ def self.call(context:, tag:, table:)
17
+ offsets = context.state.loca_offsets
18
+ if offsets.nil?
19
+ builder = GlyfLocaBuilder.new(font: context.font,
20
+ mapping: context.mapping).build
21
+ context.state.glyf_data = builder.glyf_data
22
+ context.state.loca_offsets = builder.loca_offsets
23
+ context.state.subset_bbox = builder.bbox
24
+ offsets = builder.loca_offsets
25
+ end
26
+
27
+ head = context.font.table("head")
28
+ data = String.new(encoding: Encoding::BINARY)
29
+
30
+ if head.index_to_loc_format.zero?
31
+ offsets.each { |o| data << [o / 2].pack("n") }
32
+ else
33
+ offsets.each { |o| data << [o].pack("N") }
34
+ end
35
+
36
+ data
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ module TableStrategy
6
+ # Maxp strategy: rewrite numGlyphs (offset 4, uint16) to the
7
+ # subset's glyph count. The remaining maxp fields describe worst-
8
+ # case outline/instruction complexity and can be left as-is —
9
+ # a smaller subset cannot exceed the source's maxima.
10
+ class Maxp
11
+ # @param context [SubsetContext]
12
+ # @param tag [String] "maxp"
13
+ # @param table [Maxp] parsed maxp table
14
+ # @return [String] binary maxp bytes for the subset
15
+ def self.call(context:, tag:, table:)
16
+ data = table.to_binary_s.dup
17
+ data[4, 2] = [context.mapping.size].pack("n")
18
+ data
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ module TableStrategy
6
+ # Name strategy: name records don't reference glyph IDs, so the
7
+ # full source table is preserved.
8
+ class Name
9
+ # @param context [SubsetContext]
10
+ # @param tag [String] "name"
11
+ # @param table [Name] parsed name table (unused)
12
+ # @return [String] source name bytes verbatim
13
+ def self.call(context:, tag:, table:)
14
+ context.font.table_data[tag]
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ module TableStrategy
6
+ # OS/2 strategy: OS/2 currently passes through unchanged. Unicode
7
+ # range pruning is left as future work — the source OS/2 ranges
8
+ # remain valid (they describe the source character coverage, which
9
+ # is a superset of the subset's).
10
+ class Os2
11
+ # @param context [SubsetContext]
12
+ # @param tag [String] "OS/2"
13
+ # @param table [Os2] parsed OS/2 table (unused)
14
+ # @return [String] source OS/2 bytes verbatim
15
+ def self.call(context:, tag:, table:)
16
+ context.font.table_data[tag]
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ module TableStrategy
6
+ # Default fallback for tags that have no dedicated strategy.
7
+ # Returns the source table bytes verbatim — used for tables that
8
+ # don't need any glyph-aware subsetting (e.g. name, OS/2).
9
+ class PassThrough
10
+ # @param context [SubsetContext]
11
+ # @param tag [String] table tag
12
+ # @param table [Object, nil] parsed table (unused)
13
+ # @return [String, nil] source binary bytes for `tag`, or nil
14
+ def self.call(context:, tag:, table:)
15
+ context.font.table_data[tag]
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ module TableStrategy
6
+ # Post strategy: if the `drop_names` option is set, rewrite as a
7
+ # version 3.0 post table (no glyph names). Otherwise pass through
8
+ # the source post bytes.
9
+ class Post
10
+ # @param context [SubsetContext]
11
+ # @param tag [String] "post"
12
+ # @param table [Post] parsed post table
13
+ # @return [String] binary post bytes for the subset
14
+ def self.call(context:, tag:, table:)
15
+ return build_v3(context) if context.options.drop_names
16
+
17
+ context.font.table_data["post"]
18
+ end
19
+
20
+ def self.build_v3(context)
21
+ data = String.new(encoding: Encoding::BINARY)
22
+ data << [0x00030000].pack("N") # version 3.0
23
+
24
+ original = context.font.table_data["post"]
25
+ data << if original&.length && original.length >= 32
26
+ original[4, 28]
27
+ else
28
+ [0, 0, 0, 0, 0, 0, 0].pack("N7")
29
+ end
30
+
31
+ data
32
+ end
33
+ private_class_method :build_v3
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Autoload hub for the Fontisan::Subset::TableStrategy namespace.
4
+ #
5
+ # Each table tag handled by the subsetter has a corresponding strategy
6
+ # class (e.g. `TableStrategy::Maxp` for the "maxp" tag). Strategies are
7
+ # stateless — they receive a [SubsetContext] + tag + table and return
8
+ # the subset table's binary bytes. Unknown tags fall back to
9
+ # [TableStrategy::PassThrough], which preserves the source bytes.
10
+ #
11
+ # Registering a new tag is a one-line change: add the autoload here and
12
+ # an entry in REGISTRY. No edits to TableSubsetter required (Open/Closed).
13
+
14
+ module Fontisan
15
+ module Subset
16
+ module TableStrategy
17
+ autoload :Cblc, "fontisan/subset/table_strategy/cblc"
18
+ autoload :Cbdt, "fontisan/subset/table_strategy/cbdt"
19
+ autoload :Cmap, "fontisan/subset/table_strategy/cmap"
20
+ autoload :ColorBitmapPlacement,
21
+ "fontisan/subset/table_strategy/color_bitmap_placement"
22
+ autoload :ColorBitmapStrikePlan,
23
+ "fontisan/subset/table_strategy/color_bitmap_strike_plan"
24
+ autoload :ColorBitmapSubsetPlan,
25
+ "fontisan/subset/table_strategy/color_bitmap_subset_plan"
26
+ autoload :ColorBitmapSubsetter,
27
+ "fontisan/subset/table_strategy/color_bitmap_subsetter"
28
+ autoload :ColorBitmapSubtablePlan,
29
+ "fontisan/subset/table_strategy/color_bitmap_subtable_plan"
30
+ autoload :Glyf, "fontisan/subset/table_strategy/glyf"
31
+ autoload :GlyfLocaBuilder,
32
+ "fontisan/subset/table_strategy/glyf_loca_builder"
33
+ autoload :Head, "fontisan/subset/table_strategy/head"
34
+ autoload :Hhea, "fontisan/subset/table_strategy/hhea"
35
+ autoload :Hmtx, "fontisan/subset/table_strategy/hmtx"
36
+ autoload :Loca, "fontisan/subset/table_strategy/loca"
37
+ autoload :Maxp, "fontisan/subset/table_strategy/maxp"
38
+ autoload :Name, "fontisan/subset/table_strategy/name"
39
+ autoload :Os2, "fontisan/subset/table_strategy/os2"
40
+ autoload :PassThrough, "fontisan/subset/table_strategy/pass_through"
41
+ autoload :Post, "fontisan/subset/table_strategy/post"
42
+
43
+ # Tag → strategy class name (resolved lazily via const_get to keep
44
+ # this file loadable in any order). Add new subsetting logic by
45
+ # adding one autoload + one entry here, plus a strategy file under
46
+ # `lib/fontisan/subset/table_strategy/`.
47
+ REGISTRY = {
48
+ "maxp" => :Maxp,
49
+ "hhea" => :Hhea,
50
+ "hmtx" => :Hmtx,
51
+ "loca" => :Loca,
52
+ "glyf" => :Glyf,
53
+ "cmap" => :Cmap,
54
+ "post" => :Post,
55
+ "name" => :Name,
56
+ "head" => :Head,
57
+ "OS/2" => :Os2,
58
+ "CBDT" => :Cbdt,
59
+ "CBLC" => :Cblc,
60
+ }.freeze
61
+
62
+ # Resolve a table tag to its strategy class. Returns PassThrough
63
+ # for any tag without an explicit registration.
64
+ #
65
+ # @param tag [String] OpenType table tag (e.g. "maxp", "CBDT")
66
+ # @return [Class] a strategy class responding to `.call`
67
+ def self.for(tag)
68
+ const_name = REGISTRY[tag]
69
+ return PassThrough unless const_name
70
+
71
+ const_get(const_name)
72
+ end
73
+ end
74
+ end
75
+ end