mork 0.16.1 → 0.18.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 120d2749d8734418de4f7606f4dd29d1a626962ee64653ecbad1ede0ff61a0d4
4
- data.tar.gz: d85f525368c11bf3fe2e67c481b7da94098f8668aab5af2d5689233c30d97c9d
3
+ metadata.gz: 46d443ec81a0831a6048e8a7a7b6a16eb35d6a7d9a1a0a13287427d44d7e9b77
4
+ data.tar.gz: 15788c1658c1c07dce5fc1e0c66a345afb1a15a28a3b85bca787437a6500b13e
5
5
  SHA512:
6
- metadata.gz: 9f8846c5201c97c5da75e84efb1ef00f478fb6ec2e6dbbcf8fb5151894a81cc9f08996d40254ea64d1190f98b4927fe51ef81f0d6fad529fe5fa4ebfdf6a2d9d
7
- data.tar.gz: 6145fc639a13bd1fcdc2c4224bdfc2462a01b40a23fc964db9ef74d60839e2375465224390ab8b85daae98765c2879a900f8e6ef96f1f62983d289bfa901d1e0
6
+ metadata.gz: cd65ed345abadaad63c283965c49c9af3c5b7b57564a8f0cabae07bc7d5bc32e8c167156c5703ad03b1b2ea4ff6bb0b363f976258589b2d37639cc3a39e4c1cc
7
+ data.tar.gz: bf1ae484be720c8a60f4e4f2cb540d5f9e235f89fd772089cb5bd5968762d54a3a1a1e4fb99725c29653633dbbec1732e3d8da953009fc583e4d734e86665a76
data/README.md CHANGED
@@ -64,6 +64,14 @@ Response sheets are created through the `Mork::SheetPDF` class. Two pieces of in
64
64
  s = SheetPDF.new content, layout
65
65
  ```
66
66
 
67
+ Mork uses its bundled Open Sans Regular font for all text in generated PDFs.
68
+ Prawn embeds only the glyphs used by each document, so no font needs to be
69
+ installed on the host that creates or prints the PDF. Open Sans covers extended
70
+ Latin, Greek, Cyrillic, and Hebrew, but it does not cover every Unicode script;
71
+ Arabic, Indic scripts, CJK, and emoji are not supported by Mork's default font.
72
+ The bundled font is distributed under the SIL Open Font License in
73
+ `lib/mork/fonts/OFL.txt`.
74
+
67
75
  Let’s look at each argument in turn.
68
76
 
69
77
  ### content
@@ -296,6 +304,29 @@ s.save 'marked_choices_and_outlines.jpg'
296
304
  system 'open marked_choices_and_outlines.jpg' # this works in macOS
297
305
  ```
298
306
 
307
+ For single-answer tests, Mork also provides a higher-level method that compares the detected responses against an answer key and applies the overlays automatically:
308
+
309
+ ```ruby
310
+ correct = [3, 0, 2, 1, 2] # one zero-based correct choice per question
311
+ s.overlay_corrections correct
312
+ s.save 'corrections.jpg'
313
+ system 'open corrections.jpg' # this works in macOS
314
+ ```
315
+
316
+ `overlay_corrections` applies:
317
+
318
+ - a green outline on the marked cell when the response is correct
319
+ - a red outline on the expected cell and a red cross on the marked cell when the response is incorrect
320
+ - a red outline on the expected cell when the response is blank or invalid
321
+
322
+ By default, `overlay_corrections` uses the unique marked response for each question, so if a responder marks more than one choice, the response is treated as invalid and no red cross is applied. To cross all marked cells for incorrect or multi-marked responses:
323
+
324
+ ```ruby
325
+ s.overlay_corrections correct, marked: :all
326
+ ```
327
+
328
+ The answer key must contain one entry per configured question, and each entry must be either a zero-based choice index, a one-element array containing that index, or `nil` to skip overlaying a question.
329
+
299
330
  Scoring can only be performed if the sheet gets properly registered, which in turn depends on the quality of the scanned image.
300
331
 
301
332
  ### Improving sheet registration and marking
