qr_forge 1.0.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 379423936d868bc8e4589d3483cc412c1bd6ec85bd6b9ae82e9ad58dfeb03903
4
+ data.tar.gz: 815cf224b5876c0222e8449b5edcbcc35d4f00c53d800f0a4cddb54d31f42735
5
+ SHA512:
6
+ metadata.gz: 75c947677801f0d3d4469d3544206f9f3d0755ef90444b2eec312cf6373a0a2e10661d86a7f97d574ff69d05accb797299699d9bfd6f517b74130ecfe5393765
7
+ data.tar.gz: b31403d7730bb7f2bca48f237b8e99fd1a96414c470e24485164dd6046990e337a385bae68eb8832cff3cb2504b3fccdf0ffb5553b91a5d8bc70c7037e0bc10a
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,20 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.1
3
+
4
+ Style/StringLiterals:
5
+ EnforcedStyle: double_quotes
6
+
7
+ Style/StringLiteralsInInterpolation:
8
+ EnforcedStyle: double_quotes
9
+
10
+ Metrics/MethodLength:
11
+ Max: 50
12
+
13
+ Naming/MethodParameterName:
14
+ Enabled: false
15
+
16
+ Metrics/ParameterLists:
17
+ Max: 10
18
+
19
+ Metrics/BlockLength:
20
+ Max: 150
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-06-16
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Keegankb93
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # QrForge
2
+
3
+ TODO: Delete this and the text below, and describe your gem
4
+
5
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/qr_forge`. To experiment with that code, run `bin/console` for an interactive prompt.
6
+
7
+ ## Installation
8
+
9
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ ```bash
14
+ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
15
+ ```
16
+
17
+ If bundler is not being used to manage dependencies, install the gem by executing:
18
+
19
+ ```bash
20
+ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/qr_forge.
36
+
37
+ ## License
38
+
39
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrForge
4
+ module Components
5
+ module EyeInner
6
+ class Circle < ForgeComponent
7
+ # how many modules to inset on *each* side
8
+ INSET_MODULES = 2
9
+
10
+ # @see ForgeComponent#draw
11
+ def draw(y:, x:, quiet_zone:, area:, color: "black", **_)
12
+ # 1) full-finder radius in modules
13
+ full_radius = area / 2.0
14
+
15
+ # 2) subtract that many modules from *each* side
16
+ r = full_radius - INSET_MODULES
17
+
18
+ return if r <= 0 # no room to draw
19
+
20
+ # 3) center calculation
21
+ cx = x + quiet_zone + full_radius
22
+ cy = y + quiet_zone + full_radius
23
+
24
+ @xml_builder.circle(
25
+ cx:,
26
+ cy:,
27
+ r: r,
28
+ fill: color,
29
+ test_id: @test_id
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrForge
4
+ module Components
5
+ module EyeInner
6
+ #
7
+ # Square component for the inner eye of a finder pattern
8
+ # This draws a 3x3 square in the center of the finder pattern.
9
+ class Square < ForgeComponent
10
+ # @see ForgeComponent#draw
11
+ def draw(y:, x:, quiet_zone:, area:, color: "black", **_)
12
+ position_offset = 2
13
+ area_offset = 4
14
+
15
+ x = x + position_offset + quiet_zone
16
+ y = y + position_offset + quiet_zone
17
+ area_with_offset = area - area_offset
18
+
19
+ @xml_builder.rect(
20
+ x:,
21
+ y:,
22
+ width: area_with_offset,
23
+ height: area_with_offset,
24
+ fill: color,
25
+ test_id: @test_id
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrForge
4
+ module Components
5
+ module EyeOuter
6
+ class Circle < ForgeComponent
7
+ DEFAULT_STROKE_WIDTH = 1.0
8
+
9
+ # @see ForgeComponent#draw
10
+ # Draws a circle that fills the full 'area' box, inset by half the stroke so it
11
+ # never overlaps the modules beneath.
12
+ def draw(y:, x:, quiet_zone:, area:, color: "black", **_)
13
+ stroke_width = DEFAULT_STROKE_WIDTH
14
+
15
+ # Radius = (full width of box – one stroke) / 2
16
+ r = (area - stroke_width) / 2.0
17
+
18
+ # Center of the N×N box (plus quiet_zone offset)
19
+ cx = x + quiet_zone + (area / 2.0)
20
+ cy = y + quiet_zone + (area / 2.0)
21
+
22
+ @xml_builder.circle(
23
+ cx: cx,
24
+ cy: cy,
25
+ r: r,
26
+ 'stroke-width': stroke_width,
27
+ stroke: color,
28
+ fill: "transparent",
29
+ test_id: @test_id
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrForge
4
+ module Components
5
+ module EyeOuter
6
+ class Square < ForgeComponent
7
+ # @see ForgeComponent#draw
8
+ def draw(y:, x:, quiet_zone:, area:, color: "black", **_)
9
+ x += quiet_zone
10
+ y += quiet_zone
11
+
12
+ # Draw the outer black square (7x7)
13
+ @xml_builder.rect(
14
+ x:,
15
+ y:,
16
+ width: area,
17
+ height: area,
18
+ fill: color
19
+ )
20
+
21
+ # Draw the inner (cutout) square (5x5) to create the finder pattern
22
+ inset = 1
23
+ inner = area - 2
24
+
25
+ @xml_builder.rect(
26
+ x: x + inset,
27
+ y: y + inset,
28
+ width: inner,
29
+ height: inner,
30
+ fill: color,
31
+ test_id: @test_id
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrForge
4
+ module Components
5
+ #
6
+ # Base class for all components in the QR code design.
7
+ class ForgeComponent
8
+ #
9
+ # @param xml_builder [Nokogiri::XML::Builder]
10
+ def initialize(xml_builder:, test_id: nil)
11
+ @xml_builder = xml_builder
12
+ @test_id = test_id
13
+ end
14
+
15
+ # TODO: For modules pass the next and prev module to change design based on if there is another module next or behind
16
+ #
17
+ # @param y [Integer] The row index
18
+ # @param x [Integer] The col index
19
+ # @param quiet_zone [Integer] The padding around the QR code
20
+ # @param area [Integer] width and height of the area to draw (width and height are equal)
21
+ def draw(y:, x:, quiet_zone:, area:, **_)
22
+ raise NotImplementedError, "FinderBorder::Base subclasses must implement `draw`"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrForge
4
+ module Components
5
+ module Module
6
+ # Circle component for the individual module.
7
+ class Circle < ForgeComponent
8
+ # @see ForgeComponent#draw
9
+ def draw(y:, x:, quiet_zone:, color: "black", **_)
10
+ r = 0.5
11
+ cx = x + r + quiet_zone
12
+ cy = y + r + quiet_zone
13
+
14
+ @xml_builder.circle(
15
+ cx:,
16
+ cy:,
17
+ r:,
18
+ fill: color,
19
+ test_id: @test_id
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrForge
4
+ module Components
5
+ module Module
6
+ # Square component for the individual module.
7
+ class Square < ForgeComponent
8
+ # @see ForgeComponent#draw
9
+ def draw(y:, x:, quiet_zone:, color: "black", **_)
10
+ x += quiet_zone
11
+ y += quiet_zone
12
+ area = 1
13
+
14
+ @xml_builder.rect(
15
+ x:,
16
+ y:,
17
+ width: area,
18
+ height: area,
19
+ fill: color,
20
+ test_id: @test_id
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "vips"
4
+
5
+ module QrForge
6
+ #
7
+ # Handles exporting the generated QR code in various formats.
8
+ class Exporter
9
+ def initialize(config:)
10
+ @format = config.dig(:output, :format) || :svg
11
+ end
12
+
13
+ def export(svg)
14
+ case @format
15
+ when :svg
16
+ svg
17
+ when :png
18
+ as_png(svg)
19
+ else
20
+ raise "Unsupported export format: #{@format}"
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ #
27
+ # Exports the SVG to PNG format using Vips.
28
+ # @param svg [String] The SVG content to convert
29
+ # @return [StringIO] A StringIO object containing the PNG data
30
+ def as_png(svg)
31
+ image = Vips::Image.svgload_buffer(svg)
32
+ # TODO: Compression doesn't really seem to give much noticeable difference in quality or file size.
33
+ buffer = image.write_to_buffer(".png")
34
+
35
+ StringIO.new(buffer)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrForge
4
+ #
5
+ # Entry point for building QRCodes
6
+ class Forge
7
+ def initialize(text:, config:)
8
+ version = config.dig(:qr, :version)
9
+
10
+ @data = QrForge::QrData.new(text:, version:)
11
+ @renderer = QrForge::Renderer.new(qr_data: @data, config:)
12
+ @exporter = QrForge::Exporter.new(config:)
13
+ end
14
+
15
+ #
16
+ # Builds a QR code with the given parameters.
17
+ # @param text [String] The text/data to encode in the QR code
18
+ # @param size [Integer] The size of the QR code in modules [1-40]
19
+ # @return [String, StringIO] The SVG or PNG representation of the QR code
20
+ def self.build(text:, config: {})
21
+ new(text:, config:).build
22
+ end
23
+
24
+ def build
25
+ svg = @renderer.to_svg
26
+ @exporter.export(svg)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrForge
4
+ # Responsible only for computing all the exclusion & pattern zones
5
+ class Layout
6
+ FINDER_PATTERN_SIZE = 7
7
+ ALIGNMENT_PATTERN_SIZE = 5
8
+
9
+ def initialize(qr_data:, has_image: false)
10
+ @qr_data = qr_data
11
+ @module_count = qr_data.module_count
12
+ @version = qr_data.version
13
+ @modules = qr_data.modules
14
+ @has_image = has_image
15
+
16
+ # build_cache
17
+ end
18
+
19
+ #
20
+ # Checks if a given row and column are inside the image area.
21
+ # @param row [Integer] the row index
22
+ # @param col [Integer] the column index
23
+ # @return [Boolean] true if the row and column are inside the image area, false otherwise
24
+ def inside_image?(row, col)
25
+ inside_area?(row, col, image_area)
26
+ end
27
+
28
+ #
29
+ # Checks if a given row and column are inside the finder patterns.
30
+ # @param row [Integer] the row index
31
+ # @param col [Integer] the column index
32
+ # @return [Boolean] true if the row and column are inside any finder pattern, false otherwise
33
+ def inside_finder?(row, col)
34
+ inside_area?(row, col, finder_patterns[:areas])
35
+ end
36
+
37
+ #
38
+ # Checks if a given row and column are inside the alignment patterns.
39
+ # @param row [Integer] the row index
40
+ # @param col [Integer] the column index
41
+ # @return [Boolean] true if the row and column are inside any alignment pattern, false otherwise
42
+ def inside_alignment?(row, col)
43
+ inside_area?(row, col, alignment_patterns[:areas])
44
+ end
45
+
46
+ #
47
+ # Returns the image area as a Range of rows and columns.
48
+ # The image area is centered in the QR code and its size is based on the module count.
49
+ # @return [Range] the range of rows and columns that the image area covers
50
+ def image_area
51
+ @image_area ||= begin
52
+ raw = (@module_count * 0.30).round
53
+ size = raw.even? ? raw + 1 : raw
54
+ start_idx = ((@module_count - size) / 2.0).floor
55
+ (start_idx...(start_idx + size))
56
+ end
57
+ end
58
+
59
+ #
60
+ # Returns the finder patterns' coordinates and areas.
61
+ # Coordinates are the top-left corners of the finder patterns.
62
+ # Areas are the ranges of rows and columns that the finder patterns cover.
63
+ # @return [Hash] with keys :coordinates and :areas
64
+ def finder_patterns
65
+ @finder_patterns ||= begin
66
+ offset = @module_count - FINDER_PATTERN_SIZE
67
+ coordinates = [[0, 0], [0, offset], [offset, 0]]
68
+ areas = []
69
+
70
+ coordinates.map do |row, col|
71
+ areas << [(row...(row + FINDER_PATTERN_SIZE)), (col...(col + FINDER_PATTERN_SIZE))]
72
+ end
73
+
74
+ { coordinates: coordinates, areas: areas }
75
+ end
76
+ end
77
+
78
+ # TODO: Remove some complexity out of this method
79
+ # Returns the alignment patterns' coordinates and areas.
80
+ # Coordinates are the top-left corners of the alignment patterns.
81
+ # Areas are the ranges of rows and columns that the alignment patterns cover.
82
+ # @return [Hash] with keys :coordinates and :areas
83
+ def alignment_patterns
84
+ @alignment_patterns ||= begin
85
+ # Offset from center to top-left corner of the alignment pattern
86
+ half_offset = (ALIGNMENT_PATTERN_SIZE - 1) / 2
87
+
88
+ # RQRCodeCore provides a helper method to get the alignment pattern centers
89
+ alignment_center_coordinates = RQRCodeCore::QRUtil.get_pattern_positions(@version)
90
+
91
+ # Filter out centers that are inside finder patterns or the image area
92
+ valid_alignment_coordinates = alignment_center_coordinates.product(alignment_center_coordinates).reject do |center_row, center_col|
93
+ inside_finder?(center_row, center_col) || (@has_image && inside_image?(center_row, center_col))
94
+ end
95
+
96
+ locations = { coordinates: [], areas: [] }
97
+
98
+ # Map valid alignment centers to their top-left corners and areas
99
+ valid_alignment_coordinates.map do |center_row, center_col|
100
+ top_left_row = center_row - half_offset
101
+ top_left_col = center_col - half_offset
102
+
103
+ locations[:coordinates] << [top_left_row, top_left_col]
104
+
105
+ row_range = top_left_row...(top_left_row + ALIGNMENT_PATTERN_SIZE)
106
+ col_range = top_left_col...(top_left_col + ALIGNMENT_PATTERN_SIZE)
107
+
108
+ locations[:areas] << [row_range, col_range]
109
+ end
110
+
111
+ locations
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ #
118
+ # Checks if a given row and column are inside a specified area.
119
+ # @return [Boolean] true if the row and column are inside the area, false otherwise
120
+ def inside_area?(row, col, area)
121
+ case area
122
+ when Range
123
+ area.cover?(row) && area.cover?(col)
124
+ when Array
125
+ Array(area).any? do |row_range, col_range|
126
+ row_range.cover?(row) && col_range.cover?(col)
127
+ end
128
+ else
129
+ raise ArgumentError, "Invalid area type: #{area.class}. Expected Range or Array of Ranges."
130
+ end
131
+ end
132
+
133
+ # Potentially implement caching for layouts. Benchmark saves 100-150ms on a version 40 QR code
134
+ # Most time spent is via RQRCodeCore.new and vips
135
+ # def build_cache
136
+ # @cache = {
137
+ # }
138
+ #
139
+ # @cache[:logo] ||= compute_logo_coords
140
+ # @cache[:finder] ||= compute_coords_for_areas(finder_patterns[:areas])
141
+ # @cache[:alignment] ||= compute_coords_for_areas(alignment_patterns[:areas])
142
+ #
143
+ # @cache
144
+ # end
145
+
146
+ # Turn a single Range (logo area) into a Set of [row,col] pairs
147
+ # def compute_logo_coords
148
+ # coords = []
149
+ #
150
+ # # Declare for readability
151
+ # row_range = logo_area
152
+ # col_range = logo_area
153
+ #
154
+ # row_range.to_a.each do |r|
155
+ # col_range.to_a.each do |c|
156
+ # coords << [r, c]
157
+ # end
158
+ # end
159
+ # coords.to_set
160
+ # end
161
+ #
162
+ # # Turn an Array of [row_range, col_range] into a Set of all covered coords
163
+ # def compute_coords_for_areas(areas)
164
+ # coords = []
165
+ # areas.each do |row_range, col_range|
166
+ # row_range.to_a.each do |r|
167
+ # col_range.to_a.each do |c|
168
+ # coords << [r, c]
169
+ # end
170
+ # end
171
+ # end
172
+ # coords.to_set
173
+ # end
174
+ end
175
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rqrcode_core/qrcode"
4
+
5
+ module QrForge
6
+ #
7
+ # QrData is a wrapper around RQRCodeCore::QRCode that provides
8
+ # a simplified interface for accessing QR code data.
9
+ # We can also add additional data related to the QR code, but not always RQRCodeCore related
10
+ class QrData
11
+ attr_reader :modules, :version, :module_count, :quiet_zone
12
+
13
+ def initialize(text:, version: 10, level: :h)
14
+ qr = RQRCodeCore::QRCode.new(text, size: version, level: level)
15
+ @version = qr.version
16
+ @modules = qr.modules.map(&:dup)
17
+ @module_count = @modules.size
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require_relative "layout"
5
+
6
+ module QrForge
7
+ #
8
+ # Renderer class is responsible for generating the SVG representation of a QR code.
9
+ #
10
+ # It takes QR data, design options, and renders the QR code with finder patterns, alignment patterns,
11
+ # and modules. It also handles the image if provided.
12
+ class Renderer
13
+ # Rendering of how row and column indices relate to the finder pattern:
14
+ #
15
+ # col_index (x)
16
+ # 0 1 2 3 4 5 6
17
+ # row_index (y)
18
+ # 0 # # # # # # #
19
+ # 1 # . . . . . #
20
+ # 2 # . # # # . #
21
+ # 3 # . # X # . # ← (3,3) is inside the finder
22
+ # 4 # . # # # . #
23
+ # 5 # . . . . . #
24
+ # 6 # # # # # # #
25
+ #
26
+ # Legend:
27
+ # "#" = dark module (true)
28
+ # "." = light module (false)
29
+ # "X" = example point at (row_index=3, col_index=3)
30
+ #
31
+
32
+ DEFAULT_COLORS = {
33
+ module: "black",
34
+ outer_eye: "black",
35
+ inner_eye: "black"
36
+ }.freeze
37
+
38
+ DEFAULT_COMPONENTS = {
39
+ outer_eye: ::QrForge::Components::EyeOuter::Circle,
40
+ inner_eye: ::QrForge::Components::EyeInner::Circle,
41
+ module: ::QrForge::Components::Module::Circle
42
+ }.freeze
43
+
44
+ def initialize(qr_data:, config:)
45
+ @qr_data = qr_data
46
+ @components = DEFAULT_COMPONENTS.merge(config.fetch(:components, {}))
47
+ @quiet_zone = 4
48
+ @module_count = qr_data.module_count
49
+ @image = config.dig(:design, :image)
50
+ @width = config.dig(:design, :size)
51
+ @height = config.dig(:design, :size)
52
+ @colors = DEFAULT_COLORS.merge(config.dig(:design, :colors) || {})
53
+ @layout = QrForge::Layout.new(qr_data:, has_image: image_present?)
54
+ end
55
+
56
+ #
57
+ # Generates the SVG representation of the QR code.
58
+ def to_svg
59
+ Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
60
+ xml.svg(
61
+ width: @width || canvas_size,
62
+ height: @height || canvas_size,
63
+ xmlns: "http://www.w3.org/2000/svg",
64
+ viewBox: "0 0 #{canvas_size} #{canvas_size}"
65
+ ) do
66
+ draw_background(xml)
67
+ draw_image(xml)
68
+ draw_finder_patterns(xml)
69
+ draw_alignment_patterns(xml)
70
+ draw_modules(xml)
71
+ end
72
+ end.to_xml
73
+ end
74
+
75
+ #
76
+ # Calculates the size of the canvas for the QR code.
77
+ # The canvas size is the module count plus the quiet zone on both sides.
78
+ #
79
+ # @return [Integer] the total size of the canvas in modules
80
+ #
81
+ # The quiet zone is a margin around the QR code to ensure readability.
82
+ def canvas_size
83
+ @canvas_size ||= @module_count + (@quiet_zone * 2)
84
+ end
85
+
86
+ private
87
+
88
+ #
89
+ # Checks if an image is present.
90
+ # @return [Boolean] true if an image is provided, false otherwise
91
+ def image_present?
92
+ !@image.nil? && !@image.empty?
93
+ end
94
+
95
+ #
96
+ # Draws the image in the center of the QR code if an image is provided and the QR code version is 2 or higher.
97
+ # The image is drawn in the area defined by the image_range, which is a Range of row indices.
98
+ def draw_image(xml)
99
+ return unless image_present? && @qr_data.version >= 2
100
+
101
+ image_range = @layout.image_area # a Range (row indices) for the image
102
+ size = image_range.size # width/height in modules
103
+
104
+ # Convert module coords → SVG coords (including quiet zone)
105
+ x = image_range.first + @quiet_zone
106
+ y = image_range.first + @quiet_zone
107
+
108
+ xml.image(
109
+ test_id: "image",
110
+ href: "data:image/png;base64,#{@image}",
111
+ x:,
112
+ y:,
113
+ width: size,
114
+ height: size,
115
+ preserveAspectRatio: "none"
116
+ )
117
+ end
118
+
119
+ #
120
+ # Draws the background rectangle for the QR code. Should always be white to ensure contrast.
121
+ # The rectangle is drawn with a size that includes the quiet zone around the QR code.
122
+ def draw_background(xml)
123
+ xml.rect(
124
+ test_id: "background",
125
+ x: -@quiet_zone,
126
+ y: -@quiet_zone,
127
+ width: canvas_size + @quiet_zone,
128
+ height: canvas_size + @quiet_zone,
129
+ fill: "white"
130
+ )
131
+ end
132
+
133
+ #
134
+ # Draws the finder patterns in the QR code.
135
+ # The finder patterns are the large squares in the corners of the QR code.
136
+ def draw_finder_patterns(xml)
137
+ @layout.finder_patterns[:coordinates].each.with_index do |(r, c), idx|
138
+ %i[outer_eye inner_eye].each do |layer|
139
+ @components[layer]
140
+ .new(xml_builder: xml, test_id: "finder_pattern_#{layer}_#{idx}")
141
+ .draw(y: r, x: c, quiet_zone: @quiet_zone, area: QrForge::Layout::FINDER_PATTERN_SIZE, color: @colors[layer])
142
+ end
143
+ end
144
+ end
145
+
146
+ #
147
+ # Draws the alignment patterns in the QR code.
148
+ # The alignment patterns are smaller squares that help with error correction.
149
+ def draw_alignment_patterns(xml)
150
+ size = QrForge::Layout::ALIGNMENT_PATTERN_SIZE
151
+
152
+ @layout.alignment_patterns[:coordinates].each.with_index do |(r, c), idx|
153
+ %i[outer_eye inner_eye].each do |layer|
154
+ @components[layer]
155
+ .new(xml_builder: xml, test_id: "alignment_pattern_#{layer}_#{idx}")
156
+ .draw(y: r, x: c, quiet_zone: @quiet_zone, area: size, color: @colors[layer])
157
+ end
158
+ end
159
+ end
160
+
161
+ # TODO: This can probably be optional as we can also just layer the image on top of the QR code.
162
+ # Removes modules in the image area if a image is present and the QR code version is 2 or higher.
163
+ #
164
+ # @return [void]
165
+ def remove_modules_for_image
166
+ return unless image_present? && @qr_data.version >= 2
167
+
168
+ image_area = @layout.image_area
169
+
170
+ image_area.each do |r|
171
+ image_area.each do |c|
172
+ @qr_data.modules[r][c] = false
173
+ end
174
+ end
175
+ end
176
+
177
+ #
178
+ # Draws the individual modules of the QR code.
179
+ def draw_modules(xml)
180
+ remove_modules_for_image
181
+
182
+ @qr_data.modules.each_with_index do |row, r|
183
+ row.each_with_index do |cell, c|
184
+ next if !cell ||
185
+ @layout.inside_finder?(r, c) ||
186
+ @layout.inside_alignment?(r, c)
187
+
188
+ @components[:module]
189
+ .new(xml_builder: xml, test_id: "module_#{r}_#{c}")
190
+ .draw(y: r, x: c, quiet_zone: @quiet_zone, area: 1, color: @colors[:module])
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrForge
4
+ VERSION = "1.0.0"
5
+ end
data/lib/qr_forge.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ loader = Zeitwerk::Loader.for_gem
5
+ loader.setup
6
+
7
+ module QrForge;end
metadata ADDED
@@ -0,0 +1,146 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: qr_forge
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Keegankb93
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.3.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.3.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: nokogiri
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.18'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.18'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rqrcode_core
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: vips
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '8.15'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '8.15'
68
+ - !ruby/object:Gem::Dependency
69
+ name: zeitwerk
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '2.6'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.6'
82
+ - !ruby/object:Gem::Dependency
83
+ name: capybara
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.40'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.40'
96
+ email:
97
+ - keegankb@gmail.com
98
+ executables: []
99
+ extensions: []
100
+ extra_rdoc_files: []
101
+ files:
102
+ - ".rspec"
103
+ - ".rubocop.yml"
104
+ - CHANGELOG.md
105
+ - LICENSE.txt
106
+ - README.md
107
+ - Rakefile
108
+ - lib/qr_forge.rb
109
+ - lib/qr_forge/components/eye_inner/circle.rb
110
+ - lib/qr_forge/components/eye_inner/square.rb
111
+ - lib/qr_forge/components/eye_outer/circle.rb
112
+ - lib/qr_forge/components/eye_outer/square.rb
113
+ - lib/qr_forge/components/forge_component.rb
114
+ - lib/qr_forge/components/module/circle.rb
115
+ - lib/qr_forge/components/module/square.rb
116
+ - lib/qr_forge/exporter.rb
117
+ - lib/qr_forge/forge.rb
118
+ - lib/qr_forge/layout.rb
119
+ - lib/qr_forge/qr_data.rb
120
+ - lib/qr_forge/renderer.rb
121
+ - lib/qr_forge/version.rb
122
+ homepage: https://github.com/keegankb93/qr_forge
123
+ licenses:
124
+ - MIT
125
+ metadata:
126
+ homepage_uri: https://github.com/keegankb93/qr_forge
127
+ source_code_uri: https://github.com/keegankb93/qr_forge
128
+ changelog_uri: https://github.com/keegankb93/qr_forge/blob/main/CHANGELOG.md
129
+ rdoc_options: []
130
+ require_paths:
131
+ - lib
132
+ required_ruby_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: 3.1.0
137
+ required_rubygems_version: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - ">="
140
+ - !ruby/object:Gem::Version
141
+ version: '0'
142
+ requirements: []
143
+ rubygems_version: 3.6.7
144
+ specification_version: 4
145
+ summary: QRForge is a Ruby gem for rendering QR codes with different designs and formats.
146
+ test_files: []