cocina_display 2.11.0 → 2.12.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: 3cdd3c641ebe565578770ae4f79867ccc886bdaefa17b2bfa966cc17c262c58b
4
- data.tar.gz: 2d0e16f879c06393a3e74c2e323896384040721c95f73bc2e65fb428c4c9d94a
3
+ metadata.gz: 16e21603fafbb47fe2471e436a256e19eac1d3b69acb85c1bb246d3f39e0d06c
4
+ data.tar.gz: '0898b33cdbf5b6c0b9f1f4d9fcdea3b4492d8b265b67fa90a08e14394e476bd2'
5
5
  SHA512:
6
- metadata.gz: 98248c3a9f4a68ef0e8fac5261f979702e66c529f086d9287d111d580af06bc517536b5636fb0e5d1ca02588264af17c91e9185630c5f35f287c60af5cc6de77
7
- data.tar.gz: b6c79b6672639e13f31f446ca239d11434be0ba152656f93bd328ce576271d3e6db6ace9adec9b136d40cd325fce562d0d30317cabf5b4afe37d505962a5e93f
6
+ metadata.gz: ddc270e2faa1baa9fbcbbbeaaf8f6fba3ae030e5b6be5741ed407c398a1e1211daaeefdddeeb60ae056748a963c6b1f7c0017b745cc2a2a83d7dcf97de062064
7
+ data.tar.gz: d56dc751d95514b82c2cd943a21ab3befee10adeddb69546796a30e28513a9ac4bf3bc6c4e212b5fc0d0d585a9f34ea339e4c6ccfd5224550e6df35eb575c531
@@ -14,6 +14,8 @@ module CocinaDisplay
14
14
  # All valid coordinate data formatted for indexing into a Solr RPT field.
15
15
  # @note This type of field accommodates both points and bounding boxes.
16
16
  # @note In WKT, points have longitude first, unlike {coordinates_as_point}.
17
+ # @note A box crossing the antimeridian is split at the date line, so it is
18
+ # rendered as a MULTIPOLYGON of its two halves.
17
19
  # @see https://solr.apache.org/guide/solr/latest/query-guide/spatial-search.html#rpt
18
20
  # @return [Array<String>]
19
21
  # @example ["POINT(-118.2437 34.0522)", "POLYGON((-118.2437 34.0522, -118.2437 34.1996, -117.9522 34.1996, -117.9522 34.0522, -118.2437 34.0522))"]
@@ -23,6 +25,7 @@ module CocinaDisplay
23
25
 
24
26
  # All valid coordinate data formatted for indexing into a Solr BBoxField.
25
27
  # @note Points are not included since they can't be represented as a box.
28
+ # @note West is greater than east for a box crossing the antimeridian.
26
29
  # @see https://solr.apache.org/guide/solr/latest/query-guide/spatial-search.html#bboxfield
27
30
  # @return [Array<String>]
28
31
  # @example ["ENVELOPE(-118.2437, -117.9522, 34.1996, 34.0522)"]
@@ -40,8 +43,10 @@ module CocinaDisplay
40
43
  end
41
44
 
42
45
  # All valid coordinate data formatted as bounding boxes.
43
- # Format is [[min_lat, min_long], [max_lat, max_long]].
46
+ # Format is [[south, west], [north, east]].
44
47
  # @note Points are not included since they can't be represented as a box.
48
+ # @note For a box crossing the antimeridian, east is carried past 180 so that
49
+ # the pair still reads southwest to northeast.
45
50
  # @return [Array<Array<Array<Float>>>]
46
51
  def coordinates_as_bbox
47
52
  coordinate_objects.map(&:as_bbox).compact.uniq
@@ -20,15 +20,17 @@ module CocinaDisplay
20
20
  # @return [Coordinates, nil]
21
21
  def from_structured_values(structured_values)
22
22
  if structured_values.size == 2
23
- lat = structured_values.find { |v| v["type"] == "latitude" }&.dig("value")
24
- lng = structured_values.find { |v| v["type"] == "longitude" }&.dig("value")
25
- Point.from_coords(lat: lat, lng: lng)
23
+ Point.from_coords(
24
+ lat: structured_value(structured_values, "latitude"),
25
+ lng: structured_value(structured_values, "longitude")
26
+ )
26
27
  elsif structured_values.size == 4
