iiif-presentation 1.4.2 → 1.5.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 +4 -4
- data/.github/workflows/ruby.yml +8 -1
- data/.standard.yml +1 -0
- data/Gemfile +5 -5
- data/README.md +1 -1
- data/Rakefile +4 -2
- data/VERSION +1 -1
- data/bin/standardrb +16 -0
- data/iiif-presentation.gemspec +23 -23
- data/lib/iiif/hash_behaviours.rb +29 -33
- data/lib/iiif/ordered_hash.rb +33 -33
- data/lib/iiif/presentation/abstract_resource.rb +17 -22
- data/lib/iiif/presentation/annotation.rb +9 -11
- data/lib/iiif/presentation/annotation_list.rb +8 -10
- data/lib/iiif/presentation/canvas.rb +11 -13
- data/lib/iiif/presentation/collection.rb +7 -9
- data/lib/iiif/presentation/image_resource.rb +33 -35
- data/lib/iiif/presentation/layer.rb +8 -10
- data/lib/iiif/presentation/manifest.rb +9 -12
- data/lib/iiif/presentation/range.rb +8 -10
- data/lib/iiif/presentation/resource.rb +5 -7
- data/lib/iiif/presentation/sequence.rb +8 -11
- data/lib/iiif/presentation/service.rb +1 -2
- data/lib/iiif/presentation.rb +7 -7
- data/lib/iiif/service.rb +121 -102
- data/lib/iiif/v3/abstract_resource.rb +127 -130
- data/lib/iiif/v3/presentation/annotation.rb +20 -21
- data/lib/iiif/v3/presentation/annotation_collection.rb +7 -9
- data/lib/iiif/v3/presentation/annotation_page.rb +14 -15
- data/lib/iiif/v3/presentation/canvas.rb +21 -22
- data/lib/iiif/v3/presentation/choice.rb +14 -15
- data/lib/iiif/v3/presentation/collection.rb +7 -8
- data/lib/iiif/v3/presentation/image_resource.rb +21 -22
- data/lib/iiif/v3/presentation/manifest.rb +18 -19
- data/lib/iiif/v3/presentation/nav_place.rb +125 -29
- data/lib/iiif/v3/presentation/range.rb +10 -10
- data/lib/iiif/v3/presentation/resource.rb +4 -5
- data/lib/iiif/v3/presentation/sequence.rb +10 -11
- data/lib/iiif/v3/presentation/service.rb +12 -13
- data/lib/iiif/v3/presentation.rb +8 -8
- data/spec/integration/iiif/presentation/image_resource_spec.rb +54 -58
- data/spec/integration/iiif/service_spec.rb +76 -77
- data/spec/integration/iiif/v3/abstract_resource_spec.rb +72 -73
- data/spec/integration/iiif/v3/presentation/image_resource_spec.rb +53 -56
- data/spec/spec_helper.rb +56 -64
- data/spec/unit/iiif/hash_behaviours_spec.rb +384 -387
- data/spec/unit/iiif/ordered_hash_spec.rb +86 -91
- data/spec/unit/iiif/presentation/abstract_resource_spec.rb +64 -70
- data/spec/unit/iiif/presentation/annotation_list_spec.rb +1 -3
- data/spec/unit/iiif/presentation/annotation_spec.rb +1 -3
- data/spec/unit/iiif/presentation/canvas_spec.rb +11 -14
- data/spec/unit/iiif/presentation/collection_spec.rb +26 -33
- data/spec/unit/iiif/presentation/image_resource_spec.rb +4 -6
- data/spec/unit/iiif/presentation/layer_spec.rb +16 -20
- data/spec/unit/iiif/presentation/manifest_spec.rb +45 -48
- data/spec/unit/iiif/presentation/range_spec.rb +18 -22
- data/spec/unit/iiif/presentation/resource_spec.rb +3 -6
- data/spec/unit/iiif/presentation/sequence_spec.rb +58 -63
- data/spec/unit/iiif/presentation/shared_examples/abstract_resource_only_keys.rb +4 -10
- data/spec/unit/iiif/presentation/shared_examples/any_type_keys.rb +5 -9
- data/spec/unit/iiif/presentation/shared_examples/array_only_keys.rb +9 -17
- data/spec/unit/iiif/presentation/shared_examples/int_only_keys.rb +6 -14
- data/spec/unit/iiif/presentation/shared_examples/string_only_keys.rb +6 -13
- data/spec/unit/iiif/service_spec.rb +8 -13
- data/spec/unit/iiif/v3/abstract_resource_define_methods_for_spec.rb +39 -37
- data/spec/unit/iiif/v3/abstract_resource_spec.rb +124 -131
- data/spec/unit/iiif/v3/presentation/annotation_collection_spec.rb +14 -17
- data/spec/unit/iiif/v3/presentation/annotation_page_spec.rb +71 -69
- data/spec/unit/iiif/v3/presentation/annotation_spec.rb +249 -233
- data/spec/unit/iiif/v3/presentation/canvas_spec.rb +211 -193
- data/spec/unit/iiif/v3/presentation/choice_spec.rb +81 -77
- data/spec/unit/iiif/v3/presentation/collection_spec.rb +24 -29
- data/spec/unit/iiif/v3/presentation/image_resource_spec.rb +70 -71
- data/spec/unit/iiif/v3/presentation/manifest_spec.rb +248 -235
- data/spec/unit/iiif/v3/presentation/nav_place_spec.rb +249 -49
- data/spec/unit/iiif/v3/presentation/range_spec.rb +28 -29
- data/spec/unit/iiif/v3/presentation/resource_spec.rb +83 -82
- data/spec/unit/iiif/v3/presentation/sequence_spec.rb +125 -119
- data/spec/unit/iiif/v3/presentation/service_spec.rb +123 -124
- data/spec/unit/iiif/v3/presentation/shared_examples/abstract_resource_only_keys.rb +4 -8
- data/spec/unit/iiif/v3/presentation/shared_examples/any_type_keys.rb +5 -7
- data/spec/unit/iiif/v3/presentation/shared_examples/array_only_keys.rb +9 -13
- data/spec/unit/iiif/v3/presentation/shared_examples/hash_only_keys.rb +9 -13
- data/spec/unit/iiif/v3/presentation/shared_examples/int_only_keys.rb +6 -10
- data/spec/unit/iiif/v3/presentation/shared_examples/numeric_only_keys.rb +6 -10
- data/spec/unit/iiif/v3/presentation/shared_examples/string_only_keys.rb +6 -10
- data/spec/unit/iiif/v3/presentation/shared_examples/uri_only_keys.rb +9 -13
- metadata +26 -65
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
require
|
|
1
|
+
require "geo/coord"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
2
4
|
|
|
3
5
|
module IIIF
|
|
4
6
|
module V3
|
|
@@ -6,8 +8,35 @@ module IIIF
|
|
|
6
8
|
class NavPlace < IIIF::V3::AbstractResource
|
|
7
9
|
Rect = Struct.new(:coord1, :coord2)
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
DECIMAL = /\d+(?:\.\d+)?/
|
|
12
|
+
|
|
13
|
+
# Matches one coordinate, in either of two forms:
|
|
14
|
+
# - degrees with a degree sign, optionally followed by minutes and seconds,
|
|
15
|
+
# any of which may be decimal, with the hemisphere before or after
|
|
16
|
+
# ("W 23°54'00\"", "23°54′00″W", "E 138.5°", "N 37°26ʹ30.5″");
|
|
17
|
+
# - decimal degrees with no degree sign, which need the hemisphere first
|
|
18
|
+
# ("E 138.0").
|
|
19
|
+
COORD_REGEX = /
|
|
20
|
+
(?:(?<hemisphere>[NSEW])\s*)?(?<![\d.])(?<degrees>#{DECIMAL})[°⁰*]
|
|
21
|
+
(?:\s*(?<minutes>#{DECIMAL})['ʹ′])?
|
|
22
|
+
(?:\s*(?<seconds>#{DECIMAL})["ʺ″])?
|
|
23
|
+
(?:\s*(?<hemisphere>[NSEW]))?
|
|
24
|
+
|
|
|
25
|
+
(?<hemisphere>[NSEW])\s*(?<degrees>\d+\.\d+)(?![\d.°⁰*'ʹ′"ʺ″])
|
|
26
|
+
/x
|
|
27
|
+
|
|
28
|
+
# WKT versions of points and boxes (polygon/multipolygon).
|
|
29
|
+
# See: https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry
|
|
30
|
+
WKT_REGEX = /\A\s*(?<type>POINT|POLYGON|MULTIPOLYGON)\s*(?<body>\(.*\))\s*\z/im
|
|
31
|
+
WKT_NUMBER = /[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[-+]?\d+)?/i
|
|
32
|
+
|
|
33
|
+
# @param [Array<String>] coordinate_texts points or boxes, either as text
|
|
34
|
+
# ("E 103°48ʹ/S 3°46ʹ", "W 23°54ʹ--E 53°36ʹ/N 71°19ʹ--N 33°30ʹ") or as a WKT
|
|
35
|
+
# POINT, POLYGON or MULTIPOLYGON; any that can't be read are skipped
|
|
36
|
+
# @param [String, nil] base_uri optional prefix for the Feature Collection and Feature ids
|
|
37
|
+
def initialize(coordinate_texts:, base_uri: nil)
|
|
38
|
+
raise ArgumentError, "base_uri must be an absolute HTTP(S) URI, got #{base_uri.inspect}" unless base_uri.nil? || base_uri.start_with?(/https?:\/\//)
|
|
39
|
+
|
|
11
40
|
@coordinate_texts = coordinate_texts
|
|
12
41
|
@base_uri = base_uri
|
|
13
42
|
end
|
|
@@ -18,13 +47,11 @@ module IIIF
|
|
|
18
47
|
end
|
|
19
48
|
|
|
20
49
|
def build
|
|
21
|
-
raise ArgumentError.new(
|
|
50
|
+
raise ArgumentError.new("invalid coordinates") unless valid?
|
|
22
51
|
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
features: features
|
|
27
|
-
}
|
|
52
|
+
with_id("#{base_uri}/feature-collection/1",
|
|
53
|
+
type: "FeatureCollection",
|
|
54
|
+
features: features)
|
|
28
55
|
end
|
|
29
56
|
|
|
30
57
|
private
|
|
@@ -33,6 +60,8 @@ module IIIF
|
|
|
33
60
|
|
|
34
61
|
def coordinates
|
|
35
62
|
@coordinates ||= coordinate_texts.map do |coordinate_text|
|
|
63
|
+
next wkt_geometry(coordinate_text) if coordinate_text.match?(WKT_REGEX)
|
|
64
|
+
|
|
36
65
|
coordinate_parts = coordinate_text.split(%r{ ?--|/})
|
|
37
66
|
case coordinate_parts.length
|
|
38
67
|
when 2
|
|
@@ -49,7 +78,9 @@ module IIIF
|
|
|
49
78
|
return unless long_matcher && lat_matcher
|
|
50
79
|
|
|
51
80
|
Geo::Coord.new(latd: lat_matcher[:degrees], latm: lat_matcher[:minutes], lats: lat_matcher[:seconds], lath: lat_matcher[:hemisphere],
|
|
52
|
-
|
|
81
|
+
lngd: long_matcher[:degrees], lngm: long_matcher[:minutes], lngs: long_matcher[:seconds], lngh: long_matcher[:hemisphere])
|
|
82
|
+
rescue ArgumentError
|
|
83
|
+
nil
|
|
53
84
|
end
|
|
54
85
|
|
|
55
86
|
def rect_for(coordinate_parts)
|
|
@@ -60,47 +91,112 @@ module IIIF
|
|
|
60
91
|
Rect.new(coord1, coord2)
|
|
61
92
|
end
|
|
62
93
|
|
|
94
|
+
# Turn WKT strings into GeoJSON geometry.
|
|
95
|
+
# @return [Hash, nil] the GeoJSON geometry, or nil if the WKT isn't one we can use
|
|
96
|
+
def wkt_geometry(text)
|
|
97
|
+
match = text.match(WKT_REGEX)
|
|
98
|
+
body = match[:body].gsub(/(#{WKT_NUMBER})\s+(#{WKT_NUMBER})/o) { "[#{Float($1).round(6)},#{Float($2).round(6)}]" }
|
|
99
|
+
coordinates = JSON.parse(body.tr("()", "[]"))
|
|
100
|
+
|
|
101
|
+
case match[:type].upcase
|
|
102
|
+
when "POINT"
|
|
103
|
+
{type: "Point", coordinates: coordinates.first} if coordinates.length == 1 && point?(coordinates.first)
|
|
104
|
+
when "POLYGON"
|
|
105
|
+
{type: "Polygon", coordinates: oriented(coordinates)} if polygon?(coordinates)
|
|
106
|
+
when "MULTIPOLYGON"
|
|
107
|
+
{type: "MultiPolygon", coordinates: coordinates.map { |polygon| oriented(polygon) }} if coordinates.any? && coordinates.all? { |polygon| polygon?(polygon) }
|
|
108
|
+
end
|
|
109
|
+
rescue JSON::ParserError, ArgumentError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def point?(value)
|
|
114
|
+
value.is_a?(Array) && value.length == 2 && value.all?(Numeric) && value[0].between?(-180, 180) && value[1].between?(-90, 90)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def polygon?(value)
|
|
118
|
+
value.is_a?(Array) && value.any? && value.all? do |ring|
|
|
119
|
+
ring.is_a?(Array) && ring.length >= 4 && ring.all? { |position| point?(position) } && ring.first == ring.last
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# WKT doesn't fix which way rings run, so turn the exterior counterclockwise
|
|
124
|
+
# and any holes clockwise (RFC 7946, section 3.1.6).
|
|
125
|
+
def oriented(polygon)
|
|
126
|
+
polygon.each_with_index.map do |ring, index|
|
|
127
|
+
area = ring.each_cons(2).sum { |(x1, y1), (x2, y2)| x1 * y2 - x2 * y1 }
|
|
128
|
+
((area > 0) == index.zero?) ? ring : ring.reverse
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
63
132
|
def features
|
|
64
133
|
coordinates.map.with_index(1) do |coordinate, index|
|
|
65
|
-
{
|
|
66
|
-
|
|
67
|
-
type: 'Feature',
|
|
134
|
+
with_id("#{base_uri}/iiif/feature/#{index}",
|
|
135
|
+
type: "Feature",
|
|
68
136
|
properties: {},
|
|
69
|
-
geometry:
|
|
70
|
-
}
|
|
137
|
+
geometry: geometry_for(coordinate))
|
|
71
138
|
end
|
|
72
139
|
end
|
|
73
140
|
|
|
141
|
+
def geometry_for(coordinate)
|
|
142
|
+
case coordinate
|
|
143
|
+
when Rect then polygon_geometry(coordinate)
|
|
144
|
+
when Geo::Coord then point_geometry(coordinate)
|
|
145
|
+
else coordinate # already a GeoJSON geometry, read from WKT
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The navPlace extension makes ids optional, so leave them out without a base_uri.
|
|
150
|
+
def with_id(id, resource)
|
|
151
|
+
base_uri ? {id: id}.merge(resource) : resource
|
|
152
|
+
end
|
|
153
|
+
|
|
74
154
|
def point_geometry(coord)
|
|
75
155
|
{
|
|
76
|
-
type:
|
|
156
|
+
type: "Point",
|
|
77
157
|
coordinates: [format(coord.lng), format(coord.lat)]
|
|
78
158
|
}
|
|
79
159
|
end
|
|
80
160
|
|
|
81
161
|
def polygon_geometry(rect)
|
|
162
|
+
west = edge(format(rect.coord1.lng), from: 180.0, to: -180.0)
|
|
163
|
+
east = edge(format(rect.coord2.lng), from: -180.0, to: 180.0)
|
|
164
|
+
south, north = [format(rect.coord1.lat), format(rect.coord2.lat)].minmax
|
|
165
|
+
|
|
166
|
+
return {type: "Polygon", coordinates: [ring(west, east, south, north)]} unless west > east
|
|
167
|
+
|
|
168
|
+
# A box reads west to east, so a west edge greater than its east edge means
|
|
169
|
+
# the box crosses the antimeridian. GeoJSON has no wrapping boxes, so such a
|
|
170
|
+
# box is split there into two polygons (RFC 7946, section 3.1.9).
|
|
82
171
|
{
|
|
83
|
-
type:
|
|
172
|
+
type: "MultiPolygon",
|
|
84
173
|
coordinates: [
|
|
85
|
-
[
|
|
86
|
-
|
|
87
|
-
[format(rect.coord2.lng), format(rect.coord1.lat)],
|
|
88
|
-
[format(rect.coord2.lng), format(rect.coord2.lat)],
|
|
89
|
-
[format(rect.coord1.lng), format(rect.coord2.lat)],
|
|
90
|
-
[format(rect.coord1.lng), format(rect.coord1.lat)]
|
|
91
|
-
]
|
|
174
|
+
[ring(west, 180.0, south, north)],
|
|
175
|
+
[ring(-180.0, east, south, north)]
|
|
92
176
|
]
|
|
93
177
|
}
|
|
94
178
|
end
|
|
95
179
|
|
|
180
|
+
# If a box edge sits right on the antimeridian it could be counted as
|
|
181
|
+
# 180 or -180; we pick whichever one won't require splitting (see above).
|
|
182
|
+
def edge(lng, from:, to:)
|
|
183
|
+
(lng == from) ? to : lng
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Exterior rings run counterclockwise (RFC 7946, section 3.1.6).
|
|
187
|
+
def ring(west, east, south, north)
|
|
188
|
+
[[west, south], [east, south], [east, north], [west, north], [west, south]]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# GeoJSON positions are arrays of numbers (RFC 7946, section 3.1.1), so
|
|
192
|
+
# coordinates are emitted as Floats rather than strings.
|
|
96
193
|
# @param [BigDecimal] coordinate value from geocoord gem
|
|
97
|
-
# @return [
|
|
98
|
-
# The to_f ensures removal of scientific notation of BigDecimal before converting to a string.
|
|
194
|
+
# @return [Float] value rounded to at most 6 digits after the decimal point
|
|
99
195
|
# examples:
|
|
100
|
-
# input value is BigDecimal("-23.9") or "0.239e2", output value is
|
|
101
|
-
# input value is BigDecimal("23.
|
|
196
|
+
# input value is BigDecimal("-23.9") or "0.239e2", output value is -23.9
|
|
197
|
+
# input value is BigDecimal("23.9424216434") or "0.239424216434e2", output value is 23.942422
|
|
102
198
|
def format(decimal)
|
|
103
|
-
decimal.
|
|
199
|
+
decimal.round(6).to_f
|
|
104
200
|
end
|
|
105
201
|
end
|
|
106
202
|
end
|
|
@@ -3,33 +3,33 @@ module IIIF
|
|
|
3
3
|
module Presentation
|
|
4
4
|
# Ranges are linked or embedded within the manifest in a structures field
|
|
5
5
|
class Range < Sequence
|
|
6
|
-
TYPE =
|
|
6
|
+
TYPE = "Range".freeze
|
|
7
7
|
VALID_ITEM_TYPES = [IIIF::V3::Presentation::Canvas, IIIF::V3::Presentation::Range]
|
|
8
8
|
|
|
9
9
|
def required_keys
|
|
10
|
-
super + %w
|
|
10
|
+
super + %w[id label]
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
# TODO: contentAnnotations: links to AnnotationCollection
|
|
14
14
|
# TODO: startCanvas: A link from a Sequence or Range to a Canvas that is contained within it
|
|
15
15
|
|
|
16
16
|
def array_only_keys
|
|
17
|
-
super + %w
|
|
17
|
+
super + %w[members]
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def legal_viewing_hint_values
|
|
21
|
-
super + %w
|
|
21
|
+
super + %w[top]
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def initialize(hsh={})
|
|
25
|
-
hsh[
|
|
26
|
-
super
|
|
24
|
+
def initialize(hsh = {})
|
|
25
|
+
hsh["type"] = TYPE unless hsh.has_key? "type"
|
|
26
|
+
super
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def validate
|
|
30
30
|
super
|
|
31
|
-
validate_list(self[
|
|
32
|
-
validate_list(self[
|
|
31
|
+
validate_list(self["items"]) if self["items"]
|
|
32
|
+
validate_list(self["canvases"]) if self["canvases"]
|
|
33
33
|
# TODO: Ranges must have URIs and they should be http(s) URIs.
|
|
34
34
|
# TODO: contentAnnotations: links to AnnotationCollection
|
|
35
35
|
# TODO: startCanvas: A link from a Sequence or Range to a Canvas that is contained within it
|
|
@@ -40,7 +40,7 @@ module IIIF
|
|
|
40
40
|
def validate_list(canvas_array)
|
|
41
41
|
return if canvas_array.all? { |entry| VALID_ITEM_TYPES.include?(entry.class) }
|
|
42
42
|
|
|
43
|
-
m = "All entries in the (items or canvases) array must be one of #{VALID_ITEM_TYPES.join(
|
|
43
|
+
m = "All entries in the (items or canvases) array must be one of #{VALID_ITEM_TYPES.join(", ")}"
|
|
44
44
|
raise IIIF::V3::Presentation::IllegalValueError, m
|
|
45
45
|
end
|
|
46
46
|
end
|
|
@@ -3,23 +3,22 @@ module IIIF
|
|
|
3
3
|
module Presentation
|
|
4
4
|
# class for generic content resource
|
|
5
5
|
class Resource < IIIF::V3::AbstractResource
|
|
6
|
-
|
|
7
6
|
def required_keys
|
|
8
|
-
super + %w
|
|
7
|
+
super + %w[id]
|
|
9
8
|
end
|
|
10
9
|
|
|
11
10
|
def prohibited_keys
|
|
12
|
-
super + PAGING_PROPERTIES + %w
|
|
11
|
+
super + PAGING_PROPERTIES + %w[nav_date viewing_direction start_canvas content_annotations]
|
|
13
12
|
end
|
|
14
13
|
|
|
15
14
|
def uri_only_keys
|
|
16
|
-
super + %w
|
|
15
|
+
super + %w[id]
|
|
17
16
|
end
|
|
18
17
|
|
|
19
18
|
def validate
|
|
20
19
|
super
|
|
21
20
|
|
|
22
|
-
unless self[
|
|
21
|
+
unless /^https?:/.match?(self["id"])
|
|
23
22
|
err_msg = "id must be an http(s) URI for #{self.class}"
|
|
24
23
|
raise IIIF::V3::Presentation::IllegalValueError, err_msg
|
|
25
24
|
end
|
|
@@ -2,8 +2,7 @@ module IIIF
|
|
|
2
2
|
module V3
|
|
3
3
|
module Presentation
|
|
4
4
|
class Sequence < IIIF::V3::AbstractResource
|
|
5
|
-
|
|
6
|
-
TYPE = 'Sequence'.freeze
|
|
5
|
+
TYPE = "Sequence".freeze
|
|
7
6
|
|
|
8
7
|
# NOTE: relaxing requirement for items as Universal Viewer currently only accepts canvases
|
|
9
8
|
# see https://github.com/sul-dlss/osullivan/issues/27, sul-dlss/purl/issues/167
|
|
@@ -12,22 +11,22 @@ module IIIF
|
|
|
12
11
|
# end
|
|
13
12
|
|
|
14
13
|
def prohibited_keys
|
|
15
|
-
super + CONTENT_RESOURCE_PROPERTIES + PAGING_PROPERTIES + %w
|
|
14
|
+
super + CONTENT_RESOURCE_PROPERTIES + PAGING_PROPERTIES + %w[nav_date content_annotations]
|
|
16
15
|
end
|
|
17
16
|
|
|
18
17
|
# NOTE: allowing 'items' or 'canvases' as Universal Viewer currently only accepts canvases
|
|
19
18
|
# see https://github.com/sul-dlss/osullivan/issues/27, sul-dlss/purl/issues/167
|
|
20
19
|
def array_only_keys
|
|
21
|
-
super + %w
|
|
20
|
+
super + %w[canvases]
|
|
22
21
|
end
|
|
23
22
|
|
|
24
23
|
def legal_viewing_hint_values
|
|
25
|
-
%w
|
|
24
|
+
%w[individuals paged continuous auto-advance]
|
|
26
25
|
end
|
|
27
26
|
|
|
28
|
-
def initialize(hsh={})
|
|
29
|
-
hsh[
|
|
30
|
-
super
|
|
27
|
+
def initialize(hsh = {})
|
|
28
|
+
hsh["type"] = TYPE unless hsh.has_key? "type"
|
|
29
|
+
super
|
|
31
30
|
end
|
|
32
31
|
|
|
33
32
|
def validate
|
|
@@ -36,9 +35,9 @@ module IIIF
|
|
|
36
35
|
# Canvas object list
|
|
37
36
|
# NOTE: allowing 'items' or 'canvases' as Universal Viewer currently only accepts canvases
|
|
38
37
|
# see https://github.com/sul-dlss/osullivan/issues/27, sul-dlss/purl/issues/167
|
|
39
|
-
unless
|
|
40
|
-
|
|
41
|
-
m =
|
|
38
|
+
unless self["items"]&.any? ||
|
|
39
|
+
self["canvases"]&.any?
|
|
40
|
+
m = "The (items or canvases) list must have at least one entry."
|
|
42
41
|
raise IIIF::V3::Presentation::MissingRequiredKeyError, m
|
|
43
42
|
end
|
|
44
43
|
# TODO: startCanvas: A link from a Sequence or Range to a Canvas that is contained within it
|
|
@@ -3,43 +3,42 @@ module IIIF
|
|
|
3
3
|
module Presentation
|
|
4
4
|
# See http://prezi3.iiif.io/api/annex/services for more info
|
|
5
5
|
class Service < AbstractResource
|
|
6
|
-
|
|
7
6
|
# constants included here for convenience
|
|
8
|
-
IIIF_IMAGE_V2_TYPE =
|
|
9
|
-
IIIF_IMAGE_V2_LEVEL1_PROFILE =
|
|
10
|
-
IIIF_AUTHENTICATION_V1_LOGIN_PROFILE =
|
|
11
|
-
IIIF_AUTHENTICATION_V1_TOKEN_PROFILE =
|
|
7
|
+
IIIF_IMAGE_V2_TYPE = "ImageService2".freeze
|
|
8
|
+
IIIF_IMAGE_V2_LEVEL1_PROFILE = "http://iiif.io/api/image/2/level1.json".freeze
|
|
9
|
+
IIIF_AUTHENTICATION_V1_LOGIN_PROFILE = "http://iiif.io/api/auth/1/login".freeze
|
|
10
|
+
IIIF_AUTHENTICATION_V1_TOKEN_PROFILE = "http://iiif.io/api/auth/1/token".freeze
|
|
12
11
|
|
|
13
12
|
# service class doesn't require type
|
|
14
13
|
def required_keys
|
|
15
|
-
super.reject {|el| el ==
|
|
14
|
+
super.reject { |el| el == "type" }
|
|
16
15
|
end
|
|
17
16
|
|
|
18
17
|
def prohibited_keys
|
|
19
18
|
super + CONTENT_RESOURCE_PROPERTIES + PAGING_PROPERTIES +
|
|
20
|
-
%w
|
|
19
|
+
%w[nav_date viewing_direction start_canvas content_annotations]
|
|
21
20
|
end
|
|
22
21
|
|
|
23
22
|
def uri_only_keys
|
|
24
|
-
super + %w
|
|
23
|
+
super + %w[@context id @id]
|
|
25
24
|
end
|
|
26
25
|
|
|
27
26
|
def any_type_keys
|
|
28
|
-
super + %w
|
|
27
|
+
super + %w[profile]
|
|
29
28
|
end
|
|
30
29
|
|
|
31
30
|
def validate
|
|
32
31
|
super
|
|
33
|
-
if
|
|
34
|
-
unless
|
|
32
|
+
if self["type"] == IIIF_IMAGE_V2_TYPE || self["@type"] == IIIF_IMAGE_V2_TYPE
|
|
33
|
+
unless has_key?("id") || has_key?("@id")
|
|
35
34
|
m = "id or @id values are required for IIIF::V3::Presentation::Service with type or @type #{IIIF_IMAGE_V2_TYPE}"
|
|
36
35
|
raise IIIF::V3::Presentation::MissingRequiredKeyError, m
|
|
37
36
|
end
|
|
38
|
-
if
|
|
37
|
+
if has_key?("id") && has_key?("@id") && (self["@id"] != self["id"])
|
|
39
38
|
m = "id and @id values must match for IIIF::V3::Presentation::Service with type or @type #{IIIF_IMAGE_V2_TYPE}"
|
|
40
39
|
raise IIIF::V3::Presentation::IllegalValueError, m
|
|
41
40
|
end
|
|
42
|
-
unless
|
|
41
|
+
unless has_key?("profile")
|
|
43
42
|
m = "profile should be present for IIIF::V3::Presentation::Service with type or @type #{IIIF_IMAGE_V2_TYPE}"
|
|
44
43
|
raise IIIF::V3::Presentation::MissingRequiredKeyError, m
|
|
45
44
|
end
|
data/lib/iiif/v3/presentation.rb
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
require_relative
|
|
2
|
-
require_relative
|
|
1
|
+
require_relative "abstract_resource"
|
|
2
|
+
require_relative "../ordered_hash"
|
|
3
3
|
|
|
4
4
|
# NOTE: image_resource must follow resource due to inheritance
|
|
5
5
|
# NOTE: range must follow sequence due to inheritance
|
|
6
|
-
%w
|
|
6
|
+
%w[
|
|
7
7
|
annotation
|
|
8
8
|
annotation_collection
|
|
9
9
|
annotation_page
|
|
@@ -17,16 +17,16 @@ require_relative '../ordered_hash'
|
|
|
17
17
|
sequence
|
|
18
18
|
range
|
|
19
19
|
service
|
|
20
|
-
|
|
21
|
-
require File.join(File.dirname(__FILE__),
|
|
20
|
+
].each do |f|
|
|
21
|
+
require File.join(File.dirname(__FILE__), "presentation", f)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
module IIIF
|
|
25
25
|
module V3
|
|
26
26
|
module Presentation
|
|
27
|
-
CONTEXT
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
CONTEXT = [
|
|
28
|
+
"http://www.w3.org/ns/anno.jsonld",
|
|
29
|
+
"http://iiif.io/api/presentation/3/context.json"
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
class MissingRequiredKeyError < StandardError; end
|
|
@@ -1,122 +1,118 @@
|
|
|
1
1
|
describe IIIF::Presentation::ImageResource do
|
|
2
2
|
vcr_options = {
|
|
3
|
-
cassette_name:
|
|
3
|
+
cassette_name: "pul_loris_cassette",
|
|
4
4
|
record: :new_episodes,
|
|
5
5
|
serialize_with: :json
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
describe
|
|
9
|
-
|
|
10
|
-
let(:image_server) { 'https://libimages.princeton.edu/loris' }
|
|
8
|
+
describe "self#create_image_api_image_resource", vcr: vcr_options do
|
|
9
|
+
let(:image_server) { "https://libimages.princeton.edu/loris" }
|
|
11
10
|
|
|
12
11
|
let(:valid_service_id) {
|
|
13
|
-
id =
|
|
12
|
+
id = "pudl0001%2F4612422%2F00000001.jp2"
|
|
14
13
|
"#{image_server}/#{id}"
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
let(:invalid_service_id) {
|
|
18
|
-
id =
|
|
17
|
+
id = "xxxx%2F4612422%2F00000001.jp2"
|
|
19
18
|
"#{image_server}/#{id}"
|
|
20
19
|
}
|
|
21
20
|
|
|
22
|
-
it
|
|
21
|
+
it "returns an ImageResource" do
|
|
23
22
|
instance = described_class.create_image_api_image_resource(service_id: valid_service_id)
|
|
24
23
|
expect(instance.class).to be described_class
|
|
25
24
|
end
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
opts = { service_id: valid_service_id }
|
|
26
|
+
describe "has expected values from our fixture" do
|
|
27
|
+
it "when copy_info is false" do
|
|
28
|
+
opts = {service_id: valid_service_id}
|
|
31
29
|
resource = described_class.create_image_api_image_resource(opts)
|
|
32
30
|
# expect(resource['@context']).to eq 'http://iiif.io/api/presentation/2/context.json'
|
|
33
31
|
# @context is only added when we call to_json...
|
|
34
|
-
expect(resource[
|
|
35
|
-
expect(resource[
|
|
32
|
+
expect(resource["@id"]).to eq "https://libimages.princeton.edu/loris/pudl0001%2F4612422%2F00000001.jp2/full/!200,200/0/default.jpg"
|
|
33
|
+
expect(resource["@type"]).to eq "dctypes:Image"
|
|
36
34
|
expect(resource.format).to eq "image/jpeg"
|
|
37
35
|
expect(resource.width).to eq 3047
|
|
38
36
|
expect(resource.height).to eq 7200
|
|
39
|
-
expect(resource.service[
|
|
40
|
-
expect(resource.service[
|
|
41
|
-
expect(resource.service[
|
|
37
|
+
expect(resource.service["@context"]).to eq "http://iiif.io/api/image/2/context.json"
|
|
38
|
+
expect(resource.service["@id"]).to eq "https://libimages.princeton.edu/loris/pudl0001%2F4612422%2F00000001.jp2"
|
|
39
|
+
expect(resource.service["profile"]).to eq "http://iiif.io/api/image/2/level2.json"
|
|
42
40
|
end
|
|
43
|
-
it
|
|
44
|
-
opts = {
|
|
41
|
+
it "copies over all the info (when copy_info is true)" do
|
|
42
|
+
opts = {service_id: valid_service_id, copy_info: true}
|
|
45
43
|
resource = described_class.create_image_api_image_resource(opts)
|
|
46
|
-
expect(resource[
|
|
47
|
-
expect(resource[
|
|
44
|
+
expect(resource["@id"]).to eq "https://libimages.princeton.edu/loris/pudl0001%2F4612422%2F00000001.jp2/full/!200,200/0/default.jpg"
|
|
45
|
+
expect(resource["@type"]).to eq "dctypes:Image"
|
|
48
46
|
expect(resource.format).to eq "image/jpeg"
|
|
49
47
|
expect(resource.width).to eq 3047
|
|
50
48
|
expect(resource.height).to eq 7200
|
|
51
|
-
expect(resource.service[
|
|
52
|
-
expect(resource.service[
|
|
53
|
-
expect(resource.service[
|
|
54
|
-
|
|
49
|
+
expect(resource.service["@context"]).to eq "http://iiif.io/api/image/2/context.json"
|
|
50
|
+
expect(resource.service["@id"]).to eq "https://libimages.princeton.edu/loris/pudl0001%2F4612422%2F00000001.jp2"
|
|
51
|
+
expect(resource.service["profile"]).to eq [
|
|
52
|
+
"http://iiif.io/api/image/2/level2.json",
|
|
55
53
|
{
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
"supports" => [
|
|
55
|
+
"canonicalLinkHeader", "profileLinkHeader", "mirroring",
|
|
56
|
+
"rotationArbitrary", "regionSquare", "sizeAboveFull"
|
|
59
57
|
],
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
"qualities" => ["default", "bitonal", "gray", "color"],
|
|
59
|
+
"formats" => ["jpg", "png", "gif", "webp"]
|
|
62
60
|
}
|
|
63
61
|
]
|
|
64
|
-
expect(resource.service[
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
expect(resource.service[
|
|
69
|
-
{
|
|
70
|
-
{
|
|
71
|
-
{
|
|
72
|
-
{
|
|
73
|
-
{
|
|
74
|
-
{
|
|
62
|
+
expect(resource.service["tiles"]).to eq [{
|
|
63
|
+
"width" => 1024,
|
|
64
|
+
"scaleFactors" => [1, 2, 4, 8, 16, 32]
|
|
65
|
+
}]
|
|
66
|
+
expect(resource.service["sizes"]).to eq [
|
|
67
|
+
{"width" => 96, "height" => 225},
|
|
68
|
+
{"width" => 191, "height" => 450},
|
|
69
|
+
{"width" => 381, "height" => 900},
|
|
70
|
+
{"width" => 762, "height" => 1800},
|
|
71
|
+
{"width" => 1524, "height" => 3600},
|
|
72
|
+
{"width" => 3047, "height" => 7200}
|
|
75
73
|
]
|
|
76
74
|
end
|
|
77
75
|
end
|
|
78
76
|
|
|
79
|
-
describe
|
|
80
|
-
it
|
|
81
|
-
r_id =
|
|
77
|
+
describe "respects the params we supply" do
|
|
78
|
+
it ":resource_id" do
|
|
79
|
+
r_id = "http://example.edu/images/some.jpg"
|
|
82
80
|
|
|
83
|
-
opts = {
|
|
81
|
+
opts = {service_id: valid_service_id, resource_id: r_id}
|
|
84
82
|
resource = described_class.create_image_api_image_resource(opts)
|
|
85
|
-
expect(resource[
|
|
83
|
+
expect(resource["@id"]).to eq r_id
|
|
86
84
|
end
|
|
87
|
-
it
|
|
85
|
+
it ":width" do
|
|
88
86
|
width = 42
|
|
89
|
-
opts = {
|
|
87
|
+
opts = {service_id: valid_service_id, width: width}
|
|
90
88
|
resource = described_class.create_image_api_image_resource(opts)
|
|
91
89
|
expect(resource.width).to eq width
|
|
92
90
|
end
|
|
93
|
-
it
|
|
91
|
+
it ":height" do
|
|
94
92
|
height = 42
|
|
95
|
-
opts = {
|
|
93
|
+
opts = {service_id: valid_service_id, height: height}
|
|
96
94
|
resource = described_class.create_image_api_image_resource(opts)
|
|
97
95
|
expect(resource.height).to eq height
|
|
98
96
|
end
|
|
99
|
-
it
|
|
100
|
-
profile =
|
|
101
|
-
opts = {
|
|
97
|
+
it ":profile (service['profile'])" do
|
|
98
|
+
profile = "http://iiif.io/api/image/2/level1.json"
|
|
99
|
+
opts = {service_id: valid_service_id, profile: profile}
|
|
102
100
|
resource = described_class.create_image_api_image_resource(opts)
|
|
103
|
-
expect(resource.service[
|
|
101
|
+
expect(resource.service["profile"]).to eq profile
|
|
104
102
|
end
|
|
105
103
|
end
|
|
106
104
|
|
|
107
|
-
describe
|
|
108
|
-
it
|
|
105
|
+
describe "errors" do
|
|
106
|
+
it "raises if :service_id is not included" do
|
|
109
107
|
expect {
|
|
110
108
|
described_class.create_image_api_image_resource
|
|
111
109
|
}.to raise_error
|
|
112
110
|
end
|
|
113
|
-
it
|
|
111
|
+
it "raises if the info can't be pulled in" do
|
|
114
112
|
expect {
|
|
115
113
|
described_class.create_image_api_image_resource(service_id: invalid_service_id)
|
|
116
114
|
}.to raise_error
|
|
117
115
|
end
|
|
118
116
|
end
|
|
119
|
-
|
|
120
|
-
|
|
121
117
|
end
|
|
122
118
|
end
|