@@ -0,0 +1,88 @@
1
+ Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)
2
+
3
+ -----------------------------------------------------------
4
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
5
+ -----------------------------------------------------------
6
+
7
+ PREAMBLE
8
+ The goals of the Open Font License (OFL) are to stimulate worldwide
9
+ development of collaborative font projects, to support the font
10
+ creation efforts of academic and linguistic communities, and to
11
+ provide a free and open framework in which fonts may be shared and
12
+ improved in partnership with others.
13
+
14
+ The OFL allows the licensed fonts to be used, studied, modified and
15
+ redistributed freely as long as they are not sold by themselves. The
16
+ fonts, including any derivative works, can be bundled, embedded,
17
+ redistributed and/or sold with any software provided that any reserved
18
+ names are not used by derivative works. The fonts and derivatives,
19
+ however, cannot be released under any other type of license. The
20
+ requirement for fonts to remain under this license does not apply to
21
+ any document created using the fonts or their derivatives.
22
+
23
+ DEFINITIONS
24
+ "Font Software" refers to the set of files released by the Copyright
25
+ Holder(s) under this license and clearly marked as such. This may
26
+ include source files, build scripts and documentation.
27
+
28
+ "Reserved Font Name" refers to any names specified as such after the
29
+ copyright statement(s).
30
+
31
+ "Original Version" refers to the collection of Font Software
32
+ components as distributed by the Copyright Holder(s).
33
+
34
+ "Modified Version" refers to any derivative made by adding to,
35
+ deleting, or substituting -- in part or in whole -- any of the
36
+ components of the Original Version, by changing formats or by porting
37
+ the Font Software to a new environment.
38
+
39
+ "Author" refers to any designer, engineer, programmer, technical
40
+ writer or other person who contributed to the Font Software.
41
+
42
+ PERMISSION & CONDITIONS
43
+ Permission is hereby granted, free of charge, to any person obtaining
44
+ a copy of the Font Software, to use, study, copy, merge, embed,
45
+ modify, redistribute, and sell modified and unmodified copies of the
46
+ Font Software, subject to the following conditions:
47
+
48
+ 1) Neither the Font Software nor any of its individual components, in
49
+ Original or Modified Versions, may be sold by itself.
50
+
51
+ 2) Original or Modified Versions of the Font Software may be bundled,
52
+ redistributed and/or sold with any software, provided that each copy
53
+ contains the above copyright notice and this license. These can be
54
+ included either as stand-alone text files, human-readable headers or
55
+ in the appropriate machine-readable metadata fields within text or
56
+ binary files as long as those fields can be easily viewed by the user.
57
+
58
+ 3) No Modified Version of the Font Software may use the Reserved Font
59
+ Name(s) unless explicit written permission is granted by the
60
+ corresponding Copyright Holder. This restriction only applies to the
61
+ primary font name as presented to the users.
62
+
63
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
64
+ Software shall not be used to promote, endorse or advertise any
65
+ Modified Version, except to acknowledge the contribution(s) of the
66
+ Copyright Holder(s) and the Author(s) or with their explicit written
67
+ permission.
68
+
69
+ 5) The Font Software, modified or unmodified, in part or in whole,
70
+ must be distributed entirely under this license, and must not be
71
+ distributed under any other license. The requirement for fonts to
72
+ remain under this license does not apply to any document created using
73
+ the Font Software.
74
+
75
+ TERMINATION
76
+ This license becomes null and void if any of the above conditions are
77
+ not met.
78
+
79
+ DISCLAIMER
80
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
81
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
82
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
83
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
84
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
85
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
86
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
87
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
88
+ OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file
data/lib/mork/grid_omr.rb CHANGED
@@ -5,6 +5,8 @@ require 'deep_merge/rails_compat'
5
5
  module Mork
6
6
  # @private
7
7
  class GridOMR < Grid
8
+ OVERLAY_STROKE_WIDTH_MM = 0.5
9
+
8
10
  def initialize(options=nil)
9
11
  super options
10
12
  end
@@ -15,6 +17,10 @@ module Mork
15
17
  self
16
18
  end
17
19
 
20
+ def overlay_stroke_width_px
21
+ [(pixels_per_unit * OVERLAY_STROKE_WIDTH_MM).round, 1].max
22
+ end
23
+
18
24
  def barcode_areas(bits)
19
25
  [].tap do |areas|
20
26
  bits.each_with_index do |b, i|
@@ -61,6 +67,7 @@ module Mork
61
67
 
62
68
  def cx() @px / reg_frame_width end