27
- north = structured_values.find { |v| v["type"] == "north" }&.dig("value")
28
- south = structured_values.find { |v| v["type"] == "south" }&.dig("value")
29
- east = structured_values.find { |v| v["type"] == "east" }&.dig("value")
30
- west = structured_values.find { |v| v["type"] == "west" }&.dig("value")
31
- BoundingBox.from_coords(west: west, east: east, north: north, south: south)
28
+ BoundingBox.from_coords(
29
+ west: structured_value(structured_values, "west"),
30
+ east: structured_value(structured_values, "east"),
31
+ north: structured_value(structured_values, "north"),
32
+ south: structured_value(structured_values, "south")
33
+ )
32
34
  end
33
35
  end
34
36
 
@@ -54,6 +56,36 @@ module CocinaDisplay
54
56
  # Use the matching parser to parse the string
55
57
  parser_class.parse(match_str)
56
58
  end
59
+
60
+ private
61
+
62
+ # Find a single coordinate value of the given type in structured data.
63
+ # @param [Array<Hash>] structured_values
64
+ # @param [String] type like "west" or "latitude"
65
+ # @return [String, nil]
66
+ def structured_value(structured_values, type)
67
+ value = structured_values.find { |v| v["type"] == type }&.dig("value")
68
+ normalize_value(value) if value.present?
69
+ end
70
+
71
+ # Standardize a single coordinate value so that Geo::Coord can parse it.
72
+ # Chooses a normalizer based on the string format, since structured values
73
+ # can be decimal degrees or DMS, including the packed MARC 034 form.
74
+ # @param [String] value
75
+ # @return [String, nil] nil if the format isn't recognized
76
+ # @example "W1210000" becomes "121°0′0″W"
77
+ def normalize_value(value)
78
+ # Remove all whitespace for easier matching/parsing
79
+ match_str = value.gsub(/\s+/, "")
80
+
81
+ # Try each normalizer in order until one matches; bail out if none do
82
+ normalizer_class = [
83
+ DMSCoordinateNormalizer,
84
+ DecimalCoordinateNormalizer
85
+ ].find { |normalizer| normalizer.supports?(match_str) }
86
+
87
+ normalizer_class&.normalize_coord(match_str)
88
+ end
57
89
  end
58
90
 
59
91
  protected
@@ -146,35 +178,72 @@ module CocinaDisplay
146
178
  end
147
179
  end
148
180
 
149
- # A bounding box defined by two corner points.
181
+ # A bounding box defined by its southwest and northeast corner points.
182
+ # The box can wrap east-west across the antimeridian, in which case its west
183
+ # edge is numerically east of its east edge. Both Solr's rectangle syntax and
184
+ # GeoJSON spell a crossing box that way, as do MARC 034 $d/$e, which are the
185
+ # westernmost and easternmost longitudes rather than the minimum and maximum.
186
+ # @see https://datatracker.ietf.org/doc/html/rfc7946#section-5.2
150
187
  class BoundingBox < Coordinates
151
- attr_reader :min_point, :max_point
188
+ attr_reader :southwest, :northeast
152
189
 
153
190
  # Construct a BoundingBox from west, east, north, and south string values.
191
+ # West and east are used as given, so a box that crosses the antimeridian
192
+ # is preserved instead of rejected.
154
193
  # @param [String] west western longitude
155
194
  # @param [String] east eastern longitude
156
195
  # @param [String] north northern latitude
157
196
  # @param [String] south southern latitude
158
197
  # @return [BoundingBox, nil] nil if parsing fails
159
198
  def self.from_coords(west:, east:, north:, south:)
160
- min_point = Geo::Coord.parse("#{south}, #{west}")
161
- max_point = Geo::Coord.parse("#{north}, #{east}")
199
+ southwest = Geo::Coord.parse("#{south}, #{west}")
200
+ northeast = Geo::Coord.parse("#{north}, #{east}")
162
201
 
163
202
  # Must be parsable
164
- return unless min_point && max_point
203
+ return unless southwest && northeast
204
+
205
+ # A box can wrap east-west, but never north-south
206
+ return if southwest.lat > northeast.lat
165
207
 
