From 70a2a002c98f7a4a6df30db8205ead7ff7b7586e Mon Sep 17 00:00:00 2001 From: Duncan Robertson Date: Tue, 9 Dec 2025 21:21:02 +0000 Subject: [PATCH 01/12] chore: Add AGENTS.md with development guide for AI agents --- AGENTS.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..73dfd78 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# RQRCode Development Guide for AI Agents + +## Build/Test/Lint Commands +- `bundle install` - Install dependencies +- `rake` - Run all specs and auto-fix linting (default task) +- `rake spec` - Run all specs +- `bundle exec rspec spec/path/to/file_spec.rb` - Run a single test file +- `bundle exec rspec spec/path/to/file_spec.rb:42` - Run single test at line 42 +- `rake standard` - Check code style +- `rake standard:fix` - Auto-fix code style issues +- `./bin/console` - Launch interactive console + +## Code Style (Standard RB) +- Follow [Standard Ruby](https://github.com/testdouble/standard) style guide (enforced via `standard` gem) +- Ruby version: >= 3.0.0 +- Always use `# frozen_string_literal: true` at the top of all Ruby files +- Use double quotes for strings +- Use snake_case for variables/methods, SCREAMING_SNAKE_CASE for constants +- 2-space indentation +- No trailing whitespace +- Module structure: `lib/rqrcode/` for implementation, `spec/rqrcode/` for tests + +## File Structure & Naming +- Implementation: `lib/rqrcode/.rb` or `lib/rqrcode//.rb` +- Tests: `spec/rqrcode/_spec.rb` (must end with `_spec.rb`) +- Export modules live in `lib/rqrcode/export/` (e.g., `svg.rb`, `png.rb`, `ansi.rb`) + +## Testing +- Use RSpec for all tests +- Test files require `spec_helper` at the top +- Use `describe` blocks for grouping related tests +- Use `it` blocks for individual test cases +- Tests should verify behavior, not implementation details + +## Dependencies +- Core QR generation: `rqrcode_core` gem (do not modify, separate project) +- PNG rendering: `chunky_png` gem +- This gem focuses on rendering QR codes from `rqrcode_core` data structures From d38cb21c065e4c95057cea271588d53bf1730c90 Mon Sep 17 00:00:00 2001 From: Duncan Robertson Date: Tue, 9 Dec 2025 21:21:36 +0000 Subject: [PATCH 02/12] chore: Add benchmark suite for export formats and memory usage - Add Rake tasks for running benchmarks - Add benchmark scripts for SVG, PNG, HTML, ANSI, and format comparison - Add benchmark_helper.rb for shared setup and result saving - Add benchmark/README.md with usage and baseline results - Update Gemfile.lock with benchmark dependencies - Ignore benchmark/results/ in .gitignore - Remove Ruby 3.0 from CI test matrix --- .github/workflows/ruby.yml | 2 +- .gitignore | 1 + Gemfile.lock | 6 ++ README.md | 4 + Rakefile | 35 +++++++ benchmark/README.md | 176 +++++++++++++++++++++++++++++++ benchmark/ansi_export.rb | 25 +++++ benchmark/benchmark_helper.rb | 183 +++++++++++++++++++++++++++++++++ benchmark/format_comparison.rb | 19 ++++ benchmark/html_export.rb | 25 +++++ benchmark/png_export.rb | 25 +++++ benchmark/svg_export.rb | 25 +++++ 12 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 benchmark/README.md create mode 100644 benchmark/ansi_export.rb create mode 100644 benchmark/benchmark_helper.rb create mode 100644 benchmark/format_comparison.rb create mode 100644 benchmark/html_export.rb create mode 100644 benchmark/png_export.rb create mode 100644 benchmark/svg_export.rb diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index c6697d1..b865103 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] - ruby: ["3.0", "3.1", "3.2", "3.3", "3.4"] + ruby: ["3.1", "3.2", "3.3", "3.4"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index 6b3358f..f03699b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ *.sublime-project *.sublime-workspace *.gem +/benchmark/results/ diff --git a/Gemfile.lock b/Gemfile.lock index 0209d16..6aa60f5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,11 +9,13 @@ GEM remote: https://rubygems.org/ specs: ast (2.4.2) + benchmark-ips (2.14.0) chunky_png (1.4.0) diff-lcs (1.5.1) json (2.7.2) language_server-protocol (3.17.0.3) lint_roller (1.1.0) + memory_profiler (1.1.0) parallel (1.26.3) parser (3.3.5.0) ast (~> 2.4.1) @@ -52,6 +54,7 @@ GEM rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (1.13.0) + stackprof (0.2.27) standard (1.41.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) @@ -72,10 +75,13 @@ PLATFORMS x86_64-linux DEPENDENCIES + benchmark-ips (~> 2.0) bundler (~> 2.0) + memory_profiler (~> 1.0) rake (~> 13.0) rqrcode! rspec (~> 3.5) + stackprof (~> 0.2) standard (~> 1.41) BUNDLED WITH diff --git a/README.md b/README.md index fa6c17b..89929e5 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,10 @@ $ rake standard # checks $ rake standard:fix # fixes ``` +## Benchmarks + +RQRCode includes comprehensive performance benchmarks for tracking export format performance over time. See the [benchmark README](benchmark/README.md) for details on running benchmarks, interpreting results, and current performance baselines. + ## Contributing I am not currently accepting any new renderers as the current `as_png`, `as_svg` and `as_ansi` work for most cases. If you need something different from what's available, the [`rqrcode_core`](https://github.com/whomwah/rqrcode_core) gem gives you access to all the QR Code information you will need so makes it simple to generate your own. diff --git a/Rakefile b/Rakefile index 7dca93e..a67821f 100644 --- a/Rakefile +++ b/Rakefile @@ -8,3 +8,38 @@ begin rescue LoadError # no standard/rspec available end + +# Benchmark tasks +namespace :benchmark do + desc "Run all benchmarks" + task :all do + %w[svg png html ansi format_comparison].each do |format| + Rake::Task["benchmark:#{format}"].invoke + end + end + + desc "Run SVG export benchmarks" + task :svg do + ruby "benchmark/svg_export.rb" + end + + desc "Run PNG export benchmarks" + task :png do + ruby "benchmark/png_export.rb" + end + + desc "Run HTML export benchmarks" + task :html do + ruby "benchmark/html_export.rb" + end + + desc "Run ANSI export benchmarks" + task :ansi do + ruby "benchmark/ansi_export.rb" + end + + desc "Run format comparison benchmarks" + task :format_comparison do + ruby "benchmark/format_comparison.rb" + end +end diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 0000000..b7ca348 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,176 @@ +# RQRCode Benchmarks + +This directory contains streamlined benchmarks for tracking RQRCode export performance over time. + +## Quick Start + +```bash +# Install dependencies +bundle install + +# Run specific format benchmarks +rake benchmark:svg +rake benchmark:png +rake benchmark:html +rake benchmark:ansi +rake benchmark:format_comparison + +# Run all benchmarks (takes ~1-2 minutes) +rake benchmark:all +``` + +## Results Storage + +**All benchmark results are automatically saved to `benchmark/results/` as JSON files.** + +Each run creates timestamped files with the format: +- `ips__YYYYMMDD_HHMMSS.json` - Performance data (iterations/sec, comparisons) +- `memory__YYYYMMDD_HHMMSS.json` - Memory allocation data + +This allows you to: +- Track performance changes over time +- Compare before/after optimization results +- Share baseline results with the team +- Generate summary reports comparing different runs + +**Note:** The `results/` directory is gitignored - results are local to your machine. + +## Available Benchmarks + +Each benchmark tests 3 QR code sizes (small, medium, large) with realistic data: + +### Format Comparison (`benchmark/format_comparison.rb`) +Compares all export formats head-to-head using a medium-sized QR code. +- **Key metric**: Which format is fastest overall? + +### SVG Export (`benchmark/svg_export.rb`) +Tests SVG path mode (most common use case). +- **Key metric**: Performance across different QR sizes + +### PNG Export (`benchmark/png_export.rb`) +Tests PNG with default sizing (most common use case). +- **Key metric**: Performance across different QR sizes + +### HTML Export (`benchmark/html_export.rb`) +Tests HTML table export. +- **Key metric**: Performance across different QR sizes + +### ANSI Export (`benchmark/ansi_export.rb`) +Tests ANSI terminal output. +- **Key metric**: Performance across different QR sizes + +## Understanding the JSON Output + +Example IPS result: +```json +{ + "label": "All Export Formats", + "timestamp": "2025-12-09T19:49:35+00:00", + "ruby_version": "3.3.4", + "results": { + "svg": { + "iterations_per_second": 186.71, + "standard_deviation": 0.54, + "samples": 18, + "comparison": 7.28 + }, + "ansi": { + "iterations_per_second": 1359.48, + "standard_deviation": 0.22, + "samples": 135, + "comparison": 1.0 + } + } +} +``` + +- `iterations_per_second`: Higher is better +- `standard_deviation`: Lower is more consistent +- `comparison`: Multiplier vs fastest (1.0x = fastest) + +## Test Data + +Benchmarks use 3 representative QR code sizes: +- **small**: GitHub URL (~40 chars, typical use case) +- **medium**: Lorem ipsum sentence (~100 chars) +- **large**: 500 characters (stress test) + +## Interpreting Results + +### What to track over time: +1. **Iterations per second**: Is performance improving or degrading? +2. **Relative comparisons**: How do formats compare to each other? +3. **Memory allocations**: Are we creating fewer objects? + +### Current Performance Baselines (Apple M-series, Ruby 3.3.4) +For medium-sized QR codes: +- ANSI: ~1350 i/s (fastest) +- PNG: ~860 i/s +- HTML: ~650 i/s +- SVG: ~190 i/s + +## Latest Benchmark Results + +**Last Updated: 2025-12-09 20:00:37 UTC** +**Ruby Version: 3.3.4** + +### Format Comparison (Medium QR Code) + +| Format | Iterations/sec | Std Dev | Samples | Slowdown vs Fastest | +|--------|----------------|---------|---------|---------------------| +| ANSI | 1,367.46 | 0.51% | 136 | 1.00x (baseline) | +| PNG | 865.86 | 0.92% | 87 | 1.58x | +| HTML | 653.38 | 0.31% | 65 | 2.09x | +| SVG | 183.67 | 1.09% | 18 | 7.45x | + +### Performance by QR Code Size + +#### SVG Export +| Size | Iterations/sec | Std Dev | Slowdown vs Small | +|--------|----------------|---------|-------------------| +| Small | 547.83 | 0.37% | 1.00x (baseline) | +| Medium | 188.04 | 0.53% | 2.91x | +| Large | 47.47 | 4.21% | 11.54x | + +#### PNG Export +| Size | Iterations/sec | Std Dev | Slowdown vs Small | +|--------|----------------|---------|-------------------| +| Small | 1,183.86 | 0.59% | 1.00x (baseline) | +| Medium | 857.01 | 1.75% | 1.38x | +| Large | 303.14 | 0.66% | 3.91x | + +#### HTML Export +| Size | Iterations/sec | Std Dev | Slowdown vs Small | +|--------|----------------|---------|-------------------| +| Small | 1,914.90 | 1.04% | 1.00x (baseline) | +| Medium | 659.26 | 0.30% | 2.90x | +| Large | 228.95 | 1.75% | 8.36x | + +#### ANSI Export +| Size | Iterations/sec | Std Dev | Slowdown vs Small | +|--------|----------------|---------|-------------------| +| Small | 3,954.51 | 1.06% | 1.00x (baseline) | +| Medium | 1,359.47 | 1.47% | 2.91x | +| Large | 478.66 | 1.88% | 8.26x | + +### Memory Allocations + +| Format | Total Objects Allocated | Total Memory (MB) | +|--------|-------------------------|-------------------| +| ANSI | 40,701 | 16.2 | +| PNG | 357,676 | 23.1 | +| HTML | 1,441,201 | 163.3 | +| SVG | 7,432,001 | 375.7 | + +**Key Insights:** +- ANSI is the fastest format (1,367 i/s) and most memory-efficient (40.7k objects) +- SVG is the slowest format (183 i/s) and most memory-intensive (7.4M objects) +- All formats show 3-12x performance degradation as QR size increases +- Memory usage varies dramatically: ANSI uses 23x less memory than SVG + +## Notes + +- Focus on relative comparisons, not absolute numbers +- Results vary by system (CPU, Ruby version) +- Run benchmarks before and after making changes +- Full suite runs in ~1-2 minutes diff --git a/benchmark/ansi_export.rb b/benchmark/ansi_export.rb new file mode 100644 index 0000000..3ad92e9 --- /dev/null +++ b/benchmark/ansi_export.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative "benchmark_helper" + +BenchmarkHelper.section "ANSI Export Benchmarks" + +# IPS Benchmark - ANSI Export +BenchmarkHelper.run_ips("ANSI Export") do |x, qrcodes| + x.report("ansi_small") { qrcodes[:small].as_ansi } + x.report("ansi_medium") { qrcodes[:medium].as_ansi } + x.report("ansi_large") { qrcodes[:large].as_ansi } + + x.compare! +end + +# Memory Profile - ANSI Export +BenchmarkHelper.run_memory("ANSI Export") do |qrcodes| + 50.times do + qrcodes[:small].as_ansi + qrcodes[:medium].as_ansi + qrcodes[:large].as_ansi + end +end + +puts "\nāœ“ ANSI benchmarks complete" diff --git a/benchmark/benchmark_helper.rb b/benchmark/benchmark_helper.rb new file mode 100644 index 0000000..0a7c140 --- /dev/null +++ b/benchmark/benchmark_helper.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "rqrcode" +require "benchmark/ips" +require "memory_profiler" +require "stackprof" +require "json" +require "fileutils" +require "time" + +module BenchmarkHelper + # Test data of varying sizes + QR_DATA = { + tiny: "Hi", + small: "https://github.com/whomwah/rqrcode", + medium: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.", + large: "A" * 500, + xlarge: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * 20 + }.freeze + + # Create QR codes for benchmarking + def self.qrcodes + @qrcodes ||= QR_DATA.transform_values { |data| RQRCode::QRCode.new(data) } + end + + # Get results directory + def self.results_dir + @results_dir ||= File.join(__dir__, "results") + end + + # Ensure results directory exists + def self.ensure_results_dir + FileUtils.mkdir_p(results_dir) unless Dir.exist?(results_dir) + end + + # Get timestamp for filename + def self.timestamp + @timestamp ||= Time.now.strftime("%Y%m%d_%H%M%S") + end + + # Save results to JSON + def self.save_results(name, data) + ensure_results_dir + filename = File.join(results_dir, "#{name}_#{timestamp}.json") + File.write(filename, JSON.pretty_generate(data)) + puts "\nšŸ’¾ Results saved to: #{filename}" + end + + # Run IPS benchmark + def self.run_ips(label, warmup: 2, time: 5, &block) + puts "\n" + "=" * 80 + puts "IPS Benchmark: #{label}" + puts "=" * 80 + + results = {} + report = Benchmark.ips do |x| + x.config(warmup: warmup, time: time) + block.call(x, qrcodes) + x.compare! + end + + # Extract actual metrics from the report + report.entries.each do |entry| + results[entry.label] = { + iterations_per_second: entry.stats.central_tendency.round(2), + standard_deviation: entry.stats.error_percentage.round(2), + samples: entry.measurement_cycle + } + end + + # Calculate comparison ratios (fastest = 1.0x) + if results.any? + fastest_ips = results.values.map { |r| r[:iterations_per_second] }.max + results.each do |_label, data| + data[:comparison] = (fastest_ips / data[:iterations_per_second]).round(2) + end + end + + # Save results with actual metrics + save_results( + "ips_#{label.downcase.gsub(/\s+/, "_")}", + { + label: label, + timestamp: Time.now.iso8601, + ruby_version: RUBY_VERSION, + results: results + } + ) + + report + end + + # Run memory profiler + def self.run_memory(label, &block) + puts "\n" + "=" * 80 + puts "Memory Profile: #{label}" + puts "=" * 80 + + report = MemoryProfiler.report do + block.call(qrcodes) + end + + report.pretty_print(scale_bytes: true, normalize_paths: true) + + # Save memory results + memory_data = { + label: label, + timestamp: Time.now.iso8601, + ruby_version: RUBY_VERSION, + total_allocated_memsize: report.total_allocated_memsize, + total_retained_memsize: report.total_retained_memsize, + total_allocated: report.total_allocated, + total_retained: report.total_retained + } + + save_results("memory_#{label.downcase.gsub(/\s+/, "_")}", memory_data) + + report + end + + # Run stack profiler + def self.run_stackprof(label, mode: :cpu, &block) + puts "\n" + "=" * 80 + puts "Stack Profile: #{label} (#{mode} mode)" + puts "=" * 80 + + profile = StackProf.run(mode: mode, interval: 1000) do + block.call(qrcodes) + end + + StackProf::Report.new(profile).print_text(limit: 20) + + # Save stackprof results + stackprof_data = { + label: label, + timestamp: Time.now.iso8601, + ruby_version: RUBY_VERSION, + mode: mode, + samples: profile[:samples], + frames: profile[:frames].map do |_frame_id, frame_data| + { + name: frame_data[:name], + total_samples: frame_data[:samples], + file: frame_data[:file], + line: frame_data[:line] + } + end.sort_by { |f| -f[:total_samples] }.first(20) + } + + save_results("stackprof_#{label.downcase.gsub(/\s+/, "_")}", stackprof_data) + + profile + end + + # Convenience method to run all profiling types + def self.profile_all(label, &block) + run_ips(label, &block) + run_memory(label, &block) + run_stackprof(label, &block) + end + + # Helper to print section header + def self.section(title) + puts "\n\n" + puts "#" * 80 + puts "# #{title}" + puts "#" * 80 + puts "Timestamp: #{timestamp}" + puts "Ruby Version: #{RUBY_VERSION}" + end + + # Helper to format bytes + def self.format_bytes(bytes) + if bytes < 1024 + "#{bytes} B" + elsif bytes < 1024 * 1024 + "#{(bytes / 1024.0).round(2)} KB" + else + "#{(bytes / (1024.0 * 1024)).round(2)} MB" + end + end +end diff --git a/benchmark/format_comparison.rb b/benchmark/format_comparison.rb new file mode 100644 index 0000000..66cfc1f --- /dev/null +++ b/benchmark/format_comparison.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require_relative "benchmark_helper" + +BenchmarkHelper.section "Format Comparison Benchmarks" + +# IPS Benchmark - Compare all export formats (medium QR code) +BenchmarkHelper.run_ips("All Export Formats") do |x, qrcodes| + qr = qrcodes[:medium] + + x.report("svg") { qr.as_svg(use_path: true) } + x.report("png") { qr.as_png } + x.report("html") { qr.as_html } + x.report("ansi") { qr.as_ansi } + + x.compare! +end + +puts "\nāœ“ Format comparison benchmarks complete" diff --git a/benchmark/html_export.rb b/benchmark/html_export.rb new file mode 100644 index 0000000..6a5df20 --- /dev/null +++ b/benchmark/html_export.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative "benchmark_helper" + +BenchmarkHelper.section "HTML Export Benchmarks" + +# IPS Benchmark - HTML Export +BenchmarkHelper.run_ips("HTML Export") do |x, qrcodes| + x.report("html_small") { qrcodes[:small].as_html } + x.report("html_medium") { qrcodes[:medium].as_html } + x.report("html_large") { qrcodes[:large].as_html } + + x.compare! +end + +# Memory Profile - HTML Export +BenchmarkHelper.run_memory("HTML Export") do |qrcodes| + 50.times do + qrcodes[:small].as_html + qrcodes[:medium].as_html + qrcodes[:large].as_html + end +end + +puts "\nāœ“ HTML benchmarks complete" diff --git a/benchmark/png_export.rb b/benchmark/png_export.rb new file mode 100644 index 0000000..cb1b8f4 --- /dev/null +++ b/benchmark/png_export.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative "benchmark_helper" + +BenchmarkHelper.section "PNG Export Benchmarks" + +# IPS Benchmark - PNG Export (default sizing, most common use case) +BenchmarkHelper.run_ips("PNG Export") do |x, qrcodes| + x.report("png_small") { qrcodes[:small].as_png } + x.report("png_medium") { qrcodes[:medium].as_png } + x.report("png_large") { qrcodes[:large].as_png } + + x.compare! +end + +# Memory Profile - PNG Export +BenchmarkHelper.run_memory("PNG Export") do |qrcodes| + 25.times do + qrcodes[:small].as_png + qrcodes[:medium].as_png + qrcodes[:large].as_png + end +end + +puts "\nāœ“ PNG benchmarks complete" diff --git a/benchmark/svg_export.rb b/benchmark/svg_export.rb new file mode 100644 index 0000000..4a0d636 --- /dev/null +++ b/benchmark/svg_export.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative "benchmark_helper" + +BenchmarkHelper.section "SVG Export Benchmarks" + +# IPS Benchmark - SVG Export (path mode, most common use case) +BenchmarkHelper.run_ips("SVG Export") do |x, qrcodes| + x.report("svg_small") { qrcodes[:small].as_svg(use_path: true) } + x.report("svg_medium") { qrcodes[:medium].as_svg(use_path: true) } + x.report("svg_large") { qrcodes[:large].as_svg(use_path: true) } + + x.compare! +end + +# Memory Profile - SVG Export +BenchmarkHelper.run_memory("SVG Export") do |qrcodes| + 50.times do + qrcodes[:small].as_svg(use_path: true) + qrcodes[:medium].as_svg(use_path: true) + qrcodes[:large].as_svg(use_path: true) + end +end + +puts "\nāœ“ SVG benchmarks complete" From 94505db5a8dd5d5e1433ea024a66a4205e88c10f Mon Sep 17 00:00:00 2001 From: Duncan Robertson Date: Tue, 9 Dec 2025 21:21:47 +0000 Subject: [PATCH 03/12] chore: Update gemspec file exclusions and dev dependencies --- rqrcode.gemspec | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/rqrcode.gemspec b/rqrcode.gemspec index 6a47279..4799aca 100644 --- a/rqrcode.gemspec +++ b/rqrcode.gemspec @@ -1,4 +1,4 @@ -lib = File.expand_path("../lib", __FILE__) +lib = File.expand_path("lib", __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require "rqrcode/version" @@ -22,18 +22,27 @@ Gem::Specification.new do |spec| "changelog_uri" => "https://github.com/whomwah/rqrcode/blob/main/CHANGELOG.md" } - spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do - `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject do |f| + f.match(%r{^(test|spec|features|benchmark|images|bin)/}) || # exclude test/dev directories + f.match(/\.(rspec|standard\.yml|gitignore|DS_Store)$/) || # exclude config files + f.match(/^_config\.yml$/) || # exclude Jekyll config + f.match(%r{^\.github/}) || # exclude GitHub configs + f.match(/(AGENTS|OPTIMIZATIONS)\.md$/) # exclude dev documentation + end end spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] spec.required_ruby_version = ">= 3.0" - spec.add_dependency "rqrcode_core", "~> 2.0" spec.add_dependency "chunky_png", "~> 1.0" + spec.add_dependency "rqrcode_core", "~> 2.0" + spec.add_development_dependency "benchmark-ips", "~> 2.0" spec.add_development_dependency "bundler", "~> 2.0" + spec.add_development_dependency "memory_profiler", "~> 1.0" spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rspec", "~> 3.5" + spec.add_development_dependency "stackprof", "~> 0.2" spec.add_development_dependency "standard", "~> 1.41" end From 40bb0f617dca87f9088a70f6521c307aa82a41e7 Mon Sep 17 00:00:00 2001 From: Duncan Robertson Date: Tue, 9 Dec 2025 21:54:37 +0000 Subject: [PATCH 04/12] chore: Update SVG export for clarity and minor performance improvements --- benchmark/README.md | 38 +++++++++++++++++++------------------- lib/rqrcode/export/svg.rb | 31 +++++++++++++++++-------------- 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/benchmark/README.md b/benchmark/README.md index b7ca348..0265c0f 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -111,47 +111,47 @@ For medium-sized QR codes: ## Latest Benchmark Results -**Last Updated: 2025-12-09 20:00:37 UTC** +**Last Updated: 2025-12-09 21:35:23 UTC** **Ruby Version: 3.3.4** ### Format Comparison (Medium QR Code) | Format | Iterations/sec | Std Dev | Samples | Slowdown vs Fastest | |--------|----------------|---------|---------|---------------------| -| ANSI | 1,367.46 | 0.51% | 136 | 1.00x (baseline) | -| PNG | 865.86 | 0.92% | 87 | 1.58x | -| HTML | 653.38 | 0.31% | 65 | 2.09x | -| SVG | 183.67 | 1.09% | 18 | 7.45x | +| ANSI | 1,357.88 | 0.29% | 134 | 1.00x (baseline) | +| PNG | 860.84 | 0.70% | 84 | 1.58x | +| HTML | 657.21 | 0.46% | 65 | 2.07x | +| SVG | 183.97 | 0.54% | 17 | 7.38x | ### Performance by QR Code Size #### SVG Export | Size | Iterations/sec | Std Dev | Slowdown vs Small | |--------|----------------|---------|-------------------| -| Small | 547.83 | 0.37% | 1.00x (baseline) | -| Medium | 188.04 | 0.53% | 2.91x | -| Large | 47.47 | 4.21% | 11.54x | +| Small | 541.56 | 0.55% | 1.00x (baseline) | +| Medium | 186.28 | 0.54% | 2.91x | +| Large | 45.57 | 4.39% | 11.88x | #### PNG Export | Size | Iterations/sec | Std Dev | Slowdown vs Small | |--------|----------------|---------|-------------------| -| Small | 1,183.86 | 0.59% | 1.00x (baseline) | -| Medium | 857.01 | 1.75% | 1.38x | -| Large | 303.14 | 0.66% | 3.91x | +| Small | 1,169.96 | 1.71% | 1.00x (baseline) | +| Medium | 855.79 | 1.29% | 1.37x | +| Large | 297.02 | 1.35% | 3.94x | #### HTML Export | Size | Iterations/sec | Std Dev | Slowdown vs Small | |--------|----------------|---------|-------------------| -| Small | 1,914.90 | 1.04% | 1.00x (baseline) | -| Medium | 659.26 | 0.30% | 2.90x | -| Large | 228.95 | 1.75% | 8.36x | +| Small | 1,858.17 | 1.18% | 1.00x (baseline) | +| Medium | 641.96 | 1.09% | 2.89x | +| Large | 222.92 | 1.79% | 8.34x | #### ANSI Export | Size | Iterations/sec | Std Dev | Slowdown vs Small | |--------|----------------|---------|-------------------| -| Small | 3,954.51 | 1.06% | 1.00x (baseline) | -| Medium | 1,359.47 | 1.47% | 2.91x | -| Large | 478.66 | 1.88% | 8.26x | +| Small | 3,884.06 | 1.08% | 1.00x (baseline) | +| Medium | 1,349.69 | 0.74% | 2.88x | +| Large | 474.02 | 0.84% | 8.19x | ### Memory Allocations @@ -163,8 +163,8 @@ For medium-sized QR codes: | SVG | 7,432,001 | 375.7 | **Key Insights:** -- ANSI is the fastest format (1,367 i/s) and most memory-efficient (40.7k objects) -- SVG is the slowest format (183 i/s) and most memory-intensive (7.4M objects) +- ANSI is the fastest format (1,358 i/s) and most memory-efficient (40.7k objects) +- SVG is the slowest format (184 i/s) and most memory-intensive (7.4M objects) - All formats show 3-12x performance degradation as QR size increases - Memory usage varies dramatically: ANSI uses 23x less memory than SVG diff --git a/lib/rqrcode/export/svg.rb b/lib/rqrcode/export/svg.rb index 748a404..5a4f8fe 100644 --- a/lib/rqrcode/export/svg.rb +++ b/lib/rqrcode/export/svg.rb @@ -73,15 +73,21 @@ def build(module_size, options = {}) end first_edge = edge_loop.first - edge_loop_string = SVG_PATH_COMMANDS[:move] - edge_loop_string += "#{first_edge.start_x} #{first_edge.start_y}" - - edge_loop.chunk(&:direction).to_a[0...-1].each do |direction, edges| - edge_loop_string << "#{SVG_PATH_COMMANDS[direction]}#{edges.length}" + edge_loop_parts = [ + SVG_PATH_COMMANDS[:move], + first_edge.start_x.to_s, + " ", + first_edge.start_y.to_s + ] + + chunked = edge_loop.chunk(&:direction).to_a + chunked[0...-1].each do |direction, edges| + edge_loop_parts << SVG_PATH_COMMANDS[direction] + edge_loop_parts << edges.length.to_s end - edge_loop_string << SVG_PATH_COMMANDS[:close] + edge_loop_parts << SVG_PATH_COMMANDS[:close] - path << edge_loop_string + path << edge_loop_parts.join end @result << %{} @@ -99,16 +105,13 @@ def build(module_size, options = {}) color = "##{color}" unless color.is_a?(Symbol) @qrcode.modules.each_index do |c| - tmp = [] @qrcode.modules.each_index do |r| + next unless @qrcode.checked?(c, r) + x = r * module_size + offset_x y = c * module_size + offset_y - - next unless @qrcode.checked?(c, r) - tmp << %() + @result << %() end - - @result << tmp.join end end end @@ -224,4 +227,4 @@ def as_svg(options = {}) end end -RQRCode::QRCode.send :include, RQRCode::Export::SVG +RQRCode::QRCode.include RQRCode::Export::SVG From 512b85095acd5e5350531bb873f096b2bdf497c9 Mon Sep 17 00:00:00 2001 From: Duncan Robertson Date: Wed, 17 Dec 2025 22:07:45 +0000 Subject: [PATCH 05/12] chore: Add end-to-end benchmarks to measure QR generation plus export - Introduce run_ips_e2e for full workflow timing (generation + export) - Update all export benchmarks to run both end-to-end and rendering-only modes - Expand README with detailed explanation of benchmark types and output - Clarify benchmark output and add summary messages for each script --- Gemfile | 2 + benchmark/README.md | 227 +++++++++++++++++++++++---------- benchmark/ansi_export.rb | 13 +- benchmark/benchmark_helper.rb | 55 +++++++- benchmark/format_comparison.rb | 16 ++- benchmark/html_export.rb | 13 +- benchmark/png_export.rb | 13 +- benchmark/svg_export.rb | 13 +- 8 files changed, 274 insertions(+), 78 deletions(-) diff --git a/Gemfile b/Gemfile index 3dededc..6de6ebd 100644 --- a/Gemfile +++ b/Gemfile @@ -2,3 +2,5 @@ source "https://rubygems.org" # Specify your gem's dependencies in rqrcode-base.gemspec gemspec + +#gem "rqrcode_core", path: "../rqrcode_core" diff --git a/benchmark/README.md b/benchmark/README.md index 0265c0f..884bfe4 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -1,6 +1,6 @@ # RQRCode Benchmarks -This directory contains streamlined benchmarks for tracking RQRCode export performance over time. +This directory contains benchmarks for tracking RQRCode performance over time, measuring both **end-to-end workflows** (generation + export) and **rendering-only** performance. ## Quick Start @@ -29,39 +29,89 @@ Each run creates timestamped files with the format: This allows you to: - Track performance changes over time -- Compare before/after optimization results +- Compare before/after optimisation results - Share baseline results with the team - Generate summary reports comparing different runs **Note:** The `results/` directory is gitignored - results are local to your machine. +## Benchmark Types + +All benchmarks run **two modes**: + +### 1. End-to-end (PRIMARY METRIC) +Measures the complete user workflow: `RQRCode::QRCode.new(data).as_svg` +- **What it shows**: Total time users experience, including rqrcode_core generation +- **Why it matters**: This is what users actually do - reflects real-world performance +- **When to use**: Track overall improvements, compare formats for actual usage +- **File naming**: `ips_e2e_*_YYYYMMDD_HHMMSS.json` + +### 2. Rendering-only (DIAGNOSTIC METRIC) +Measures only export performance using pre-generated QR codes +- **What it shows**: Export format performance in isolation +- **Why it matters**: Helps identify which export method needs optimisation +- **When to use**: When optimising export code, isolating rendering bottlenecks +- **File naming**: `ips_*_YYYYMMDD_HHMMSS.json` + +**Key Insight**: End-to-end benchmarks often show QR generation is the bottleneck (all formats perform similarly), while rendering-only benchmarks reveal significant differences between export formats (SVG can be 7-8x slower than ANSI). + ## Available Benchmarks -Each benchmark tests 3 QR code sizes (small, medium, large) with realistic data: +Each benchmark tests 3 QR code sizes (small, medium, large) with realistic data and runs both end-to-end and rendering-only modes: ### Format Comparison (`benchmark/format_comparison.rb`) Compares all export formats head-to-head using a medium-sized QR code. -- **Key metric**: Which format is fastest overall? +- **End-to-end metric**: Which format is fastest for users? (Usually ~same due to generation overhead) +- **Rendering-only metric**: Which export format is most efficient? ### SVG Export (`benchmark/svg_export.rb`) -Tests SVG path mode (most common use case). -- **Key metric**: Performance across different QR sizes +Tests SVG path mode (most common use case) across different QR sizes. +- **End-to-end metric**: Total time for generation + SVG export +- **Rendering-only metric**: SVG rendering performance in isolation ### PNG Export (`benchmark/png_export.rb`) -Tests PNG with default sizing (most common use case). -- **Key metric**: Performance across different QR sizes +Tests PNG with default sizing (most common use case) across different QR sizes. +- **End-to-end metric**: Total time for generation + PNG export +- **Rendering-only metric**: PNG rendering performance in isolation ### HTML Export (`benchmark/html_export.rb`) -Tests HTML table export. -- **Key metric**: Performance across different QR sizes +Tests HTML table export across different QR sizes. +- **End-to-end metric**: Total time for generation + HTML export +- **Rendering-only metric**: HTML rendering performance in isolation ### ANSI Export (`benchmark/ansi_export.rb`) -Tests ANSI terminal output. -- **Key metric**: Performance across different QR sizes +Tests ANSI terminal output across different QR sizes. +- **End-to-end metric**: Total time for generation + ANSI export +- **Rendering-only metric**: ANSI rendering performance in isolation ## Understanding the JSON Output -Example IPS result: +### End-to-end Results +Files named `ips_e2e_*` contain generation + export times: +```json +{ + "label": "All Export Formats (end-to-end)", + "timestamp": "2025-12-17T21:46:51+00:00", + "ruby_version": "3.3.4", + "results": { + "svg": { + "iterations_per_second": 16.71, + "standard_deviation": 0.00, + "samples": 84, + "comparison": 1.09 + }, + "ansi": { + "iterations_per_second": 18.13, + "standard_deviation": 5.50, + "samples": 91, + "comparison": 1.0 + } + } +} +``` + +### Rendering-only Results +Files named `ips_*` (without `e2e`) contain export-only times: ```json { "label": "All Export Formats", @@ -84,9 +134,11 @@ Example IPS result: } ``` -- `iterations_per_second`: Higher is better -- `standard_deviation`: Lower is more consistent -- `comparison`: Multiplier vs fastest (1.0x = fastest) +**Common fields:** +- `iterations_per_second`: Higher is better (more iterations completed per second) +- `standard_deviation`: Lower is more consistent (percent variation) +- `comparison`: Multiplier vs fastest (1.0x = fastest, higher = slower) +- `samples`: Number of iterations run for measurement ## Test Data @@ -98,79 +150,114 @@ Benchmarks use 3 representative QR code sizes: ## Interpreting Results ### What to track over time: -1. **Iterations per second**: Is performance improving or degrading? -2. **Relative comparisons**: How do formats compare to each other? -3. **Memory allocations**: Are we creating fewer objects? +1. **End-to-end i/s**: Are users getting faster overall? (Includes rqrcode_core improvements) +2. **Rendering-only i/s**: Are export methods getting more efficient? +3. **Relative comparisons**: How do formats compare to each other in both modes? +4. **Memory allocations**: Are we creating fewer objects? + +### Interpreting Performance Changes + +**When end-to-end improves but rendering-only doesn't:** +- Improvements came from rqrcode_core (QR generation algorithm) +- Export methods haven't changed + +**When rendering-only improves but end-to-end shows modest gains:** +- Export methods got faster, but generation time dominates +- For small improvements, generation overhead masks rendering gains -### Current Performance Baselines (Apple M-series, Ruby 3.3.4) -For medium-sized QR codes: -- ANSI: ~1350 i/s (fastest) -- PNG: ~860 i/s -- HTML: ~650 i/s -- SVG: ~190 i/s +**When both improve proportionally:** +- Changes benefited the whole pipeline ## Latest Benchmark Results -**Last Updated: 2025-12-09 21:35:23 UTC** +**Last Updated: 2025-12-17 21:59:00 UTC** **Ruby Version: 3.3.4** +**Platform: Apple M-series** +**rqrcode_core version: 2.0.1 (feat/performance101 branch with 80-90% generation improvements)** -### Format Comparison (Medium QR Code) +### Quick Reference Baselines -| Format | Iterations/sec | Std Dev | Samples | Slowdown vs Fastest | -|--------|----------------|---------|---------|---------------------| -| ANSI | 1,357.88 | 0.29% | 134 | 1.00x (baseline) | -| PNG | 860.84 | 0.70% | 84 | 1.58x | -| HTML | 657.21 | 0.46% | 65 | 2.07x | -| SVG | 183.97 | 0.54% | 17 | 7.38x | +#### End-to-end (Generation + Export) - Medium QR Code +*User-facing performance - what matters for real-world usage* -### Performance by QR Code Size +| Format | Iterations/sec | Std Dev | Samples | Slowdown vs Fastest | vs Previous | +|--------|----------------|---------|---------|---------------------|-------------| +| ANSI | 33.0 | 0.00% | 165 | 1.00x (baseline) | +82% šŸš€ | +| PNG | 32.5 | 0.00% | 165 | 1.01x (same-ish) | +80% šŸš€ | +| HTML | 32.2 | 0.00% | 162 | 1.02x (same-ish) | +79% šŸš€ | +| SVG | 28.3 | 0.00% | 142 | 1.17x | +69% šŸš€ | + +**Key Insight**: All formats perform similarly (~28-33 i/s) because QR generation dominates the time. The 69-82% improvement across all formats reflects the rqrcode_core optimisation. Format choice has minimal impact on end-to-end performance. + +#### Rendering-only (Export Performance) - Medium QR Code +*Diagnostic metric - shows export efficiency in isolation* -#### SVG Export -| Size | Iterations/sec | Std Dev | Slowdown vs Small | -|--------|----------------|---------|-------------------| -| Small | 541.56 | 0.55% | 1.00x (baseline) | -| Medium | 186.28 | 0.54% | 2.91x | -| Large | 45.57 | 4.39% | 11.88x | - -#### PNG Export -| Size | Iterations/sec | Std Dev | Slowdown vs Small | -|--------|----------------|---------|-------------------| -| Small | 1,169.96 | 1.71% | 1.00x (baseline) | -| Medium | 855.79 | 1.29% | 1.37x | -| Large | 297.02 | 1.35% | 3.94x | - -#### HTML Export -| Size | Iterations/sec | Std Dev | Slowdown vs Small | -|--------|----------------|---------|-------------------| -| Small | 1,858.17 | 1.18% | 1.00x (baseline) | -| Medium | 641.96 | 1.09% | 2.89x | -| Large | 222.92 | 1.79% | 8.34x | - -#### ANSI Export -| Size | Iterations/sec | Std Dev | Slowdown vs Small | -|--------|----------------|---------|-------------------| -| Small | 3,884.06 | 1.08% | 1.00x (baseline) | -| Medium | 1,349.69 | 0.74% | 2.88x | -| Large | 474.02 | 0.84% | 8.19x | +| Format | Iterations/sec | Std Dev | Samples | Slowdown vs Fastest | vs Previous | +|--------|----------------|---------|---------|---------------------|-------------| +| ANSI | 1,315 | 0.50% | 6,603 | 1.00x (baseline) | āœ… stable | +| PNG | 823 | 3.40% | 4,150 | 1.60x | āœ… stable | +| HTML | 616 | 1.30% | 3,087 | 2.14x | āœ… stable | +| SVG | 171 | 4.10% | 867 | 7.67x | āœ… stable | + +**Key Insight**: Export format differences are dramatic when isolated. SVG rendering is 7.7x slower than ANSI, indicating optimisation opportunities. Rendering-only performance remained stable (as expected) while end-to-end improved dramatically. + +### Performance by QR Code Size +*Note: Higher iterations/sec is better; lower std dev is better; lower slowdown is better* + +#### SVG Export (End-to-end) +| Size | Iterations/sec | Std Dev | Slowdown vs Small | vs Previous | +|--------|----------------|---------|-------------------|-------------| +| Small | 90.2 | 0.00% | 1.00x (baseline) | +71% šŸš€ | +| Medium | 28.6 | 0.00% | 3.16x | +69% šŸš€ | +| Large | 9.3 | 0.00% | 9.65x | +79% šŸš€ | + +#### PNG Export (End-to-end) +| Size | Iterations/sec | Std Dev | Slowdown vs Small | vs Previous | +|--------|----------------|---------|-------------------|-------------| +| Small | 98.3 | 0.00% | 1.00x (baseline) | +72% šŸš€ | +| Medium | 32.1 | 0.00% | 3.07x | +80% šŸš€ | +| Large | 11.0 | 0.00% | 8.95x | +79% šŸš€ | + +#### HTML Export (End-to-end) +| Size | Iterations/sec | Std Dev | Slowdown vs Small | vs Previous | +|--------|----------------|---------|-------------------|-------------| +| Small | 102.0 | 0.00% | 1.00x (baseline) | +77% šŸš€ | +| Medium | 31.8 | 0.00% | 3.21x | +79% šŸš€ | +| Large | 10.8 | 0.00% | 9.42x | +80% šŸš€ | + +#### ANSI Export (End-to-end) +| Size | Iterations/sec | Std Dev | Slowdown vs Small | vs Previous | +|--------|----------------|---------|-------------------|-------------| +| Small | 105.6 | 0.00% | 1.00x (baseline) | +87% šŸš€ | +| Medium | 32.9 | 0.00% | 3.21x | +82% šŸš€ | +| Large | 11.2 | 0.00% | 9.43x | +80% šŸš€ | ### Memory Allocations +*Note: Lower is better for both metrics* | Format | Total Objects Allocated | Total Memory (MB) | |--------|-------------------------|-------------------| | ANSI | 40,701 | 16.2 | -| PNG | 357,676 | 23.1 | -| HTML | 1,441,201 | 163.3 | -| SVG | 7,432,001 | 375.7 | +| PNG | 357,676 | 22.0 | +| HTML | 1,441,201 | 155.8 | +| SVG | 7,443,651 | 374.4 | **Key Insights:** -- ANSI is the fastest format (1,358 i/s) and most memory-efficient (40.7k objects) -- SVG is the slowest format (184 i/s) and most memory-intensive (7.4M objects) -- All formats show 3-12x performance degradation as QR size increases +- **Major improvement**: rqrcode_core optimisations delivered 69-87% faster end-to-end performance across all formats! šŸŽ‰ +- **End-to-end**: QR generation is the bottleneck - format choice barely matters (~28-33 i/s for all) +- **Rendering-only**: ANSI is fastest (1,315 i/s), SVG is slowest (171 i/s) and most memory-intensive +- **Validation**: Rendering-only benchmarks remained stable, confirming improvements came from rqrcode_core +- **Optimisation priority**: Improvements to rqrcode_core have biggest impact on user experience (proven!) +- **Format choice**: For high-volume rendering, ANSI/PNG are significantly faster than SVG/HTML +- All formats show 3-10x performance degradation as QR size increases - Memory usage varies dramatically: ANSI uses 23x less memory than SVG ## Notes +- **Primary metric**: End-to-end results reflect real user experience +- **Secondary metric**: Rendering-only helps diagnose where to optimise - Focus on relative comparisons, not absolute numbers -- Results vary by system (CPU, Ruby version) -- Run benchmarks before and after making changes -- Full suite runs in ~1-2 minutes +- Results vary by system (CPU, Ruby version, rqrcode_core version) +- Run benchmarks before and after making changes to measure impact +- Full suite runs in ~2-3 minutes (doubled due to two modes per benchmark) +- When rqrcode_core updates, expect end-to-end improvements even without changes to this gem diff --git a/benchmark/ansi_export.rb b/benchmark/ansi_export.rb index 3ad92e9..dfab440 100644 --- a/benchmark/ansi_export.rb +++ b/benchmark/ansi_export.rb @@ -4,7 +4,16 @@ BenchmarkHelper.section "ANSI Export Benchmarks" -# IPS Benchmark - ANSI Export +# PRIMARY: End-to-end benchmark (generation + export) +BenchmarkHelper.run_ips_e2e("ANSI Export") do |x, qr_data| + x.report("ansi_small") { RQRCode::QRCode.new(qr_data[:small]).as_ansi } + x.report("ansi_medium") { RQRCode::QRCode.new(qr_data[:medium]).as_ansi } + x.report("ansi_large") { RQRCode::QRCode.new(qr_data[:large]).as_ansi } + + x.compare! +end + +# DIAGNOSTIC: Rendering-only benchmark (isolates export performance) BenchmarkHelper.run_ips("ANSI Export") do |x, qrcodes| x.report("ansi_small") { qrcodes[:small].as_ansi } x.report("ansi_medium") { qrcodes[:medium].as_ansi } @@ -23,3 +32,5 @@ end puts "\nāœ“ ANSI benchmarks complete" +puts " - End-to-end: Full user workflow (generation + export)" +puts " - Rendering-only: Export performance in isolation" diff --git a/benchmark/benchmark_helper.rb b/benchmark/benchmark_helper.rb index 0a7c140..754b82c 100644 --- a/benchmark/benchmark_helper.rb +++ b/benchmark/benchmark_helper.rb @@ -19,11 +19,16 @@ module BenchmarkHelper xlarge: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * 20 }.freeze - # Create QR codes for benchmarking + # Create QR codes for benchmarking (rendering-only benchmarks) def self.qrcodes @qrcodes ||= QR_DATA.transform_values { |data| RQRCode::QRCode.new(data) } end + # Get raw QR data for end-to-end benchmarks (generation + rendering) + def self.qr_data + QR_DATA + end + # Get results directory def self.results_dir @results_dir ||= File.join(__dir__, "results") @@ -47,10 +52,10 @@ def self.save_results(name, data) puts "\nšŸ’¾ Results saved to: #{filename}" end - # Run IPS benchmark + # Run IPS benchmark (rendering-only, uses pre-generated QR codes) def self.run_ips(label, warmup: 2, time: 5, &block) puts "\n" + "=" * 80 - puts "IPS Benchmark: #{label}" + puts "IPS Benchmark: #{label} (rendering-only)" puts "=" * 80 results = {} @@ -91,6 +96,50 @@ def self.run_ips(label, warmup: 2, time: 5, &block) report end + # Run IPS benchmark for end-to-end workflow (generation + rendering) + def self.run_ips_e2e(label, warmup: 2, time: 5, &block) + puts "\n" + "=" * 80 + puts "IPS Benchmark: #{label} (end-to-end: generation + rendering)" + puts "=" * 80 + + results = {} + report = Benchmark.ips do |x| + x.config(warmup: warmup, time: time) + block.call(x, qr_data) + x.compare! + end + + # Extract actual metrics from the report + report.entries.each do |entry| + results[entry.label] = { + iterations_per_second: entry.stats.central_tendency.round(2), + standard_deviation: entry.stats.error_percentage.round(2), + samples: entry.measurement_cycle + } + end + + # Calculate comparison ratios (fastest = 1.0x) + if results.any? + fastest_ips = results.values.map { |r| r[:iterations_per_second] }.max + results.each do |_label, data| + data[:comparison] = (fastest_ips / data[:iterations_per_second]).round(2) + end + end + + # Save results with actual metrics + save_results( + "ips_e2e_#{label.downcase.gsub(/\s+/, "_")}", + { + label: "#{label} (end-to-end)", + timestamp: Time.now.iso8601, + ruby_version: RUBY_VERSION, + results: results + } + ) + + report + end + # Run memory profiler def self.run_memory(label, &block) puts "\n" + "=" * 80 diff --git a/benchmark/format_comparison.rb b/benchmark/format_comparison.rb index 66cfc1f..3352c4c 100644 --- a/benchmark/format_comparison.rb +++ b/benchmark/format_comparison.rb @@ -4,7 +4,19 @@ BenchmarkHelper.section "Format Comparison Benchmarks" -# IPS Benchmark - Compare all export formats (medium QR code) +# PRIMARY: End-to-end benchmark (generation + export) - what users actually do +BenchmarkHelper.run_ips_e2e("All Export Formats") do |x, qr_data| + data = qr_data[:medium] + + x.report("svg") { RQRCode::QRCode.new(data).as_svg(use_path: true) } + x.report("png") { RQRCode::QRCode.new(data).as_png } + x.report("html") { RQRCode::QRCode.new(data).as_html } + x.report("ansi") { RQRCode::QRCode.new(data).as_ansi } + + x.compare! +end + +# DIAGNOSTIC: Rendering-only benchmark (isolates export performance) BenchmarkHelper.run_ips("All Export Formats") do |x, qrcodes| qr = qrcodes[:medium] @@ -17,3 +29,5 @@ end puts "\nāœ“ Format comparison benchmarks complete" +puts " - End-to-end: Full user workflow (generation + export)" +puts " - Rendering-only: Export performance in isolation" diff --git a/benchmark/html_export.rb b/benchmark/html_export.rb index 6a5df20..7b2b1bb 100644 --- a/benchmark/html_export.rb +++ b/benchmark/html_export.rb @@ -4,7 +4,16 @@ BenchmarkHelper.section "HTML Export Benchmarks" -# IPS Benchmark - HTML Export +# PRIMARY: End-to-end benchmark (generation + export) +BenchmarkHelper.run_ips_e2e("HTML Export") do |x, qr_data| + x.report("html_small") { RQRCode::QRCode.new(qr_data[:small]).as_html } + x.report("html_medium") { RQRCode::QRCode.new(qr_data[:medium]).as_html } + x.report("html_large") { RQRCode::QRCode.new(qr_data[:large]).as_html } + + x.compare! +end + +# DIAGNOSTIC: Rendering-only benchmark (isolates export performance) BenchmarkHelper.run_ips("HTML Export") do |x, qrcodes| x.report("html_small") { qrcodes[:small].as_html } x.report("html_medium") { qrcodes[:medium].as_html } @@ -23,3 +32,5 @@ end puts "\nāœ“ HTML benchmarks complete" +puts " - End-to-end: Full user workflow (generation + export)" +puts " - Rendering-only: Export performance in isolation" diff --git a/benchmark/png_export.rb b/benchmark/png_export.rb index cb1b8f4..2d82e81 100644 --- a/benchmark/png_export.rb +++ b/benchmark/png_export.rb @@ -4,7 +4,16 @@ BenchmarkHelper.section "PNG Export Benchmarks" -# IPS Benchmark - PNG Export (default sizing, most common use case) +# PRIMARY: End-to-end benchmark (generation + export) +BenchmarkHelper.run_ips_e2e("PNG Export") do |x, qr_data| + x.report("png_small") { RQRCode::QRCode.new(qr_data[:small]).as_png } + x.report("png_medium") { RQRCode::QRCode.new(qr_data[:medium]).as_png } + x.report("png_large") { RQRCode::QRCode.new(qr_data[:large]).as_png } + + x.compare! +end + +# DIAGNOSTIC: Rendering-only benchmark (isolates export performance) BenchmarkHelper.run_ips("PNG Export") do |x, qrcodes| x.report("png_small") { qrcodes[:small].as_png } x.report("png_medium") { qrcodes[:medium].as_png } @@ -23,3 +32,5 @@ end puts "\nāœ“ PNG benchmarks complete" +puts " - End-to-end: Full user workflow (generation + export)" +puts " - Rendering-only: Export performance in isolation" diff --git a/benchmark/svg_export.rb b/benchmark/svg_export.rb index 4a0d636..7d1b10a 100644 --- a/benchmark/svg_export.rb +++ b/benchmark/svg_export.rb @@ -4,7 +4,16 @@ BenchmarkHelper.section "SVG Export Benchmarks" -# IPS Benchmark - SVG Export (path mode, most common use case) +# PRIMARY: End-to-end benchmark (generation + export) +BenchmarkHelper.run_ips_e2e("SVG Export") do |x, qr_data| + x.report("svg_small") { RQRCode::QRCode.new(qr_data[:small]).as_svg(use_path: true) } + x.report("svg_medium") { RQRCode::QRCode.new(qr_data[:medium]).as_svg(use_path: true) } + x.report("svg_large") { RQRCode::QRCode.new(qr_data[:large]).as_svg(use_path: true) } + + x.compare! +end + +# DIAGNOSTIC: Rendering-only benchmark (isolates export performance) BenchmarkHelper.run_ips("SVG Export") do |x, qrcodes| x.report("svg_small") { qrcodes[:small].as_svg(use_path: true) } x.report("svg_medium") { qrcodes[:medium].as_svg(use_path: true) } @@ -23,3 +32,5 @@ end puts "\nāœ“ SVG benchmarks complete" +puts " - End-to-end: Full user workflow (generation + export)" +puts " - Rendering-only: Export performance in isolation" From 822071682137186eca433fe0167f32d8f5b061c2 Mon Sep 17 00:00:00 2001 From: Duncan Robertson Date: Wed, 7 Jan 2026 21:45:38 +0000 Subject: [PATCH 06/12] chore(deps): update rqrcode_core to 2.1.0 --- Gemfile | 2 +- Gemfile.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 6de6ebd..963212f 100644 --- a/Gemfile +++ b/Gemfile @@ -3,4 +3,4 @@ source "https://rubygems.org" # Specify your gem's dependencies in rqrcode-base.gemspec gemspec -#gem "rqrcode_core", path: "../rqrcode_core" +# gem "rqrcode_core", path: "../rqrcode_core" diff --git a/Gemfile.lock b/Gemfile.lock index 6aa60f5..ba6beb8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -24,7 +24,7 @@ GEM rainbow (3.1.1) rake (13.2.1) regexp_parser (2.9.2) - rqrcode_core (2.0.1) + rqrcode_core (2.1.0) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) From b4ef4532bd6c7a59b3060da02420f6d764aa42d3 Mon Sep 17 00:00:00 2001 From: Duncan Robertson Date: Wed, 7 Jan 2026 21:45:47 +0000 Subject: [PATCH 07/12] docs: add semantic commit message guidelines to AGENTS.md --- AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 73dfd78..2af74fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,3 +36,11 @@ - Core QR generation: `rqrcode_core` gem (do not modify, separate project) - PNG rendering: `chunky_png` gem - This gem focuses on rendering QR codes from `rqrcode_core` data structures + +## Commit Messages + +Use the Semantic Commit Message style: + +- **type**: The type of change (see below) +- **scope**: Optional, the area of the codebase affected (e.g., `auth`, `api`, `ui`) +- **subject**: A brief description in imperative mood, lowercase, no full stop From a0515013f8173c388c8fcc06cbb836140e48b23f Mon Sep 17 00:00:00 2001 From: Duncan Robertson Date: Wed, 7 Jan 2026 21:46:29 +0000 Subject: [PATCH 08/12] refactor(svg): move color prefix logic to top-level render method --- lib/rqrcode/export/svg.rb | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/rqrcode/export/svg.rb b/lib/rqrcode/export/svg.rb index 5a4f8fe..a02e817 100644 --- a/lib/rqrcode/export/svg.rb +++ b/lib/rqrcode/export/svg.rb @@ -21,9 +21,6 @@ def build(module_size, options = {}) offset_x = options[:offset_x].to_i offset_y = options[:offset_y].to_i - # Prefix hexadecimal colors unless using a named color (symbol) - color = "##{color}" unless color.is_a?(Symbol) - modules_array = @qrcode.modules matrix_width = matrix_height = modules_array.length + 1 empty_row = [Array.new(matrix_width - 1, false)] @@ -101,9 +98,6 @@ def build(module_size, options = {}) offset_x = options[:offset_x].to_i offset_y = options[:offset_y].to_i - # Prefix hexadecimal colors unless using a named color (symbol) - color = "##{color}" unless color.is_a?(Symbol) - @qrcode.modules.each_index do |c| @qrcode.modules.each_index do |r| next unless @qrcode.checked?(c, r) @@ -187,7 +181,7 @@ def as_svg(options = {}) color = options[:color] || "000" shape_rendering = options[:shape_rendering] || "crispEdges" module_size = options[:module_size] || 11 - standalone = options[:standalone].nil? ? true : options[:standalone] + standalone = options[:standalone].nil? || options[:standalone] viewbox = options[:viewbox].nil? ? false : options[:viewbox] svg_attributes = options[:svg_attributes] || {} @@ -207,6 +201,9 @@ def as_svg(options = {}) open_tag = %() close_tag = "" + # Prefix hexadecimal colors unless using a named color (symbol) + color = "##{color}" unless color.is_a?(Symbol) + output_tag = (use_path ? Path : Rect).new(@qrcode) output_tag.build(module_size, offset_x: offset_x, offset_y: offset_y, color: color) From ed9c3a42c83903f6310fbedace896be4fc556e40 Mon Sep 17 00:00:00 2001 From: Duncan Robertson Date: Wed, 7 Jan 2026 21:57:29 +0000 Subject: [PATCH 09/12] refactor(html): optimize as_html for performance Rewrite HTML export to use a single loop and preallocate string capacity, improving speed and memory usage. Remove unnecessary helper classes. --- lib/rqrcode/export/html.rb | 61 ++++++++++++++---------------------- lib/rqrcode/qrcode/qrcode.rb | 1 + 2 files changed, 24 insertions(+), 38 deletions(-) diff --git a/lib/rqrcode/export/html.rb b/lib/rqrcode/export/html.rb index 0384733..11c4c26 100644 --- a/lib/rqrcode/export/html.rb +++ b/lib/rqrcode/export/html.rb @@ -3,49 +3,34 @@ module RQRCode module Export module HTML - # - # Use this module to HTML-ify the QR code if you just want the default HTML - def as_html - ["", rows.as_html, "
"].join - end - - private - - def rows - Rows.new(@qrcode) - end + TABLE_OPEN = "" + TABLE_CLOSE = "
" + TR_OPEN = "" + TR_CLOSE = "" + TD_BLACK = '' + TD_WHITE = '' - class Rows < Struct.new(:qr) - def as_html - rows.map(&:as_html).join - end - - def rows - qr.modules.each_with_index.map { |qr_module, row_index| Row.new(qr, qr_module, row_index) } - end - end - - class Row < Struct.new(:qr, :qr_module, :row_index) - def as_html - ["", cells.map(&:as_html).join, ""].join - end - - def cells - qr.modules.each_with_index.map { |qr_module, col_index| Cell.new(qr, col_index, row_index) } - end - end - - class Cell < Struct.new(:qr, :col_index, :row_index) - def as_html - "" + def as_html + qr = @qrcode + module_count = qr.module_count + + estimated_size = (module_count * module_count * 26) + (module_count * 9) + 15 + result = String.new(capacity: estimated_size) + + result << TABLE_OPEN + module_count.times do |row_index| + result << TR_OPEN + module_count.times do |col_index| + result << (qr.checked?(row_index, col_index) ? TD_BLACK : TD_WHITE) + end + result << TR_CLOSE end + result << TABLE_CLOSE - def html_class - qr.checked?(row_index, col_index) ? "black" : "white" - end + result end end end end -RQRCode::QRCode.send :include, RQRCode::Export::HTML +RQRCode::QRCode.include RQRCode::Export::HTML diff --git a/lib/rqrcode/qrcode/qrcode.rb b/lib/rqrcode/qrcode/qrcode.rb index 946757c..65c1cb7 100644 --- a/lib/rqrcode/qrcode/qrcode.rb +++ b/lib/rqrcode/qrcode/qrcode.rb @@ -5,6 +5,7 @@ module RQRCode # :nodoc: class QRCode extend Forwardable + def_delegators :@qrcode, :to_s def_delegators :@qrcode, :modules # deprecated From 12a395d13de08680f53561af4403dd9f2535bcfe Mon Sep 17 00:00:00 2001 From: Duncan Robertson Date: Thu, 8 Jan 2026 08:56:09 +0000 Subject: [PATCH 10/12] chore: update ruby support to >= 3.2 and update dependencies --- .github/workflows/ruby.yml | 2 +- Gemfile.lock | 105 +++++++++++++++++++++++++------------ README.md | 2 +- rqrcode.gemspec | 12 ++--- 4 files changed, 78 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index b865103..f720a67 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] - ruby: ["3.1", "3.2", "3.3", "3.4"] + ruby: ["3.2", "3.3", "3.4", "4.0"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 diff --git a/Gemfile.lock b/Gemfile.lock index ba6beb8..93e358e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,75 +8,80 @@ PATH GEM remote: https://rubygems.org/ specs: - ast (2.4.2) + ast (2.4.3) benchmark-ips (2.14.0) chunky_png (1.4.0) - diff-lcs (1.5.1) - json (2.7.2) - language_server-protocol (3.17.0.3) + diff-lcs (1.6.2) + json (2.18.0) + language_server-protocol (3.17.0.5) lint_roller (1.1.0) memory_profiler (1.1.0) - parallel (1.26.3) - parser (3.3.5.0) + parallel (1.27.0) + parser (3.3.10.0) ast (~> 2.4.1) racc + prism (1.7.0) racc (1.8.1) rainbow (3.1.1) - rake (13.2.1) - regexp_parser (2.9.2) + rake (13.3.1) + regexp_parser (2.11.3) rqrcode_core (2.1.0) - rspec (3.13.0) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.1) + rspec-core (3.13.6) rspec-support (~> 3.13.0) - rspec-expectations (3.13.2) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.1) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.1) - rubocop (1.66.1) + rspec-support (3.13.6) + rubocop (1.81.7) json (~> 2.3) - language_server-protocol (>= 3.17.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rubocop-ast (>= 1.32.2, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.32.3) - parser (>= 3.3.1.0) - rubocop-performance (1.22.1) - rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (1.13.0) stackprof (0.2.27) - standard (1.41.0) + standard (1.52.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) - rubocop (~> 1.66.0) + rubocop (~> 1.81.7) standard-custom (~> 1.0.0) - standard-performance (~> 1.5) + standard-performance (~> 1.8) standard-custom (1.0.2) lint_roller (~> 1.0) rubocop (~> 1.50) - standard-performance (1.5.0) + standard-performance (1.9.0) lint_roller (~> 1.1) - rubocop-performance (~> 1.22.0) - unicode-display_width (2.6.0) + rubocop-performance (~> 1.26.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) PLATFORMS - aarch64-linux + arm64-darwin-24 ruby - x86_64-linux DEPENDENCIES benchmark-ips (~> 2.0) - bundler (~> 2.0) + bundler (~> 4.0) memory_profiler (~> 1.0) rake (~> 13.0) rqrcode! @@ -84,5 +89,39 @@ DEPENDENCIES stackprof (~> 0.2) standard (~> 1.41) +CHECKSUMS + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + benchmark-ips (2.14.0) sha256=b72bc8a65d525d5906f8cd94270dccf73452ee3257a32b89fbd6684d3e8a9b1d + chunky_png (1.4.0) sha256=89d5b31b55c0cf4da3cf89a2b4ebc3178d8abe8cbaf116a1dba95668502fdcfe + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505 + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + memory_profiler (1.1.0) sha256=79a17df7980a140c83c469785905409d3027ca614c42c086089d128b805aa8f8 + parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + parser (3.3.10.0) sha256=ce3587fa5cc55a88c4ba5b2b37621b3329aadf5728f9eafa36bbd121462aabd6 + prism (1.7.0) sha256=10062f734bf7985c8424c44fac382ac04a58124ea3d220ec3ba9fe4f2da65103 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + rqrcode (3.1.1) + rqrcode_core (2.1.0) sha256=f303b85df89c1b8fc5ee8dc19808c9dc4330e6329b660d99d4a8cbb36ca13051 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.7) sha256=0979034e64b1d7a838aaaddf12bf065ea4dc40ef3d4c39f01f93ae2c66c62b1c + rspec-support (3.13.6) sha256=2e8de3702427eab064c9352fe74488cc12a1bfae887ad8b91cba480ec9f8afb2 + rubocop (1.81.7) sha256=6fb5cc298c731691e2a414fe0041a13eb1beed7bab23aec131da1bcc527af094 + rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + stackprof (0.2.27) sha256=aff6d28656c852e74cf632cc2046f849033dc1dedffe7cb8c030d61b5745e80c + standard (1.52.0) sha256=ec050e63228e31fabe40da3ef96da7edda476f7acdf3e7c2ad47b6e153f6a076 + standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b + standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2 + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + BUNDLED WITH - 2.4.10 + 4.0.3 diff --git a/README.md b/README.md index 89929e5..d91a92d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [RQRCode](https://github.com/whomwah/rqrcode) is a library for creating and rendering QR codes into various formats. It has a simple interface with all the standard QR code options. It was adapted from the Javascript library by Kazuhiko Arase. - QR code is trademarked by Denso Wave inc -- Minimum Ruby version is `>= 3.0.0` +- Minimum Ruby version is `>= 3.2.0` ## Installing diff --git a/rqrcode.gemspec b/rqrcode.gemspec index 4799aca..1b8ab26 100644 --- a/rqrcode.gemspec +++ b/rqrcode.gemspec @@ -23,23 +23,19 @@ Gem::Specification.new do |spec| } spec.files = Dir.chdir(File.expand_path(__dir__)) do - `git ls-files -z`.split("\x0").reject do |f| - f.match(%r{^(test|spec|features|benchmark|images|bin)/}) || # exclude test/dev directories - f.match(/\.(rspec|standard\.yml|gitignore|DS_Store)$/) || # exclude config files - f.match(/^_config\.yml$/) || # exclude Jekyll config - f.match(%r{^\.github/}) || # exclude GitHub configs - f.match(/(AGENTS|OPTIMIZATIONS)\.md$/) # exclude dev documentation + `git ls-files -z`.split("\x0").select do |f| + f.match(%r{^lib/}) || %w[LICENSE.txt README.md CHANGELOG.md].include?(f) end end spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.required_ruby_version = ">= 3.0" + spec.required_ruby_version = ">= 3.2" spec.add_dependency "chunky_png", "~> 1.0" spec.add_dependency "rqrcode_core", "~> 2.0" spec.add_development_dependency "benchmark-ips", "~> 2.0" - spec.add_development_dependency "bundler", "~> 2.0" + spec.add_development_dependency "bundler", "~> 4.0" spec.add_development_dependency "memory_profiler", "~> 1.0" spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rspec", "~> 3.5" From 4697d7d2f4b2478f7aeafa45305d4f6da11a535e Mon Sep 17 00:00:00 2001 From: Duncan Robertson Date: Thu, 8 Jan 2026 13:55:52 +0000 Subject: [PATCH 11/12] docs(benchmark): update benchmark results for 2026-01-08 --- benchmark/README.md | 91 ++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 47 deletions(-) diff --git a/benchmark/README.md b/benchmark/README.md index 884bfe4..8303c0b 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -170,87 +170,84 @@ Benchmarks use 3 representative QR code sizes: ## Latest Benchmark Results -**Last Updated: 2025-12-17 21:59:00 UTC** +**Last Updated: 2026-01-08 13:51:30 UTC** **Ruby Version: 3.3.4** **Platform: Apple M-series** -**rqrcode_core version: 2.0.1 (feat/performance101 branch with 80-90% generation improvements)** +**rqrcode_core version: 2.0.1** ### Quick Reference Baselines #### End-to-end (Generation + Export) - Medium QR Code *User-facing performance - what matters for real-world usage* -| Format | Iterations/sec | Std Dev | Samples | Slowdown vs Fastest | vs Previous | -|--------|----------------|---------|---------|---------------------|-------------| -| ANSI | 33.0 | 0.00% | 165 | 1.00x (baseline) | +82% šŸš€ | -| PNG | 32.5 | 0.00% | 165 | 1.01x (same-ish) | +80% šŸš€ | -| HTML | 32.2 | 0.00% | 162 | 1.02x (same-ish) | +79% šŸš€ | -| SVG | 28.3 | 0.00% | 142 | 1.17x | +69% šŸš€ | +| Format | Iterations/sec | Std Dev | Samples | Slowdown vs Fastest | +|--------|----------------|---------|---------|---------------------| +| HTML | 34.2 | 0.00% | 174 | 1.00x (baseline) | +| ANSI | 34.0 | 0.00% | 171 | 1.01x (same-ish) | +| PNG | 33.1 | 3.00% | 168 | 1.04x (same-ish) | +| SVG | 29.0 | 0.00% | 146 | 1.18x | -**Key Insight**: All formats perform similarly (~28-33 i/s) because QR generation dominates the time. The 69-82% improvement across all formats reflects the rqrcode_core optimisation. Format choice has minimal impact on end-to-end performance. +**Key Insight**: All formats perform similarly (~29-34 i/s) because QR generation dominates the time. Format choice has minimal impact on end-to-end performance. #### Rendering-only (Export Performance) - Medium QR Code *Diagnostic metric - shows export efficiency in isolation* -| Format | Iterations/sec | Std Dev | Samples | Slowdown vs Fastest | vs Previous | -|--------|----------------|---------|---------|---------------------|-------------| -| ANSI | 1,315 | 0.50% | 6,603 | 1.00x (baseline) | āœ… stable | -| PNG | 823 | 3.40% | 4,150 | 1.60x | āœ… stable | -| HTML | 616 | 1.30% | 3,087 | 2.14x | āœ… stable | -| SVG | 171 | 4.10% | 867 | 7.67x | āœ… stable | +| Format | Iterations/sec | Std Dev | Samples | Slowdown vs Fastest | +|--------|----------------|---------|---------|---------------------| +| HTML | 1,868 | 0.60% | 9,412 | 1.00x (baseline) | +| ANSI | 1,351 | 0.30% | 6,885 | 1.38x | +| PNG | 864 | 0.70% | 4,386 | 2.16x | +| SVG | 184 | 0.50% | 936 | 10.15x | -**Key Insight**: Export format differences are dramatic when isolated. SVG rendering is 7.7x slower than ANSI, indicating optimisation opportunities. Rendering-only performance remained stable (as expected) while end-to-end improved dramatically. +**Key Insight**: Export format differences are dramatic when isolated. SVG rendering is 10x slower than HTML, indicating optimisation opportunities. ### Performance by QR Code Size *Note: Higher iterations/sec is better; lower std dev is better; lower slowdown is better* #### SVG Export (End-to-end) -| Size | Iterations/sec | Std Dev | Slowdown vs Small | vs Previous | -|--------|----------------|---------|-------------------|-------------| -| Small | 90.2 | 0.00% | 1.00x (baseline) | +71% šŸš€ | -| Medium | 28.6 | 0.00% | 3.16x | +69% šŸš€ | -| Large | 9.3 | 0.00% | 9.65x | +79% šŸš€ | +| Size | Iterations/sec | Std Dev | Slowdown vs Small | +|--------|----------------|---------|-------------------| +| Small | 92.8 | 0.00% | 1.00x (baseline) | +| Medium | 29.4 | 0.00% | 3.16x | +| Large | 9.6 | 0.00% | 9.66x | #### PNG Export (End-to-end) -| Size | Iterations/sec | Std Dev | Slowdown vs Small | vs Previous | -|--------|----------------|---------|-------------------|-------------| -| Small | 98.3 | 0.00% | 1.00x (baseline) | +72% šŸš€ | -| Medium | 32.1 | 0.00% | 3.07x | +80% šŸš€ | -| Large | 11.0 | 0.00% | 8.95x | +79% šŸš€ | +| Size | Iterations/sec | Std Dev | Slowdown vs Small | +|--------|----------------|---------|-------------------| +| Small | 102.5 | 1.00% | 1.00x (baseline) | +| Medium | 33.6 | 0.00% | 3.05x | +| Large | 11.4 | 0.00% | 9.00x | #### HTML Export (End-to-end) -| Size | Iterations/sec | Std Dev | Slowdown vs Small | vs Previous | -|--------|----------------|---------|-------------------|-------------| -| Small | 102.0 | 0.00% | 1.00x (baseline) | +77% šŸš€ | -| Medium | 31.8 | 0.00% | 3.21x | +79% šŸš€ | -| Large | 10.8 | 0.00% | 9.42x | +80% šŸš€ | +| Size | Iterations/sec | Std Dev | Slowdown vs Small | +|--------|----------------|---------|-------------------| +| Small | 109.2 | 3.70% | 1.00x (baseline) | +| Medium | 34.2 | 0.00% | 3.19x | +| Large | 11.6 | 0.00% | 9.40x | #### ANSI Export (End-to-end) -| Size | Iterations/sec | Std Dev | Slowdown vs Small | vs Previous | -|--------|----------------|---------|-------------------|-------------| -| Small | 105.6 | 0.00% | 1.00x (baseline) | +87% šŸš€ | -| Medium | 32.9 | 0.00% | 3.21x | +82% šŸš€ | -| Large | 11.2 | 0.00% | 9.43x | +80% šŸš€ | +| Size | Iterations/sec | Std Dev | Slowdown vs Small | +|--------|----------------|---------|-------------------| +| Small | 108.6 | 0.90% | 1.00x (baseline) | +| Medium | 33.9 | 0.00% | 3.21x | +| Large | 11.5 | 0.00% | 9.42x | ### Memory Allocations *Note: Lower is better for both metrics* | Format | Total Objects Allocated | Total Memory (MB) | |--------|-------------------------|-------------------| -| ANSI | 40,701 | 16.2 | -| PNG | 357,676 | 22.0 | -| HTML | 1,441,201 | 155.8 | -| SVG | 7,443,651 | 374.4 | +| HTML | 451 | 18.0 | +| PNG | 357,676 | 23.1 | +| SVG | 7,443,651 | 392.5 | **Key Insights:** -- **Major improvement**: rqrcode_core optimisations delivered 69-87% faster end-to-end performance across all formats! šŸŽ‰ -- **End-to-end**: QR generation is the bottleneck - format choice barely matters (~28-33 i/s for all) -- **Rendering-only**: ANSI is fastest (1,315 i/s), SVG is slowest (171 i/s) and most memory-intensive -- **Validation**: Rendering-only benchmarks remained stable, confirming improvements came from rqrcode_core -- **Optimisation priority**: Improvements to rqrcode_core have biggest impact on user experience (proven!) -- **Format choice**: For high-volume rendering, ANSI/PNG are significantly faster than SVG/HTML +- **End-to-end**: QR generation is the bottleneck - format choice barely matters (~29-34 i/s for all) +- **Rendering-only**: HTML is fastest (1,868 i/s), SVG is slowest (184 i/s) and most memory-intensive +- **Optimisation priority**: Improvements to rqrcode_core have biggest impact on user experience +- **Format choice**: For high-volume rendering, HTML/ANSI are significantly faster than SVG - All formats show 3-10x performance degradation as QR size increases -- Memory usage varies dramatically: ANSI uses 23x less memory than SVG +- Memory usage varies dramatically: HTML uses 22x less memory than SVG ## Notes From 36917bf812e849f88776df6969904761b454e13b Mon Sep 17 00:00:00 2001 From: Duncan Robertson Date: Thu, 8 Jan 2026 14:40:55 +0000 Subject: [PATCH 12/12] perf(svg): optimize SVG path export for speed and output size - Refactor path tracing to use integer direction constants and arrays - Remove Edge struct, reduce allocations and intermediate arrays - Build SVG path string directly for each loop, minimizing string ops - Improves rendering speed by 130% and reduces output size - Update benchmark README with new performance results - Add comprehensive SVG export tests covering options and output --- benchmark/README.md | 41 +++---- lib/rqrcode/export/svg.rb | 194 ++++++++++++++++++++------------ spec/rqrcode/export_svg_spec.rb | 184 ++++++++++++++++++++++++++++++ 3 files changed, 330 insertions(+), 89 deletions(-) diff --git a/benchmark/README.md b/benchmark/README.md index 8303c0b..f38a890 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -53,7 +53,7 @@ Measures only export performance using pre-generated QR codes - **When to use**: When optimising export code, isolating rendering bottlenecks - **File naming**: `ips_*_YYYYMMDD_HHMMSS.json` -**Key Insight**: End-to-end benchmarks often show QR generation is the bottleneck (all formats perform similarly), while rendering-only benchmarks reveal significant differences between export formats (SVG can be 7-8x slower than ANSI). +**Key Insight**: End-to-end benchmarks often show QR generation is the bottleneck (all formats perform similarly), while rendering-only benchmarks reveal differences between export formats (SVG is ~4x slower than HTML due to algorithmic complexity). ## Available Benchmarks @@ -170,7 +170,7 @@ Benchmarks use 3 representative QR code sizes: ## Latest Benchmark Results -**Last Updated: 2026-01-08 13:51:30 UTC** +**Last Updated: 2026-01-08 14:09:06 UTC** **Ruby Version: 3.3.4** **Platform: Apple M-series** **rqrcode_core version: 2.0.1** @@ -182,24 +182,24 @@ Benchmarks use 3 representative QR code sizes: | Format | Iterations/sec | Std Dev | Samples | Slowdown vs Fastest | |--------|----------------|---------|---------|---------------------| -| HTML | 34.2 | 0.00% | 174 | 1.00x (baseline) | -| ANSI | 34.0 | 0.00% | 171 | 1.01x (same-ish) | -| PNG | 33.1 | 3.00% | 168 | 1.04x (same-ish) | -| SVG | 29.0 | 0.00% | 146 | 1.18x | +| HTML | 34.1 | 2.90% | 171 | 1.00x (baseline) | +| ANSI | 34.1 | 0.00% | 171 | 1.00x (same-ish) | +| PNG | 33.6 | 0.00% | 171 | 1.01x (same-ish) | +| SVG | 32.2 | 0.00% | 162 | 1.06x (same-ish) | -**Key Insight**: All formats perform similarly (~29-34 i/s) because QR generation dominates the time. Format choice has minimal impact on end-to-end performance. +**Key Insight**: All formats now perform very similarly (~32-34 i/s) because QR generation dominates the time. SVG optimisations brought it in line with other formats for end-to-end usage. #### Rendering-only (Export Performance) - Medium QR Code *Diagnostic metric - shows export efficiency in isolation* | Format | Iterations/sec | Std Dev | Samples | Slowdown vs Fastest | |--------|----------------|---------|---------|---------------------| -| HTML | 1,868 | 0.60% | 9,412 | 1.00x (baseline) | -| ANSI | 1,351 | 0.30% | 6,885 | 1.38x | -| PNG | 864 | 0.70% | 4,386 | 2.16x | -| SVG | 184 | 0.50% | 936 | 10.15x | +| HTML | 1,876 | 0.70% | 9,464 | 1.00x (baseline) | +| ANSI | 1,310 | 6.20% | 6,615 | 1.43x | +| PNG | 840 | 4.90% | 4,214 | 2.23x | +| SVG | 424 | 1.70% | 2,150 | 4.42x | -**Key Insight**: Export format differences are dramatic when isolated. SVG rendering is 10x slower than HTML, indicating optimisation opportunities. +**Key Insight**: SVG rendering improved from 184 i/s to 424 i/s (+130%) after optimisations. The gap vs HTML reduced from 10x to 4.4x. Remaining gap is due to algorithmic complexity (edge detection + path tracing vs simple iteration). ### Performance by QR Code Size *Note: Higher iterations/sec is better; lower std dev is better; lower slowdown is better* @@ -207,9 +207,9 @@ Benchmarks use 3 representative QR code sizes: #### SVG Export (End-to-end) | Size | Iterations/sec | Std Dev | Slowdown vs Small | |--------|----------------|---------|-------------------| -| Small | 92.8 | 0.00% | 1.00x (baseline) | -| Medium | 29.4 | 0.00% | 3.16x | -| Large | 9.6 | 0.00% | 9.66x | +| Small | 102.7 | 1.00% | 1.00x (baseline) | +| Medium | 32.2 | 0.00% | 3.19x | +| Large | 10.9 | 0.00% | 9.41x | #### PNG Export (End-to-end) | Size | Iterations/sec | Std Dev | Slowdown vs Small | @@ -239,15 +239,16 @@ Benchmarks use 3 representative QR code sizes: |--------|-------------------------|-------------------| | HTML | 451 | 18.0 | | PNG | 357,676 | 23.1 | -| SVG | 7,443,651 | 392.5 | +| SVG | 2,157,951 | 113.8 | **Key Insights:** -- **End-to-end**: QR generation is the bottleneck - format choice barely matters (~29-34 i/s for all) -- **Rendering-only**: HTML is fastest (1,868 i/s), SVG is slowest (184 i/s) and most memory-intensive +- **End-to-end**: All formats now perform similarly (~32-34 i/s) - SVG optimisations closed the gap +- **Rendering-only**: HTML is fastest (1,876 i/s), SVG improved significantly (424 i/s, was 184 i/s) +- **SVG improvements**: +130% rendering speed, 71% memory reduction after 2026-01-08 optimisations - **Optimisation priority**: Improvements to rqrcode_core have biggest impact on user experience -- **Format choice**: For high-volume rendering, HTML/ANSI are significantly faster than SVG +- **Format choice**: For high-volume rendering, HTML/ANSI are still faster but SVG is now competitive - All formats show 3-10x performance degradation as QR size increases -- Memory usage varies dramatically: HTML uses 22x less memory than SVG +- Memory usage: HTML uses 6x less memory than SVG (was 22x before optimisation) ## Notes diff --git a/lib/rqrcode/export/svg.rb b/lib/rqrcode/export/svg.rb index a02e817..3f5f8f7 100644 --- a/lib/rqrcode/export/svg.rb +++ b/lib/rqrcode/export/svg.rb @@ -15,79 +15,153 @@ def initialize(qrcode) end class Path < BaseOutputSVG + # Direction constants for edge representation + # Edges stored as [start_x, start_y, direction] arrays instead of Struct + DIR_UP = 0 + DIR_DOWN = 1 + DIR_LEFT = 2 + DIR_RIGHT = 3 + + # Pre-computed end coordinate deltas: [dx, dy] for each direction + DIR_DELTAS = [ + [0, -1], # UP + [0, 1], # DOWN + [-1, 0], # LEFT + [1, 0] # RIGHT + ].freeze + + # SVG path commands indexed by direction constant + DIR_PATH_COMMANDS = ["v-", "v", "h-", "h"].freeze + def build(module_size, options = {}) - # Extract values from options color = options[:color] offset_x = options[:offset_x].to_i offset_y = options[:offset_y].to_i modules_array = @qrcode.modules - matrix_width = matrix_height = modules_array.length + 1 - empty_row = [Array.new(matrix_width - 1, false)] - edge_matrix = Array.new(matrix_height) { Array.new(matrix_width) } - - (empty_row + modules_array + empty_row).each_cons(2).with_index do |row_pair, row_index| - first_row, second_row = row_pair - - # horizontal edges - first_row.zip(second_row).each_with_index do |cell_pair, column_index| - edge = case cell_pair - when [true, false] then Edge.new column_index + 1, row_index, :left - when [false, true] then Edge.new column_index, row_index, :right + module_count = modules_array.length + matrix_size = module_count + 1 + + # Edge matrix stores arrays of [x, y, direction] tuples + edge_matrix = Array.new(matrix_size) { Array.new(matrix_size) } + edge_count = 0 + + # Process horizontal edges (between vertically adjacent cells) + (module_count + 1).times do |row_index| + module_count.times do |col_index| + above = row_index > 0 && modules_array[row_index - 1][col_index] + below = row_index < module_count && modules_array[row_index][col_index] + + if above && !below + # Edge going left at (col+1, row) + x = col_index + 1 + y = row_index + (edge_matrix[y][x] ||= []) << [x, y, DIR_LEFT] + edge_count += 1 + elsif !above && below + # Edge going right at (col, row) + x = col_index + y = row_index + (edge_matrix[y][x] ||= []) << [x, y, DIR_RIGHT] + edge_count += 1 end - - (edge_matrix[edge.start_y][edge.start_x] ||= []) << edge if edge end + end - # vertical edges - ([false] + second_row + [false]).each_cons(2).each_with_index do |cell_pair, column_index| - edge = case cell_pair - when [true, false] then Edge.new column_index, row_index, :down - when [false, true] then Edge.new column_index, row_index + 1, :up + # Process vertical edges (between horizontally adjacent cells) + module_count.times do |row_index| + (module_count + 1).times do |col_index| + left = col_index > 0 && modules_array[row_index][col_index - 1] + right = col_index < module_count && modules_array[row_index][col_index] + + if left && !right + # Edge going down at (col, row) + x = col_index + y = row_index + (edge_matrix[y][x] ||= []) << [x, y, DIR_DOWN] + edge_count += 1 + elsif !left && right + # Edge going up at (col, row+1) + x = col_index + y = row_index + 1 + (edge_matrix[y][x] ||= []) << [x, y, DIR_UP] + edge_count += 1 end - - (edge_matrix[edge.start_y][edge.start_x] ||= []) << edge if edge end end - edge_count = edge_matrix.flatten.compact.count - path = [] + path_parts = [] + + # Track search position to avoid re-scanning from beginning + search_y = 0 + search_x = 0 while edge_count > 0 - edge_loop = [] - next_matrix_cell = edge_matrix.find(&:any?).find { |cell| cell&.any? } - edge = next_matrix_cell.first - - while edge - edge_loop << edge - matrix_cell = edge_matrix[edge.start_y][edge.start_x] - matrix_cell.delete edge - edge_matrix[edge.start_y][edge.start_x] = nil if matrix_cell.empty? + # Find next non-empty cell, starting from last position + start_edge = nil + found_y = search_y + found_x = search_x + + # Continue from where we left off + (search_y...matrix_size).each do |y| + start_col = (y == search_y) ? search_x : 0 + (start_col...matrix_size).each do |x| + cell = edge_matrix[y][x] + next unless cell && !cell.empty? + + start_edge = cell.first + found_y = y + found_x = x + break + end + break if start_edge + end + + # Update search position for next iteration + search_y = found_y + search_x = found_x + + # Build path string directly without intermediate edge_loop array + path_str = String.new(capacity: 64) + path_str << "M" << start_edge[0].to_s << " " << start_edge[1].to_s + + current_edge = start_edge + current_dir = nil + current_count = 0 + + while current_edge + ex, ey, edir = current_edge + + # Remove edge from matrix + cell = edge_matrix[ey][ex] + cell.delete(current_edge) + edge_matrix[ey][ex] = nil if cell.empty? edge_count -= 1 - # try to find an edge continuing the current edge - edge = edge_matrix[edge.end_y][edge.end_x]&.first - end + # Accumulate consecutive edges in same direction + if edir == current_dir + current_count += 1 + else + # Flush previous direction + path_str << DIR_PATH_COMMANDS[current_dir] << current_count.to_s if current_dir + current_dir = edir + current_count = 1 + end - first_edge = edge_loop.first - edge_loop_parts = [ - SVG_PATH_COMMANDS[:move], - first_edge.start_x.to_s, - " ", - first_edge.start_y.to_s - ] - - chunked = edge_loop.chunk(&:direction).to_a - chunked[0...-1].each do |direction, edges| - edge_loop_parts << SVG_PATH_COMMANDS[direction] - edge_loop_parts << edges.length.to_s + # Find next edge at end coordinates + delta = DIR_DELTAS[edir] + next_x = ex + delta[0] + next_y = ey + delta[1] + next_cell = edge_matrix[next_y]&.[](next_x) + current_edge = next_cell&.first end - edge_loop_parts << SVG_PATH_COMMANDS[:close] - path << edge_loop_parts.join + # Don't output the last direction segment - close path instead + path_str << "z" + path_parts << path_str end - @result << %{} + @result << %{} end end @@ -110,24 +184,6 @@ def build(module_size, options = {}) end end - class Edge < Struct.new(:start_x, :start_y, :direction) - def end_x - case direction - when :right then start_x + 1 - when :left then start_x - 1 - else start_x - end - end - - def end_y - case direction - when :down then start_y + 1 - when :up then start_y - 1 - else start_y - end - end - end - DEFAULT_SVG_ATTRIBUTES = [ %(version="1.1"), %(xmlns="http://www.w3.org/2000/svg"), diff --git a/spec/rqrcode/export_svg_spec.rb b/spec/rqrcode/export_svg_spec.rb index 82bf64d..9d7acdc 100644 --- a/spec/rqrcode/export_svg_spec.rb +++ b/spec/rqrcode/export_svg_spec.rb @@ -107,4 +107,188 @@ )).to eq(AS_SVG10) end end + + context "with shape_rendering option" do + it "uses default crispEdges when not specified" do + svg = RQRCode::QRCode.new("test").as_svg + expect(svg).to include('shape-rendering="crispEdges"') + end + + it "applies custom shape_rendering value" do + svg = RQRCode::QRCode.new("test").as_svg(shape_rendering: "geometricPrecision") + expect(svg).to include('shape-rendering="geometricPrecision"') + end + + it "supports optimizeSpeed shape_rendering" do + svg = RQRCode::QRCode.new("test").as_svg(shape_rendering: "optimizeSpeed") + expect(svg).to include('shape-rendering="optimizeSpeed"') + end + end + + context "SVG element structure" do + it "generates rect elements when use_path is false (default)" do + svg = RQRCode::QRCode.new("test").as_svg + expect(svg).to include("') + end + + it "includes required SVG namespace attributes" do + svg = RQRCode::QRCode.new("test").as_svg + expect(svg).to include('xmlns="http://www.w3.org/2000/svg"') + expect(svg).to include('xmlns:xlink="http://www.w3.org/1999/xlink"') + expect(svg).to include('version="1.1"') + end + + it "closes SVG tag properly" do + svg = RQRCode::QRCode.new("test").as_svg + expect(svg).to end_with("") + end + end + + context "dimension calculations" do + let(:qr) { RQRCode::QRCode.new("test") } + let(:module_count) { qr.instance_variable_get(:@qrcode).module_count } + + it "calculates dimensions based on module_count and default module_size" do + svg = qr.as_svg + expected_size = module_count * 11 # default module_size is 11 + expect(svg).to include(%(width="#{expected_size}" height="#{expected_size}")) + end + + it "calculates dimensions with custom module_size" do + svg = qr.as_svg(module_size: 5) + expected_size = module_count * 5 + expect(svg).to include(%(width="#{expected_size}" height="#{expected_size}")) + end + + it "includes offsets in dimension calculations" do + svg = qr.as_svg(module_size: 10, offset: 20) + expected_size = (module_count * 10) + (2 * 20) + expect(svg).to include(%(width="#{expected_size}" height="#{expected_size}")) + end + + it "calculates asymmetric dimensions with different x and y offsets" do + svg = qr.as_svg(module_size: 10, offset_x: 15, offset_y: 25) + expected_width = (module_count * 10) + (2 * 15) + expected_height = (module_count * 10) + (2 * 25) + expect(svg).to include(%(width="#{expected_width}" height="#{expected_height}")) + end + end + + context "color handling" do + it "prefixes hex color with # for color option" do + svg = RQRCode::QRCode.new("test").as_svg(use_path: true, color: "ff0000") + expect(svg).to include('fill="#ff0000"') + end + + it "prefixes hex color with # for fill option" do + svg = RQRCode::QRCode.new("test").as_svg(use_path: true, fill: "00ff00") + expect(svg).to include('fill="#00ff00"') + end + + it "does not prefix symbol colors" do + svg = RQRCode::QRCode.new("test").as_svg(use_path: true, color: :blue) + expect(svg).to include('fill="blue"') + expect(svg).not_to include('fill="#blue"') + end + + it "uses default color 000 when not specified" do + svg = RQRCode::QRCode.new("test").as_svg(use_path: true) + expect(svg).to include('fill="#000"') + end + + it "supports 3-character hex codes" do + svg = RQRCode::QRCode.new("test").as_svg(use_path: true, color: "f00") + expect(svg).to include('fill="#f00"') + end + end + + context "viewbox with use_rect" do + it "uses viewBox attribute instead of width/height" do + qr = RQRCode::QRCode.new("test") + svg = qr.as_svg(viewbox: true) # use_rect is default + module_count = qr.instance_variable_get(:@qrcode).module_count + expected_size = module_count * 11 + + expect(svg).to include(%(viewBox="0 0 #{expected_size} #{expected_size}")) + expect(svg).not_to include(%(width="#{expected_size}")) + expect(svg).not_to include(%(height="#{expected_size}")) + end + end + + context "fill background with use_rect" do + it "adds background rect before module rects" do + svg = RQRCode::QRCode.new("test").as_svg(fill: "ffffff") + # Background rect should be first rect after svg open tag + expect(svg).to match(/]*>]*fill="#ffffff"/) + end + end + + context "standalone false with use_rect" do + it "outputs only rect elements without XML declaration or SVG wrapper" do + svg = RQRCode::QRCode.new("test").as_svg(standalone: false) + expect(svg).not_to include("") + expect(svg).to include(" "qr-code", + :class => "qr-image", + "data-content" => "test", + :role => "img" + } + ) + expect(svg).to include('id="qr-code"') + expect(svg).to include('class="qr-image"') + expect(svg).to include('data-content="test"') + expect(svg).to include('role="img"') + end + end + + context "with different QR code complexities" do + it "handles short content" do + svg = RQRCode::QRCode.new("a").as_svg(use_path: true) + expect(svg).to include("") + end + + it "handles URL content" do + svg = RQRCode::QRCode.new("https://example.com/path?query=value").as_svg(use_path: true) + expect(svg).to include("") + end + + it "handles content with special characters" do + svg = RQRCode::QRCode.new("Hello, World! @#$%").as_svg(use_path: true) + expect(svg).to include("") + end + end + + context "path output compactness" do + it "generates smaller output with use_path than use_rect for same content" do + qr = RQRCode::QRCode.new("https://example.com") + svg_rect = qr.as_svg(use_path: false) + svg_path = qr.as_svg(use_path: true) + + expect(svg_path.length).to be < svg_rect.length + end + end end