63
69
  def cy() @py / reg_frame_height end
70
+ def pixels_per_unit() (ppu_x + ppu_y) / 2.0 end
64
71
  def ppu_x() @px / page_width end
65
72
  def ppu_y() @py / page_height end
66
73
 
data/lib/mork/magicko.rb CHANGED
@@ -8,10 +8,12 @@ module Mork
8
8
  class Magicko
9
9
  attr_reader :width
10
10
  attr_reader :height
11
+ attr_reader :overlay_stroke_width
11
12
 
12
13
  def initialize(path)
13
14
  @path = path
14
15
  @cmd = []
16
+ @overlay_stroke_width = 3
15
17
  # a density is required for processing PDF or other vector-based images;
16
18
  # a default of 150 dpi seems sensible. It should not affect bitmaps.
17
19
  density = 150
@@ -39,6 +41,10 @@ module Mork
39
41
  end
40
42
  end
41
43
 
44
+ def overlay_stroke_width=(width)
45
+ @overlay_stroke_width = [width.to_i, 1].max
46
+ end
47
+
42
48
  def valid?
43
49
  @valid
44
50
  end
@@ -71,14 +77,36 @@ module Mork
71
77
  end
72
78
 
73
79
  def outline(coords, rounded)
74
- @cmd << [:stroke, 'green']
75
- @cmd << [:strokewidth, '3']
80
+ outline_with_color(coords, rounded, 'green')
81
+ end
82
+
83
+ def outline_green(coords, rounded)
84
+ outline_with_color(coords, rounded, 'green')
85
+ end
86
+
87
+ def outline_red(coords, rounded)
88
+ outline_with_color(coords, rounded, 'red')
89
+ end
90
+
91
+ def check(coords, rounded)
92
+ check_with_color(coords, 'red')
93
+ end
94
+
95
+ def check_red(coords, rounded)
96
+ check_with_color(coords, 'red')
97
+ end
98
+
99
+ def outline_with_color(coords, rounded, color)
100
+ return if coords.empty?
101
+ @cmd << [:stroke, color]
102
+ @cmd << [:strokewidth, overlay_stroke_width]
76
103
  @cmd << [:fill, 'none']
77
104
  coords.each { |c| @cmd << [:draw, shape(c, rounded)] }
78
105
  end
79
106
 
80
- def check(coords, rounded)
81
- @cmd << [:stroke, 'red']
107
+ def check_with_color(coords, color)
108
+ return if coords.empty?
109
+ @cmd << [:stroke, color]
82
110
  @cmd << [:strokewidth, '3']
83
111
  coords.each do |c|
84
112
  @cmd << [:draw, "line #{c.cross1}"]
@@ -115,7 +143,7 @@ module Mork
115
143
  def join(p)
116
144
  @cmd << [:fill, 'none']
117
145
  @cmd << [:stroke, 'green']