166
- # Ensure min_point is southwest and max_point is northeast
167
- return if min_point.lat > max_point.lat || min_point.lng > max_point.lng
208
+ new(southwest: southwest, northeast: northeast)
209
+ end
168
210
 
169
- new(min_point: min_point, max_point: max_point)
211
+ # Construct a BoundingBox from two corner Geo::Coord points.
212
+ # @param [Geo::Coord] southwest
213
+ # @param [Geo::Coord] northeast
214
+ def initialize(southwest:, northeast:)
215
+ @southwest = southwest
216
+ @northeast = northeast
170
217
  end
171
218
 
172
- # Construct a BoundingBox from two Geo::Coord points.
173
- # @param [Geo::Coord] min_point
174
- # @param [Geo::Coord] max_point
175
- def initialize(min_point:, max_point:)
176
- @min_point = min_point
177
- @max_point = max_point
219
+ # The westernmost longitude of the box.
220
+ # @return [BigDecimal]
221
+ def west
222
+ southwest.lng
223
+ end
224
+
225
+ # The easternmost longitude of the box.
226
+ # @return [BigDecimal]
227
+ def east
228
+ northeast.lng
229
+ end
230
+
231
+ # The northernmost latitude of the box.
232
+ # @return [BigDecimal]
233
+ def north
234
+ northeast.lat
235
+ end
236
+
237
+ # The southernmost latitude of the box.
238
+ # @return [BigDecimal]
239
+ def south
240
+ southwest.lat
241
+ end
242
+
243
+ # True if the box wraps east-west across the antimeridian.
244
+ # @return [Boolean]
245
+ def crosses_antimeridian?
246
+ west > east
178
247
  end
179
248
 
180
249
  # Format for display in DMS format, adapted from ISO 6709 standard.
@@ -183,33 +252,31 @@ module CocinaDisplay
183
252
  # @return [String]
184
253
  # @example "118°14′37″W -- 117°56′55″W / 34°03′08″N -- 34°11′59″N"
185
254
  def to_s
186
- min_lat, min_lng = format_point(min_point)
187
- max_lat, max_lng = format_point(max_point)
188
- "#{min_lng} -- #{max_lng} / #{max_lat} -- #{min_lat}"
255
+ south_str, west_str = format_point(southwest)
256
+ north_str, east_str = format_point(northeast)
257
+ "#{west_str} -- #{east_str} / #{north_str} -- #{south_str}"
189
258
  end
190
259
 
191
260
  # Format using the Well-Known Text (WKT) representation.
192
261
  # @note Limits decimals to 6 places.
262
+ # @note A box crossing the antimeridian is split into two polygons at the
263
+ # date line, so that every longitude stays within bounds.
193
264
  # @see https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry
265
+ # @see https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.9
194
266
  # @return [String]
195
267
  def as_wkt
196
- "POLYGON((%.6f %.6f, %.6f %.6f, %.6f %.6f, %.6f %.6f, %.6f %.6f))" % [
197
- min_point.lng, min_point.lat,
198
- max_point.lng, min_point.lat,
199
- max_point.lng, max_point.lat,
200
- min_point.lng, max_point.lat,
201
- min_point.lng, min_point.lat
202
- ]
268
+ return "POLYGON(#{ring(west, east)})" unless crosses_antimeridian?
269
+
270
+ "MULTIPOLYGON((#{ring(west, 180)}), (#{ring(-180, east)}))"
203
271
  end
204
272
 
205
273
  # Format using the CQL ENVELOPE representation.
206
274
  # @note Limits decimals to 6 places.
275
+ # @note West is greater than east for a box crossing the antimeridian.
207
276
  # @example "ENVELOPE(-118.2437, -117.9522, 34.1996, 34.0522)"
208
277
  # @return [String]
209
278
  def as_envelope
210
- "ENVELOPE(%.6f, %.6f, %.6f, %.6f)" % [
211
- min_point.lng, max_point.lng, max_point.lat, min_point.lat
212
- ]
279
+ "ENVELOPE(%.6f, %.6f, %.6f, %.6f)" % [west, east, north, south]
213
280
  end
214
281
 
215
282
  # The box center point as a space-separated x y (longitude latitude) pair.
@@ -217,18 +284,43 @@ module CocinaDisplay
217
284
  # @example "-118.2437 34.0522"
218
285
  # @return [String]
219
286
  def as_point
