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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +20 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +12 -0
- data/lib/qr_forge/components/eye_inner/circle.rb +35 -0
- data/lib/qr_forge/components/eye_inner/square.rb +31 -0
- data/lib/qr_forge/components/eye_outer/circle.rb +35 -0
- data/lib/qr_forge/components/eye_outer/square.rb +37 -0
- data/lib/qr_forge/components/forge_component.rb +26 -0
- data/lib/qr_forge/components/module/circle.rb +25 -0
- data/lib/qr_forge/components/module/square.rb +26 -0
- data/lib/qr_forge/exporter.rb +38 -0
- data/lib/qr_forge/forge.rb +29 -0
- data/lib/qr_forge/layout.rb +175 -0
- data/lib/qr_forge/qr_data.rb +20 -0
- data/lib/qr_forge/renderer.rb +195 -0
- data/lib/qr_forge/version.rb +5 -0
- data/lib/qr_forge.rb +7 -0
- metadata +146 -0
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
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
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,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
|
data/lib/qr_forge.rb
ADDED
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: []
|