118
- @cmd << [:strokewidth, 3]
146
+ @cmd << [:strokewidth, overlay_stroke_width]
119
147
  pts = [
120
148
  p[0][:x], p[0][:y],
121
149
  p[1][:x], p[1][:y],
data/lib/mork/mimage.rb CHANGED
@@ -14,6 +14,7 @@ module Mork
14
14
  @mack = Magicko.new path
15
15
 
16
16
  @grom = grom.set_page_size @mack.width, @mack.height
17
+ @mack.overlay_stroke_width = @grom.overlay_stroke_width_px
17
18
  @choxq = [(0...@grom.max_choices_per_question).to_a] * grom.max_questions
18
19
  @rm = {} # registration mark centers
19
20
  @valid = register
@@ -86,7 +87,10 @@ module Mork
86
87
  fail ArgumentError, 'Invalid overlay argument “where”'
87
88
  end
88
89
  round = where != :barcode
89
- @mack.send what, areas, round
90
+ unless @mack.respond_to?(what)
91
+ fail ArgumentError, 'Invalid overlay argument “what”'
92
+ end
93
+ @mack.public_send what, areas, round
90
94
  end
91
95
 
92
96
  # write the underlying MiniMagick::Image to disk;
@@ -163,6 +163,47 @@ module Mork
163
163
  @mim.overlay what, where
164
164
  end
165
165
 
166
+ # Applies correctness-aware overlays to the image using an answer key.
167
+ #
168
+ # Correct responses receive a green outline on the marked cell. Incorrect
169
+ # responses receive a red outline on the expected cell and a red cross on
170
+ # the given cell. Blank or invalid responses receive only the red outline
171
+ # on the expected cell.
172
+ #
173
+ # @param correct_choices [Array<Integer, Array<Integer>, nil>] one entry per
174
+ # question. Each entry can be an integer choice index, a 1-element array
175
+ # containing that index, or nil to skip that question.
176
+ # @param marked [Symbol] `:unique` treats multi-marked responses as invalid
177
+ # and does not cross them out; `:all` crosses all marked cells for
178
+ # incorrect responses.
179
+ def overlay_corrections(correct_choices, marked: :unique)
180
+ return if not_registered
181
+
182
+ expected = normalize_correct_choices(correct_choices)
183
+ actual = marked_responses(marked)
184
+
185
+ green_outlines = Array.new(expected.length) { [] }
186
+ red_outlines = Array.new(expected.length) { [] }
187
+ red_crosses = Array.new(expected.length) { [] }
188
+
189
+ expected.each_with_index do |choice, question|
190
+ next if choice.nil?
191
+
192
+ marked_cells = actual[question]
193
+ if marked_cells == [choice]
194
+ green_outlines[question] = [choice]
195
+ next
196
+ end
197
+
198
+ red_outlines[question] = [choice]
199
+ red_crosses[question] = marked_cells if marked_cells.any?
200
+ end
201
+
202
+ @mim.overlay :outline_green, green_outlines
203
+ @mim.overlay :outline_red, red_outlines
204
+ @mim.overlay :check_red, red_crosses
205
+ end
206
+
166
207
  # Saves a copy of the source image after registration;
167
208
  # the output image will also contain any previously applied overlays.
168
209
  #
@@ -187,6 +228,53 @@ module Mork
187
228
  private #
188
229
  # ============================================================#
189
230
 
231
+ def normalize_correct_choices(correct_choices)
232
+ unless correct_choices.is_a?(Array)
233
+ fail ArgumentError, 'Correct choices must be an array'
234
+ end
235
+
236
+ if correct_choices.length != @mim.choxq.length
237
+ fail ArgumentError, 'Correct choices must match the configured number of questions'
238
+ end
239
+
240
+ correct_choices.map.with_index do |choice, question|
241
+ normalized =
242
+ case choice
243
+ when nil
244
+ nil
245
+ when Integer
246
+ choice
247
+ when Array
248
+ if choice.length > 1
249
+ fail ArgumentError, 'Each question supports a single correct choice'
250
+ end
251
+ choice.first
252
+ else
253
+ fail ArgumentError, 'Correct choices must contain integers, 1-element arrays, or nil'
254
+ end
255
+
256
+ next if normalized.nil?
257
+
258
+ max_choice = @mim.choxq[question].length
259
+ if normalized.negative? || normalized >= max_choice
260
+ fail ArgumentError, 'Correct choice exceeds the configured number of choices'
261
+ end
262
+
263
+ normalized
264
+ end
265
+ end
266
+
267
+ def marked_responses(mode)
268
+ case mode
269
+ when :unique
270
+ marked_choices.map { |choices| choices.length == 1 ? choices : [] }
271
+ when :all
272
+ marked_choices
273
+ else
274
+ fail ArgumentError, 'Invalid marked argument'
275
+ end
276
+ end
277
+
190
278
  def not_registered
191
279
  unless valid?
192
280
  puts "---=={ Unregistered image. Reason: '#{@mim.status.inspect}' }==---"
@@ -6,6 +6,10 @@ module Mork
6
6
  # Generating response sheets as PDF files.
7
7
  # See the README file for usage
8
8
  class SheetPDF < Prawn::Document
9
+ DEFAULT_FONT_FAMILY = 'Open Sans'
10
+ DEFAULT_FONT_PATH = File.expand_path('fonts/OpenSans-Regular.ttf', __dir__)
11
+ private_constant :DEFAULT_FONT_FAMILY, :DEFAULT_FONT_PATH
12
+
9
13
  include Extensions
10
14
  def initialize(content, layout=nil, duplex=false)
11
15
  @content =
@@ -24,6 +28,7 @@ module Mork
24
28
  else raise ArgumentError, 'Invalid initialization parameter'
25
29
  end
26
30
  super my_page_params
31
+ use_default_font
27
32
  @duplex = duplex
28
33
  process
29
34
  end
@@ -44,6 +49,15 @@ module Mork
44
49
  private
45
50
  ###########################################################################
46
51
 
52
+ def use_default_font
53
+ font_families.update(
54
+ DEFAULT_FONT_FAMILY => {
55
+ normal: { file: DEFAULT_FONT_PATH, subset: true }
56
+ }
57
+ )
58
+ font DEFAULT_FONT_FAMILY
59
+ end
60
+
47
61
  def my_page_params
48
62
  {
49
63
  page_size: @grip.page_size,
@@ -63,9 +77,7 @@ module Mork
63
77
  start_new_page if i>0
64
78
  barcode(content[:barcode] || 0)
65
79
  header(content[:header] || [])
66
- unless equal_choice_number?
67
- questions_and_choices ch_len[i]
68
- end
80
+ choice_cells(ch_len[i]) unless equal_choice_number?
69
81
  start_new_page if @duplex
70
82
  end
71
83
  end
@@ -73,11 +85,9 @@ module Mork
73
85
  def make_repeaters
74
86
  pages = @duplex ? :odd : :all
75
87
 
76
- if equal_choice_number?
77
- repeat(pages) do
78
- questions_and_choices ch_len.first
79
- end
80
- end
88
+ repeat(pages) { question_numbers }
89
+
90
+ repeat(pages) { choice_cells(ch_len.first) } if equal_choice_number?
81
91
 
82
92
  repeat(pages) do
83
93
  calibration_cell_repeater
@@ -129,14 +139,19 @@ module Mork
129
139
  end
130
140
  end
131
141
 
132
- def questions_and_choices(n_ch)
133
- n_ch.each_with_index do |n, i|
142
+ def question_numbers
143
+ ch_len.first.length.times do |i|
134
144
  text_box "#{i+1}",
135
145
  at: @grip.qnum_xy(i),
136
146
  width: @grip.qnum_width,
137
147
  height: @grip.height_of_cell,
138
148
  align: :right,
139
149
  valign: :center
150
+ end
151
+ end
152
+
153
+ def choice_cells(n_ch)
154
+ n_ch.each_with_index do |n, i|
140
155
  stamp_at "s#{n}", @grip.item_xy(i)
141
156
  end
142
157
  end
@@ -194,11 +209,16 @@ module Mork
194
209
  end
195
210
 
196
211
  def equal_choice_number?
197
- return false unless ch_len.all? { |c| c.length == ch_len[0].length }
198
- ch_len[0].each_with_index do |c, i|
199
- return false unless ch_len.all? { |x| c == x[i] }
212
+ return @equal_choice_number unless @equal_choice_number.nil?
213
+
214
+ first_choices = ch_len.first
215
+ return @equal_choice_number = false unless ch_len.all? { |c| c.length == first_choices.length }
216
+
217
+ first_choices.each_with_index do |c, i|
218
+ return @equal_choice_number = false unless ch_len.all? { |x| c == x[i] }
200
219
  end
201
- true
220
+
221
+ @equal_choice_number = true
202
222
  end
203
223
 
204
224
  def ensure_presence_of_choices
data/lib/mork/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Mork
2
- VERSION = '0.16.1'
2
+ VERSION = '0.18.0'
3
3
  end
@@ -0,0 +1,40 @@
1
+ require 'benchmark'
2
+ require 'mork'
3
+
4
+ def build_content(pages:, unequal:)
5
+ Array.new(pages) do |i|
6
+ choices =
7
+ if unequal
8
+ Array.new(120) { |j| 1 + ((i + j) % 5) }
9
+ else
10
+ [5] * 120
11
+ end
12
+
13
+ {
14
+ barcode: i,
15
+ choices: choices,
16
+ header: { title: "Test #{i + 1}" }
17
+ }
18
+ end
19
+ end
20
+
21
+ def measure(label)
22
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
23
+ result = yield
24
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
25
+ [label, elapsed, result]
26
+ end
27
+
28
+ puts "ruby=#{RUBY_VERSION} prawn=#{Prawn::VERSION}"
29
+ puts "pages\tshape\tbuild_s\trender_s\tbytes"
30
+
31
+ [1, 20, 100].each do |pages|
32
+ [[false, 'equal'], [true, 'unequal']].each do |unequal, shape|
33
+ content = build_content(pages: pages, unequal: unequal)
34
+
35
+ _, build_s, pdf = measure('build') { Mork::SheetPDF.new(content) }
36
+ _, render_s, bytes = measure('render') { pdf.to_pdf.bytesize }
37
+
38
+ puts [pages, shape, format('%.6f', build_s), format('%.6f', render_s), bytes].join("\t")
39
+ end
40
+ end
@@ -56,6 +56,20 @@ module Mork
56
56
  end
57
57
  end
58
58
 
59
+ describe '#overlay_stroke_width_px' do
60
+ it 'keeps the default visual width on the reference scan' do
61
+ grom = GridOMR.new 'spec/samples/jdoe/layout.yml'
62
+ grom.set_page_size 1240, 1754
63
+ expect(grom.overlay_stroke_width_px).to eq 3
64
+ end
65
+
66
+ it 'scales with scan resolution' do
67
+ grom = GridOMR.new 'spec/samples/jdoe/layout.yml'
68
+ grom.set_page_size 2480, 3508
69
+ expect(grom.overlay_stroke_width_px).to eq 6
70
+ end
71
+ end
72
+
59
73
  describe '#paper_white_area' do
60
74
  it 'returns the coordinates of the white area used for barcode calibration' do
61
75
  expect(@grom.paper_white_area).to have_coords(93, 2260, 25, 21)
@@ -38,5 +38,17 @@ module Mork
38
38
  expect(ma.registered_bytes(pp).length).to eq sh.height*sh.width
39
39
  end
40
40
  end
41
+
42
+ describe 'overlay stroke width' do
43
+ it 'defaults to the legacy 3-pixel width' do
44
+ expect(ma.overlay_stroke_width).to eq 3
45
+ end
46
+
47
+ it 'uses the configured width for green outlines' do
48
+ ma.overlay_stroke_width = 6
49
+ ma.outline [co], false
50
+ expect(ma.instance_variable_get(:@cmd)).to include([:strokewidth, 6])
51
+ end
52
+ end
41
53
  end
42
54
  end
@@ -148,6 +148,58 @@ module Mork
148
148
  omr.overlay :outline
149
149
  omr.save "spec/out/JD-outline-marked.jpeg"
150
150
  end
151
+
152
+ it 'applies correctness-aware overlays and saves the result' do
153
+ omr.set_choices [5] * 5
154
+ omr.overlay_corrections [0, 0, 2, 3, 1]
155
+ omr.save "spec/out/JD-corrections.jpeg"
156
+ end
157
+ end
158
+
159
+ context 'applying correctness-aware overlays' do
160
+ let(:mim) { omr.instance_variable_get(:@mim) }
161
+
162
+ before do
163
+ omr.set_choices [5] * 4
164
+ end
165
+
166
+ it 'batches green outlines, red outlines, and red crosses by correctness' do
167
+ allow(omr).to receive(:marked_choices).and_return([[1], [2], [], [3, 4]])
168
+
169
+ expect(mim).to receive(:overlay).with(:outline_green, [[1], [], [], []]).ordered
170
+ expect(mim).to receive(:overlay).with(:outline_red, [[], [0], [2], [3]]).ordered
171
+ expect(mim).to receive(:overlay).with(:check_red, [[], [2], [], []]).ordered
172
+
173
+ omr.overlay_corrections [1, 0, 2, 3]
174
+ end
175
+
176
+ it 'can cross all marked cells for incorrect responses' do
177
+ allow(omr).to receive(:marked_choices).and_return([[1, 2], [0]])
178
+
179
+ omr.set_choices [5] * 2
180
+
181
+ expect(mim).to receive(:overlay).with(:outline_green, [[], []]).ordered
182
+ expect(mim).to receive(:overlay).with(:outline_red, [[3], [1]]).ordered
183
+ expect(mim).to receive(:overlay).with(:check_red, [[1, 2], [0]]).ordered
184
+
185
+ omr.overlay_corrections [3, 1], marked: :all
186
+ end
187
+
188
+ it 'raises an ArgumentError if the answer key length does not match the questions' do
189
+ expect { omr.overlay_corrections([0, 1, 2]) }.to raise_error(ArgumentError)
190
+ end
191
+
192
+ it 'raises an ArgumentError if a correct choice exceeds the configured choices' do
193
+ expect { omr.overlay_corrections([0, 1, 5, 2]) }.to raise_error(ArgumentError)
194
+ end
195
+
196
+ it 'raises an ArgumentError if a question has multiple correct choices' do
197
+ expect { omr.overlay_corrections([0, [1, 2], 2, 3]) }.to raise_error(ArgumentError)
198
+ end
199
+
200
+ it 'raises an ArgumentError if the marked mode is invalid' do
201
+ expect { omr.overlay_corrections([0, 1, 2, 3], marked: :invalid) }.to raise_error(ArgumentError)
202
+ end
151
203
  end
152
204
 
153
205
  context 'requesting invalid responses and choices' do
@@ -48,6 +48,26 @@ module Mork
48
48
  expect(sp.instance_variable_get('@content').first).to be_a Hash
49
49
  end
50
50
 
51
+ it 'uses the bundled Open Sans font with subsetting' do
52
+ pdf = SheetPDF.new({})
53
+
54
+ expect(pdf.font.family).to eq 'Open Sans'
55
+ expect(pdf.font.options[:subset]).to be true
56
+ expect(pdf.font.options[:file]).to end_with 'lib/mork/fonts/OpenSans-Regular.ttf'
57
+ end
58
+
59
+ it 'embeds Open Sans and renders supported Unicode across pages' do
60
+ content = [
61
+ { header: { title: 'Extended Latin: Ž; Greek: Ω' } },
62
+ { header: { title: 'Cyrillic: Ж; Hebrew: א' } }
63
+ ]
64
+
65
+ bytes = SheetPDF.new(content).to_pdf
66
+
67
+ expect(bytes).to include '/FontFile2'
68
+ expect(bytes).to include 'OpenSans-Regular'
69
+ end
70
+
51
71
  it 'creates a minimal PDF sheet' do
52
72
  s = SheetPDF.new({})
53
73
  s.save dest 'minimal'
@@ -106,6 +126,23 @@ module Mork
106
126
  SheetPDF.new(c).save dest('uneq')
107
127
  end
108
128
 
129
+ it 'reuses question number repeaters for multi-page unequal batches' do
130
+ choices = Array.new(120) { |i| 1 + (i % 5) }
131
+ c = 3.times.map do |x|
132
+ {
133
+ barcode: x,
134
+ choices: choices.rotate(x),
135
+ header: { title: "Test #{x+1}" }
136
+ }
137
+ end
138
+
139
+ pdf = SheetPDF.new(c)
140
+
141
+ expect(pdf.page_count).to eq 3
142
+ expect(pdf.repeaters.length).to eq 2
143
+ expect { pdf.to_pdf }.not_to raise_error
144
+ end
145
+
109
146
  it 'creates 20 PDF sheets' do
110
147
  c = 20.times.map do |x|
111
148
  content.merge({ header: {title: "Test #{x+1}"}, barcode: x})
@@ -130,4 +167,3 @@ module Mork
130
167
  end
131
168
  end
132
169
  end
133
-
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mork
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.1
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Giuseppe Bertini
@@ -183,6 +183,8 @@ files:
183
183
  - lib/mork.rb
184
184
  - lib/mork/coord.rb
185
185
  - lib/mork/extensions.rb
186
+ - lib/mork/fonts/OFL.txt
187
+ - lib/mork/fonts/OpenSans-Regular.ttf
186
188
  - lib/mork/grid.rb
187
189
  - lib/mork/grid_const.rb
188
190
  - lib/mork/grid_omr.rb
@@ -195,6 +197,7 @@ files:
195
197
  - lib/mork/version.rb
196
198
  - mork.code-workspace
197
199
  - mork.gemspec
200
+ - script/bench_sheet_pdf.rb
198
201
  - spec/mork/coord_spec.rb
199
202
  - spec/mork/grid_omr_spec.rb
200
203
  - spec/mork/grid_spec.rb
@@ -283,7 +286,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
283
286
  - !ruby/object:Gem::Version
284
287
  version: '0'
285
288
  requirements: []
286
- rubygems_version: 3.7.2
289
+ rubygems_version: 4.0.4
287
290
  specification_version: 4
288
291
  summary: Optical mark recognition of multiple-choice tests and surveys
289
292
  test_files: []