220
- azimuth = min_point.azimuth(max_point)
221
- distance = min_point.distance(max_point)
222
- center = min_point.endpoint(distance / 2, azimuth)
223
- "%.6f %.6f" % [center.lng, center.lat]
287
+ center_lng = (west + unwrapped_east) / 2
288
+ center_lng -= 360 if center_lng > 180
289
+ "%.6f %.6f" % [center_lng, (south + north) / 2]
224
290
  end
225
291
 
226
292
  # Format the bounding box as an array of two coordinate pairs [[S, W], [N, E]].
227
293
  # @note Limits decimals to 6 places.
294
+ # @note For a box crossing the antimeridian, east is carried past 180 so that
295
+ # the pair still reads southwest to northeast.
228
296
  # @return [Array<Array<Float>>]
229
- # @example [[-118.2437, 34.0522], [-117.9522, 34.1996]]
297
+ # @example [[34.0522, -118.2437], [34.1996, -117.9522]]
230
298
  def as_bbox
231
- [[min_point.lat, min_point.lng], [max_point.lat, max_point.lng]]
299
+ [[south, west], [north, unwrapped_east]]
300
+ end
301
+
302
+ private
303
+
304
+ # The east edge as a continuous longitude, carried past 180 if the box
305
+ # crosses the antimeridian.
306
+ # @return [BigDecimal]
307
+ def unwrapped_east
308
+ crosses_antimeridian? ? east + 360 : east
309
+ end
310
+
311
+ # A closed WKT linear ring for the box, spanning the given longitudes.
312
+ # @note Limits decimals to 6 places.
313
+ # @param [Numeric] west_lng
314
+ # @param [Numeric] east_lng
315
+ # @return [String]
316
+ def ring(west_lng, east_lng)
317
+ "(%.6f %.6f, %.6f %.6f, %.6f %.6f, %.6f %.6f, %.6f %.6f)" % [
318
+ west_lng, south,
319
+ east_lng, south,
320
+ east_lng, north,
321
+ west_lng, north,
322
+ west_lng, south
323
+ ]
232
324
  end
233
325
  end
234
326
 
@@ -313,12 +405,58 @@ module CocinaDisplay
313
405
  matches = input_str.match(self::PATTERN)
314
406
  return unless matches
315
407
 
316
- min_lng = normalize_coord(matches[:min_lng])
317
- max_lng = normalize_coord(matches[:max_lng])
318
- min_lat = normalize_coord(matches[:min_lat])
319
- max_lat = normalize_coord(matches[:max_lat])
408
+ west = normalize_coord(matches[:west])
409
+ east = normalize_coord(matches[:east])
410
+ south = normalize_coord(matches[:south])
411
+ north = normalize_coord(matches[:north])
412
+
413
+ BoundingBox.from_coords(west: west, east: east, north: north, south: south)
414
+ end
415
+ end
320
416
 
321
- BoundingBox.from_coords(west: min_lng, east: max_lng, north: max_lat, south: min_lat)
417
+ # Base class for normalizers that standardize a single coordinate value, as
418
+ # found in Cocina structured values, so that Geo::Coord can parse it.
419
+ # Subclasses define a PATTERN and mix in a parser module for normalize_coord.
420
+ class CoordinateNormalizer < CoordinatesParser
421
+ # Move a trailing hemisphere letter to the front, since that is where the
422
+ # parser normalizers expect it.
423
+ # @example "121.5W" becomes "W121.5"
424
+ # @param [String] value
425
+ # @return [String]
426
+ def self.hemisphere_first(value)
427
+ value.sub(/\A(.+?)([NESW])\z/, '\2\1')
428
+ end
429
+ end
430
+
431
+ # Normalizes DMS values, including the packed form used in MARC 034 subfields.
432
+ # @example W1210000
433
+ # @example 121°14′48″W
434
+ class DMSCoordinateNormalizer < CoordinateNormalizer
435
+ include DMSParser
436
+
437
+ # Either DMS punctuation, or a hemisphere paired with packed digits.
438
+ PATTERN = /[°⁰º′ʹ'″ʺ"]|\A[NESW]\d{4,}\z|\A\d{4,}[NESW]\z/
439
+
440
+ # @param [String] value
441
+ # @return [String, nil]
442
+ def self.normalize_coord(value)
443
+ super(hemisphere_first(value))
444
+ end
445
+ end
446
+
447
+ # Normalizes decimal degree values, either signed or paired with a hemisphere.
448
+ # @note Degrees are limited to 3 digits so that packed DMS isn't read as decimal.
449
+ # @example -121.24658
450
+ # @example W126.04
451
+ class DecimalCoordinateNormalizer < CoordinateNormalizer
452
+ include DecimalParser
453
+
454
+ PATTERN = /\A[NESW+-]?\d{1,3}(?:\.\d+)?[NESW]?\z/
455
+
456
+ # @param [String] value
457
+ # @return [String]
458
+ def self.normalize_coord(value)
459
+ super(hemisphere_first(value))
322
460
  end
323
461
  end
324
462
 
@@ -342,7 +480,7 @@ module CocinaDisplay
342
480
  class DMSBoundingBoxParser < BoundingBoxParser
343
481
  include DMSParser
344
482
 
345
- PATTERN = /(?<min_lng>.+?)-+(?<max_lng>.+)\/(?<max_lat>.+?)-+(?<min_lat>.+)/
483
+ PATTERN = /(?<west>.+?)-+(?<east>.+)\/(?<north>.+?)-+(?<south>.+)/
346
484
  end
347
485
 
348
486
  # Format that pairs hemispheres with decimal degrees.
@@ -350,21 +488,21 @@ module CocinaDisplay
350
488
  class DecimalBoundingBoxParser < BoundingBoxParser
351
489
  include DecimalParser
352
490
 
353
- PATTERN = /(?<min_lng>[0-9.EW]+?)-+(?<max_lng>[0-9.EW]+)\/(?<max_lat>[0-9.NS]+?)-+(?<min_lat>[0-9.NS]+)/
491
+ PATTERN = /(?<west>[0-9.EW]+?)-+(?<east>[0-9.EW]+)\/(?<north>[0-9.NS]+?)-+(?<south>[0-9.NS]+)/
354
492
  end
355
493
 
356
494
  # DMS-format data that appears to come from MARC 034 subfields.
357
495
  # @see https://www.oclc.org/bibformats/en/0xx/034.html
358
496
  # @example $dW0963700$eW0900700$fN0433000$gN040220
359
497
  class MarcDMSBoundingBoxParser < DMSBoundingBoxParser
360
- PATTERN = /\$d(?<min_lng>[WENS].+)\$e(?<max_lng>[WENS].+)\$f(?<max_lat>[WENS].+)\$g(?<min_lat>[WENS].+)/
498
+ PATTERN = /\$d(?<west>[WENS].+)\$e(?<east>[WENS].+)\$f(?<north>[WENS].+)\$g(?<south>[WENS].+)/
361
499
  end
362
500
 
363
501
  # Decimal degree format data that appears to come from MARC 034 subfields.
364
502
  # @see https://www.oclc.org/bibformats/en/0xx/034.html
365
503
  # @example $d-112.0785250$e-111.6012719$f037.6516503$g036.8583209
366
504
  class MarcDecimalBoundingBoxParser < DecimalBoundingBoxParser
367
- PATTERN = /\$d(?<min_lng>[0-9.-]+)\$e(?<max_lng>[0-9.-]+)\$f(?<max_lat>[0-9.-]+)\$g(?<min_lat>[0-9.-]+)/
505
+ PATTERN = /\$d(?<west>[0-9.-]+)\$e(?<east>[0-9.-]+)\$f(?<north>[0-9.-]+)\$g(?<south>[0-9.-]+)/
368
506
  end
369
507
  end
370
508
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  # :nodoc:
4
4
  module CocinaDisplay
5
- VERSION = "2.11.0" # :nodoc:
5
+ VERSION = "2.12.0" # :nodoc:
6
6
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cocina_display
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.11.0
4
+ version: 2.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Budak
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
10
+ date: 2026-08-11 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: janeway-jsonpath
@@ -326,7 +326,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
326
326
  - !ruby/object:Gem::Version
327
327
  version: '0'
328
328
  requirements: []
329
- rubygems_version: 4.0.6
329
+ rubygems_version: 3.6.2
330
330
  specification_version: 4
331
331
  summary: Helpers for rendering Cocina metadata
332
332
  test_files: []