@projektemacher/storymapjs 0.10.8 → 0.11.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.
Files changed (195) hide show
  1. package/CHANGELOG +81 -0
  2. package/README.md +129 -12
  3. package/dist/assets/{demo-DBpeF31K.js → demo-bWnOyfUw.js} +1 -1
  4. package/dist/assets/harness-C3JUv5Ii.js +1 -0
  5. package/dist/assets/{harness-multi-CYHgI0a9.js → harness-multi-XZNex3tb.js} +1 -1
  6. package/dist/assets/{harness-support-Do7sIp2j.js → harness-support-CjfZIVeb.js} +1 -1
  7. package/dist/assets/main-DQ32hWBM.js +17 -0
  8. package/dist/context.json +13 -2
  9. package/dist/css/storymap.css +1 -1
  10. package/dist/demo.html +3 -2
  11. package/dist/docs/api/assets/navigation.js +1 -1
  12. package/dist/docs/api/assets/search.js +1 -1
  13. package/dist/docs/api/classes/StoryMap.html +777 -269
  14. package/dist/docs/api/functions/MediaType.html +1 -1
  15. package/dist/docs/api/functions/isPresentation3Collection.html +1 -1
  16. package/dist/docs/api/functions/isPresentation3Manifest.html +1 -1
  17. package/dist/docs/api/functions/isSlideshowCollection.html +51 -0
  18. package/dist/docs/api/functions/loadCSS.html +1 -1
  19. package/dist/docs/api/functions/manifestToStorymapData.html +1 -1
  20. package/dist/docs/api/functions/setLanguage.html +1 -1
  21. package/dist/docs/api/functions/slideshowToStorymapData.html +51 -0
  22. package/dist/docs/api/functions/slideshowVersion.html +48 -0
  23. package/dist/docs/api/functions/storymapToManifest.html +1 -1
  24. package/dist/docs/api/functions/validateStorymap.html +1 -1
  25. package/dist/docs/api/functions/validateStorymapAndReport.html +1 -1
  26. package/dist/docs/api/index.html +115 -12
  27. package/dist/docs/api/interfaces/AnimationHandle.html +2 -2
  28. package/dist/docs/api/interfaces/ImagereadyPayload.html +4 -4
  29. package/dist/docs/api/interfaces/LanguageEntry.html +6 -6
  30. package/dist/docs/api/interfaces/LatLngLiteral.html +4 -4
  31. package/dist/docs/api/interfaces/LoadOptions.html +2 -2
  32. package/dist/docs/api/interfaces/MapEvents.html +116 -0
  33. package/dist/docs/api/interfaces/MapMarkerEvents.html +66 -0
  34. package/dist/docs/api/interfaces/MarkerEventPayload.html +57 -0
  35. package/dist/docs/api/interfaces/MediaEvents.html +78 -0
  36. package/dist/docs/api/interfaces/MenuBarEvents.html +114 -0
  37. package/dist/docs/api/interfaces/MessageEvents.html +72 -0
  38. package/dist/docs/api/interfaces/OlLayer.html +1 -1
  39. package/dist/docs/api/interfaces/OlMap.html +1 -1
  40. package/dist/docs/api/interfaces/OlSource.html +1 -1
  41. package/dist/docs/api/interfaces/OlTileLayer.html +1 -1
  42. package/dist/docs/api/interfaces/OlVectorLayer.html +1 -1
  43. package/dist/docs/api/interfaces/OlView.html +1 -1
  44. package/dist/docs/api/interfaces/SlideBackgroundState.html +66 -0
  45. package/dist/docs/api/interfaces/SlideEvents.html +78 -0
  46. package/dist/docs/api/interfaces/SlideNavEvents.html +72 -0
  47. package/dist/docs/api/interfaces/SlideshowPlayerSettings.html +182 -0
  48. package/dist/docs/api/interfaces/SlideshowToStorymapResult.html +60 -0
  49. package/dist/docs/api/interfaces/SlideshowTranslatorOptions.html +65 -0
  50. package/dist/docs/api/interfaces/SlideshowWarning.html +72 -0
  51. package/dist/docs/api/interfaces/StoryMapEvents.html +146 -0
  52. package/dist/docs/api/interfaces/StoryMapMarker.html +763 -0
  53. package/dist/docs/api/interfaces/StorySliderEvents.html +102 -0
  54. package/dist/docs/api/interfaces/StorymapBasemap.html +69 -0
  55. package/dist/docs/api/interfaces/StorymapData.html +12 -6
  56. package/dist/docs/api/interfaces/StorymapDataWrapper.html +2 -2
  57. package/dist/docs/api/interfaces/StorymapDocument.html +2 -2
  58. package/dist/docs/api/interfaces/StorymapError.html +3 -3
  59. package/dist/docs/api/interfaces/StorymapGeoreference.html +5 -5
  60. package/dist/docs/api/interfaces/StorymapGeoreferenceBody.html +4 -4
  61. package/dist/docs/api/interfaces/StorymapGroundControlPoint.html +4 -4
  62. package/dist/docs/api/interfaces/StorymapLanguageMap.html +2 -2
  63. package/dist/docs/api/interfaces/StorymapManifest.html +12 -12
  64. package/dist/docs/api/interfaces/StorymapManifestAnnotation.html +43 -11
  65. package/dist/docs/api/interfaces/StorymapManifestBackground.html +6 -6
  66. package/dist/docs/api/interfaces/StorymapManifestCanvas.html +65 -17
  67. package/dist/docs/api/interfaces/StorymapManifestContentResource.html +8 -8
  68. package/dist/docs/api/interfaces/StorymapManifestFeatureCollection.html +4 -4
  69. package/dist/docs/api/interfaces/StorymapManifestGeoreferencing.html +7 -7
  70. package/dist/docs/api/interfaces/StorymapManifestImageService.html +4 -4
  71. package/dist/docs/api/interfaces/StorymapManifestRange.html +5 -5
  72. package/dist/docs/api/interfaces/StorymapManifestService.html +4 -4
  73. package/dist/docs/api/interfaces/StorymapManifestSpecificResource.html +4 -4
  74. package/dist/docs/api/interfaces/StorymapManifestStatement.html +3 -3
  75. package/dist/docs/api/interfaces/StorymapMapOptions.html +5 -5
  76. package/dist/docs/api/interfaces/StorymapOptions.html +277 -75
  77. package/dist/docs/api/interfaces/StorymapOverlayLayer.html +42 -11
  78. package/dist/docs/api/interfaces/StorymapSlide.html +70 -16
  79. package/dist/docs/api/interfaces/StorymapSlideBackground.html +4 -4
  80. package/dist/docs/api/interfaces/StorymapSlideLocation.html +58 -14
  81. package/dist/docs/api/interfaces/StorymapSlideMarker.html +8 -9
  82. package/dist/docs/api/interfaces/StorymapSlideMedia.html +49 -10
  83. package/dist/docs/api/interfaces/StorymapSlideNarration.html +61 -9
  84. package/dist/docs/api/interfaces/StorymapSlideText.html +4 -4
  85. package/dist/docs/api/interfaces/StorymapTilejson.html +8 -8
  86. package/dist/docs/api/interfaces/SwipableEvents.html +102 -0
  87. package/dist/docs/api/interfaces/TextEvents.html +66 -0
  88. package/dist/docs/api/media/events.md +66 -0
  89. package/dist/docs/api/media/migration-from-knightlab.md +89 -49
  90. package/dist/docs/api/media/slideshow.md +109 -0
  91. package/dist/docs/api/media/storymap-as-iiif-manifest.md +1007 -0
  92. package/dist/docs/api/modules.html +6 -6
  93. package/dist/docs/api/types/ContentState.html +3 -3
  94. package/dist/docs/api/types/FiredEvent.html +43 -0
  95. package/dist/docs/api/types/ImagereadyKind.html +1 -1
  96. package/dist/docs/api/types/MediaTypeMatch.html +5 -5
  97. package/dist/docs/api/types/SwipeDirection.html +36 -0
  98. package/dist/docs/api/types/TileSourceFactory.html +1 -1
  99. package/dist/docs/authoring.html +20 -7
  100. package/dist/docs/events.html +263 -0
  101. package/dist/docs/iiif.html +136 -22
  102. package/dist/docs/migration.html +66 -19
  103. package/dist/docs/readme.html +113 -12
  104. package/dist/embed/index.html +7 -0
  105. package/dist/examples/font-css-false.json +33 -0
  106. package/dist/examples/issue-image-region.json +1 -1
  107. package/dist/examples/layers-control.json +60 -0
  108. package/dist/examples/map-template-provider.json +36 -0
  109. package/dist/examples/multi-media.json +71 -0
  110. package/dist/examples-iiif/annotated-image.json +16 -0
  111. package/dist/examples-iiif/font-css-false.json +114 -0
  112. package/dist/examples-iiif/issue-image-region.json +1 -1
  113. package/dist/examples-iiif/layers-control.json +226 -0
  114. package/dist/examples-iiif/map-template-provider.json +133 -0
  115. package/dist/examples-iiif/multi-media.json +237 -0
  116. package/dist/examples-slideshow/single.json +79 -0
  117. package/dist/examples-slideshow/static/pixel.png +0 -0
  118. package/dist/examples-slideshow/static.json +44 -0
  119. package/dist/harness-multi.html +3 -3
  120. package/dist/harness.html +4 -3
  121. package/dist/index.html +3 -2
  122. package/dist/js/storymap.d.ts +1326 -289
  123. package/dist/js/storymap.js +6690 -4784
  124. package/dist/js/storymap.js.map +1 -1
  125. package/dist/navplace-properties.json +14 -0
  126. package/dist/site.css +54 -36
  127. package/docs/migration-from-knightlab.md +89 -49
  128. package/docs/storymap-as-iiif-manifest.md +199 -126
  129. package/package.json +4 -2
  130. package/schema/storymap.schema.json +334 -34
  131. package/src/core/Util.ts +3 -1
  132. package/src/core/mixins.ts +42 -28
  133. package/src/language/Language.ts +7 -0
  134. package/src/language/locale/.expected-gaps.json +287 -27
  135. package/src/language/locale/de.json +11 -1
  136. package/src/language/locale/en.json +11 -1
  137. package/src/main.ts +51 -0
  138. package/src/map/Map.ts +44 -6
  139. package/src/map/MapMarker.ts +16 -3
  140. package/src/map/openlayers/Map.OpenLayers.ts +787 -62
  141. package/src/map/openlayers/MapMarker.OpenLayers.ts +113 -13
  142. package/src/map/slideView.ts +149 -0
  143. package/src/map/types.ts +2 -0
  144. package/src/media/EmbedUtil.ts +27 -2
  145. package/src/media/Media.ts +27 -4
  146. package/src/media/embedId.ts +6 -0
  147. package/src/media/types/Flickr.ts +14 -3
  148. package/src/media/types/HtmlMedia.ts +66 -0
  149. package/src/media/types/Image.ts +15 -3
  150. package/src/media/types/SoundCloud.ts +1 -1
  151. package/src/media/types/Text.ts +27 -8
  152. package/src/media/types/Twitter.ts +9 -7
  153. package/src/media/types/Vimeo.ts +1 -1
  154. package/src/media/types/YouTube.ts +2 -2
  155. package/src/scss/Typography.scss +15 -15
  156. package/src/scss/VCO.StoryMap.scss +173 -8
  157. package/src/scss/core/Reset.scss +5 -18
  158. package/src/scss/map/VCO.Map.scss +1 -1
  159. package/src/scss/map/VCO.MapMarker.scss +20 -9
  160. package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +7 -4
  161. package/src/scss/media/VCO.Media.scss +7 -7
  162. package/src/scss/media/types/VCO.Media.Blockquote.scss +1 -1
  163. package/src/scss/media/types/VCO.Media.Text.scss +5 -5
  164. package/src/scss/media/types/VCO.Media.Twitter.scss +4 -4
  165. package/src/scss/media/types/VCO.Media.Wikipedia.scss +2 -19
  166. package/src/scss/site/site.scss +55 -36
  167. package/src/scss/slider/VCO.Slide.scss +49 -11
  168. package/src/scss/slider/VCO.SlideNav.scss +23 -7
  169. package/src/scss/slider/VCO.StorySlider.scss +50 -0
  170. package/src/scss/snapmap/VCO.SnapMap.scss +3 -3
  171. package/src/scss/ui/VCO.Consent.scss +20 -14
  172. package/src/scss/ui/VCO.Layers.scss +119 -0
  173. package/src/scss/ui/VCO.MenuBar.Button.scss +140 -19
  174. package/src/scss/ui/VCO.MenuBar.scss +9 -2
  175. package/src/scss/ui/VCO.Message.scss +14 -0
  176. package/src/slider/Slide.ts +232 -74
  177. package/src/slider/SlideNav.ts +15 -4
  178. package/src/slider/StorySlider.ts +203 -22
  179. package/src/storymap/Consent.ts +22 -0
  180. package/src/storymap/StoryMap.ts +944 -131
  181. package/src/storymap/content-state.ts +3 -8
  182. package/src/storymap/font-css.ts +72 -0
  183. package/src/storymap/from-slideshow.ts +785 -0
  184. package/src/storymap/iiif-shared.ts +228 -0
  185. package/src/storymap/iiif.ts +289 -100
  186. package/src/storymap/to-iiif.ts +131 -44
  187. package/src/types.ts +382 -17
  188. package/src/ui/LayersControl.ts +302 -0
  189. package/src/ui/MenuBar.ts +232 -21
  190. package/src/ui/Message.ts +36 -3
  191. package/src/ui/Swipable.ts +25 -7
  192. package/dist/assets/harness-Cr0rYpvt.js +0 -1
  193. package/dist/assets/main-DXalctuR.js +0 -17
  194. package/src/scss/VCO.StoryMap.Dark.scss +0 -150
  195. package/src/scss/Variables.Dark.scss +0 -57
@@ -15,54 +15,24 @@ import type {
15
15
  StorymapSlideMedia,
16
16
  } from "../types";
17
17
  import { readGroundControlPoints } from "../map/georeference";
18
+ import {
19
+ LOCATION_PROPERTIES,
20
+ asBoolean,
21
+ asNumber,
22
+ asRecord,
23
+ asString,
24
+ asStringArray,
25
+ clampRegion,
26
+ escapeText,
27
+ isDuplicateNarration,
28
+ isLonLatBox,
29
+ readPlaybackBag,
30
+ } from "./iiif-shared";
18
31
 
19
32
  const PRESENTATION_3_CONTEXT = "iiif.io/api/presentation/3/context.json";
20
33
  const MAPCONFIG_PROFILE = "mapconfig";
21
34
  const STORYMAP_PREFIX = "storymap:";
22
35
 
23
- /** Keys copied verbatim from navPlace Feature properties onto the location. */
24
- const LOCATION_PROPERTIES = [
25
- "name",
26
- "zoom",
27
- "line",
28
- "icon",
29
- "iconSize",
30
- "image",
31
- "use_custom_marker",
32
- // marker presentation with no IIIF vocabulary of its own: a GeoJSON
33
- // foreign member needs no registration, which is why these live here
34
- // rather than in a storymap: term (docs/plans/iiif-media-tours.md §2)
35
- "popup",
36
- "audioBadge",
37
- ] as const;
38
-
39
- function asRecord(value: unknown): Record<string, unknown> | null {
40
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
41
- return null;
42
- }
43
- return value as Record<string, unknown>;
44
- }
45
-
46
- function asString(value: unknown): string | null {
47
- return typeof value === "string" && value !== "" ? value : null;
48
- }
49
-
50
- function asNumber(value: unknown): number | null {
51
- return typeof value === "number" && !isNaN(value) ? value : null;
52
- }
53
-
54
- function asBoolean(value: unknown): boolean | null {
55
- return typeof value === "boolean" ? value : null;
56
- }
57
-
58
- function asStringArray(value: unknown): string[] {
59
- if (typeof value === "string") return [value];
60
- if (Array.isArray(value)) {
61
- return value.filter((entry): entry is string => typeof entry === "string");
62
- }
63
- return [];
64
- }
65
-
66
36
  /**
67
37
  * Reads a IIIF xywh image region (`storymap:imageRegion`): an array of
68
38
  * exactly 4 finite numbers ([x, y, w, h] in image pixels). Returns null
@@ -161,8 +131,8 @@ function isEmptySelector(sel: ReadSelector): boolean {
161
131
 
162
132
  /** A `PointSelector` is a pin, which the viewer cannot fit, so we synthesize
163
133
  * a square of 5% of the canvas's smaller side, centred on the point and
164
- * clamped to the canvas. Stated once, here: the annotation-driven stops in
165
- * docs/plans/iiif-media-tours.md consume this rather than re-deriving it. */
134
+ * clamped to the canvas. Stated once, here, so annotation-driven stops
135
+ * share it rather than re-deriving it. */
166
136
  const POINT_SQUARE_FRACTION = 0.05;
167
137
 
168
138
  function pointToRegion(
@@ -190,8 +160,11 @@ function readSelectorEntry(entry: unknown, out: ReadSelector, depth = 0): void {
190
160
  const match = /xywh=(pixel:)?([^,]+),([^,]+),([^,]+),([^,]+)/.exec(raw);
191
161
  if (match) {
192
162
  const parts = match.slice(2).map(Number);
163
+ // Origins may be negative (slideshow overshoots its canvas);
164
+ // clamping to the canvas happens in readSelector, which knows
165
+ // the size. Only the size must already be positive here.
193
166
  if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) {
194
- if (parts[0] >= 0 && parts[1] >= 0 && parts[2] > 0 && parts[3] > 0) {
167
+ if (parts[2] > 0 && parts[3] > 0) {
195
168
  out.region = parts as [number, number, number, number];
196
169
  }
197
170
  }
@@ -273,7 +246,14 @@ export function readSelector(
273
246
  }
274
247
  }
275
248
 
276
- if (out.region === null && out.point !== null) {
249
+ if (out.region !== null) {
250
+ const clamped = clampRegion(out.region, width, height);
251
+ out.region = clamped;
252
+ if (clamped === null && out.point !== null) {
253
+ // fully outside the canvas: fall back to the point square
254
+ out.region = pointToRegion(out.point, width, height);
255
+ }
256
+ } else if (out.point !== null) {
277
257
  out.region = pointToRegion(out.point, width, height);
278
258
  }
279
259
 
@@ -312,7 +292,7 @@ export function flattenLanguageMap(value: unknown): string {
312
292
  * type 'object'" — so the array branch below is producer leniency, not
313
293
  * conformance. The `label` ("Credit", "Rights holder", a language-tagged term)
314
294
  * is half of what a real institutional manifest ships, and reading only
315
- * `.value` dropped it. See docs/plans/iiif-interop.md §2.1.
295
+ * `.value` dropped it.
316
296
  */
317
297
  function readRequiredStatement(statement: unknown): { label: string; value: string } {
318
298
  const entries = Array.isArray(statement)
@@ -437,8 +417,7 @@ function collectionToStorymapData(collection: Record<string, unknown>): Storymap
437
417
  "A Presentation 3 Collection references its members from other documents, and " +
438
418
  "the converter is synchronous, so it contributes no slides rather than " +
439
419
  "mangling them. To build a multi-manifest tour, fetch the members and " +
440
- "concatenate their manifestToStorymapData() slides yourself " +
441
- "(see docs/plans/iiif-interop.md §2.2).",
420
+ "concatenate their manifestToStorymapData() slides yourself.",
442
421
  );
443
422
 
444
423
  return data;
@@ -550,8 +529,8 @@ function readMapConfig(manifest: Record<string, unknown>): Record<string, unknow
550
529
  /**
551
530
  * What a painting annotation tells us about a slide's media.
552
531
  *
553
- * This is the **shared body record** the annotation-driven stops in
554
- * docs/plans/iiif-media-tours.md read: `type` is how a `Sound` body is told
532
+ * This is the **shared body record** for annotation-driven stops:
533
+ * `type` is how a `Sound` body is told
555
534
  * from an `Image` or a `TextualBody`. Anything else that needs a field from
556
535
  * the body belongs on this record rather than in a second reader.
557
536
  */
@@ -582,6 +561,27 @@ export interface PaintingBody {
582
561
  * in storymap JSON is the documented route.
583
562
  */
584
563
  subtitles: string | null;
564
+ /**
565
+ * Playback flags from `storymap:` foreign members on the painting
566
+ * annotation (slideshow audio terms); absent stays absent.
567
+ */
568
+ offset?: number;
569
+ loop?: boolean;
570
+ play?: "auto" | "click";
571
+ stopOnExit?: boolean;
572
+ }
573
+
574
+ /**
575
+ * Reads `storymap:` playback flags off a painting annotation into the
576
+ * painting body: `offset` (seconds), `loop`, `play` and `stopOnExit`
577
+ * (painting annotations never carry `stopAllPrevious`).
578
+ */
579
+ function readPaintingPlayback(
580
+ annotationRecord: Record<string, unknown>,
581
+ painting: { offset?: number; loop?: boolean; play?: "auto" | "click"; stopOnExit?: boolean },
582
+ ): void {
583
+ const { stopAllPrevious: _dropped, ...bag } = readPlaybackBag(annotationRecord);
584
+ Object.assign(painting, bag);
585
585
  }
586
586
 
587
587
  /**
@@ -642,18 +642,101 @@ function readFirstTypedBody(body: unknown): Record<string, unknown> | null {
642
642
  return url === null ? null : record;
643
643
  }
644
644
 
645
+ /** Every body of a `body` array that yields a URL, in order (#358). */
646
+ function readPaintingBodies(body: unknown): Record<string, unknown>[] {
647
+ if (Array.isArray(body)) {
648
+ return body.flatMap((entry) => readPaintingBodies(entry));
649
+ }
650
+ const record = asRecord(body);
651
+ if (!record) return [];
652
+ const url = asString(record.id) ?? asString(record.value);
653
+ return url === null ? [] : [record];
654
+ }
655
+
645
656
  /**
646
- * Reads the slide media URL from the canvas's painting annotation body:
657
+ * Reads the slide media URLs from the canvas's painting annotation bodies:
647
658
  * typed bodies contribute their `id`, TextualBody (HTML) content its `value`
648
659
  * - the legacy format stores both in `media.url`. Also returns the region of
649
660
  * a selector on the annotation target, and the body fields listed on
650
661
  * {@link PaintingBody}.
662
+ *
663
+ * Every painting match is collected in order (#358): the first becomes the
664
+ * slide's `media`, further ones its `media_extra`.
651
665
  */
652
- function readPainting(
666
+ function readPaintings(
653
667
  canvas: Record<string, unknown>,
654
668
  width: number | null = null,
655
669
  height: number | null = null,
656
- ): PaintingBody | null {
670
+ ): PaintingBody[] {
671
+ const found: PaintingBody[] = [];
672
+ const annotationPages = Array.isArray(canvas.items) ? canvas.items : [];
673
+ for (const page of annotationPages) {
674
+ const pageRecord = asRecord(page);
675
+ if (!pageRecord) continue;
676
+ const annotations = Array.isArray(pageRecord.items) ? pageRecord.items : [];
677
+ for (const annotation of annotations) {
678
+ const annotationRecord = asRecord(annotation);
679
+ if (!annotationRecord) continue;
680
+ // motivation may be an array (notably ["painting","supplementing"]):
681
+ // accept when any entry names painting. An absent or unparsable
682
+ // motivation stays lenient (treated as painting), as before — only
683
+ // the array form is new.
684
+ const rawMotivation = annotationRecord.motivation;
685
+ const motivations = Array.isArray(rawMotivation) ? rawMotivation : [rawMotivation];
686
+ const isPainting = motivations.some((m) => {
687
+ const s = asString(m);
688
+ return s === null || s === "painting";
689
+ });
690
+ if (!isPainting) continue;
691
+ for (const body of readPaintingBodies(annotationRecord.body)) {
692
+ const painting: PaintingBody = {
693
+ url: asString(body.id) ?? asString(body.value) ?? "",
694
+ region: readSelector(annotationRecord.target, width, height).region,
695
+ type: asString(body.type),
696
+ format: asString(body.format),
697
+ // P3 puts label / requiredStatement / accessibilitySummary on
698
+ // the Annotation; some producers put them on the body, so the
699
+ // body is still consulted as a fallback (§2.7)
700
+ label:
701
+ flattenLanguageMap(annotationRecord.label) ||
702
+ flattenLanguageMap(body.label) ||
703
+ null,
704
+ accessibilitySummary:
705
+ flattenLanguageMap(annotationRecord.accessibilitySummary) ||
706
+ flattenLanguageMap(body.accessibilitySummary) ||
707
+ null,
708
+ credit: readAnnotationCredit(annotationRecord) ?? readBodyCredit(body),
709
+ thumbnail: readThumbnailId(body.thumbnail),
710
+ duration: asNumber(body.duration),
711
+ start: asNumber(body.start),
712
+ end: asNumber(body.end),
713
+ subtitles: readBodySubtitles(annotationRecord.body),
714
+ };
715
+ readPaintingPlayback(annotationRecord, painting);
716
+ found.push(painting);
717
+ }
718
+ }
719
+ }
720
+ return found;
721
+ }
722
+
723
+ /**
724
+ * Reads a slide narration from `motivation: "supplementing"` annotations:
725
+ * the first `Sound`/`Video` body wins. Painting bodies are skipped here —
726
+ * the painting reader owns them, including the array-motivation form — and
727
+ * anything else (commenting, tagging, …) belongs to the annotation stops.
728
+ *
729
+ * Playback flags ride `storymap:` foreign members on the annotation (or the
730
+ * body as a fallback), which need no vocabulary registration.
731
+ */
732
+ function readSupplementing(canvas: Record<string, unknown>): {
733
+ url: string;
734
+ loop?: boolean;
735
+ offset?: number;
736
+ play?: "auto" | "click";
737
+ stopOnExit?: boolean;
738
+ stopAllPrevious?: boolean;
739
+ } | null {
657
740
  const annotationPages = Array.isArray(canvas.items) ? canvas.items : [];
658
741
  for (const page of annotationPages) {
659
742
  const pageRecord = asRecord(page);
@@ -662,33 +745,24 @@ function readPainting(
662
745
  for (const annotation of annotations) {
663
746
  const annotationRecord = asRecord(annotation);
664
747
  if (!annotationRecord) continue;
665
- const motivation = asString(annotationRecord.motivation);
666
- if (motivation !== null && motivation !== "painting") continue;
748
+ const rawMotivation = annotationRecord.motivation;
749
+ const motivations = Array.isArray(rawMotivation) ? rawMotivation : [rawMotivation];
750
+ if (!motivations.some((m) => asString(m) === "supplementing")) continue;
667
751
  const body = readFirstTypedBody(annotationRecord.body);
668
752
  if (body === null) continue;
669
- return {
670
- url: asString(body.id) ?? asString(body.value) ?? "",
671
- region: readSelector(annotationRecord.target, width, height).region,
672
- type: asString(body.type),
673
- format: asString(body.format),
674
- // P3 puts label / requiredStatement / accessibilitySummary on
675
- // the Annotation; some producers put them on the body, so the
676
- // body is still consulted as a fallback (§2.7)
677
- label:
678
- flattenLanguageMap(annotationRecord.label) ||
679
- flattenLanguageMap(body.label) ||
680
- null,
681
- accessibilitySummary:
682
- flattenLanguageMap(annotationRecord.accessibilitySummary) ||
683
- flattenLanguageMap(body.accessibilitySummary) ||
684
- null,
685
- credit: readAnnotationCredit(annotationRecord) ?? readBodyCredit(body),
686
- thumbnail: readThumbnailId(body.thumbnail),
687
- duration: asNumber(body.duration),
688
- start: asNumber(body.start),
689
- end: asNumber(body.end),
690
- subtitles: readBodySubtitles(annotationRecord.body),
691
- };
753
+ const type = asString(body.type);
754
+ if (type !== "Sound" && type !== "Video") continue;
755
+ const url = asString(body.id) ?? asString(body.value);
756
+ if (url === null) continue;
757
+ const narration: {
758
+ url: string;
759
+ loop?: boolean;
760
+ offset?: number;
761
+ play?: "auto" | "click";
762
+ stopOnExit?: boolean;
763
+ stopAllPrevious?: boolean;
764
+ } = { url, ...readPlaybackBag(annotationRecord), ...readPlaybackBag(body) };
765
+ return narration;
692
766
  }
693
767
  }
694
768
  return null;
@@ -728,6 +802,28 @@ function readFeatureLocation(feature: unknown): StorymapSlideLocation | null {
728
802
  if (value === undefined || value === null || value === "") continue;
729
803
  locationProps[key] = value;
730
804
  }
805
+ // Narrow the slideshow-driven keys the verbatim copy cannot check:
806
+ // a mistyped value must not reach the map as a wrong-typed field.
807
+ if (typeof location.rotation !== "number" || !Number.isFinite(location.rotation)) {
808
+ delete location.rotation;
809
+ }
810
+ if (typeof location.basemap !== "string" || location.basemap === "") {
811
+ delete location.basemap;
812
+ }
813
+ const filter = asRecord(location.filter);
814
+ if (location.filter !== undefined && filter === null) delete location.filter;
815
+ const mask = asRecord(location.mask);
816
+ if (location.mask !== undefined) {
817
+ if (
818
+ mask === null ||
819
+ typeof mask.x !== "number" ||
820
+ typeof mask.y !== "number" ||
821
+ typeof mask.w !== "number" ||
822
+ typeof mask.h !== "number"
823
+ ) {
824
+ delete location.mask;
825
+ }
826
+ }
731
827
  }
732
828
  return location;
733
829
  }
@@ -956,7 +1052,11 @@ function canvasToSlide(
956
1052
  // requiredStatement / accessibilitySummary as caption, credit and alt
957
1053
  // text (§2.7 — the `mediaCaption`/`mediaCredit`/`mediaAlt` canvas terms are
958
1054
  // gone). srcset/sizes remain terms: IIIF has no vocabulary for either.
959
- const painting = readPainting(record, asNumber(record.width), asNumber(record.height));
1055
+ const paintings = readPaintings(record, asNumber(record.width), asNumber(record.height));
1056
+ // the first painting is the slide's media; further ones are its
1057
+ // media_extra (#358)
1058
+ const painting = paintings[0] ?? null;
1059
+ const extraPaintings = paintings.slice(1);
960
1060
  const caption = painting?.label ?? null;
961
1061
  const credit = painting?.credit ?? null;
962
1062
  const alt = painting?.accessibilitySummary ?? null;
@@ -984,14 +1084,41 @@ function canvasToSlide(
984
1084
  if (sizes !== null) media.sizes = sizes;
985
1085
  if (thumbnail !== null) media.thumb = thumbnail;
986
1086
  if (painting?.subtitles != null) media.subtitles = painting.subtitles;
1087
+ if (painting?.offset !== undefined) media.offset = painting.offset;
1088
+ if (painting?.loop !== undefined) media.loop = painting.loop;
1089
+ if (painting?.play !== undefined) media.play = painting.play;
1090
+ if (painting?.stopOnExit !== undefined) media.stopOnExit = painting.stopOnExit;
987
1091
  slide.media = media;
988
1092
  }
1093
+ if (extraPaintings.length > 0) {
1094
+ slide.media_extra = extraPaintings.map((extra) => {
1095
+ const item: StorymapSlideMedia = { url: extra.url };
1096
+ if (extra.label !== null) item.caption = extra.label;
1097
+ if (extra.credit !== null) item.credit = extra.credit;
1098
+ if (extra.accessibilitySummary !== null) item.alt = extra.accessibilitySummary;
1099
+ return item;
1100
+ });
1101
+ }
1102
+
1103
+ // narration: a supplementing Sound/Video body plays as the slide's
1104
+ // narration (V1 of #358). Skipped when it duplicates a painting URL —
1105
+ // a body carrying both motivations is already the slide's media.
1106
+ const supplement = readSupplementing(record);
1107
+ const mediaUrls = new Set(
1108
+ [slide.media?.url, ...(slide.media_extra ?? []).map((item) => item.url)].filter(
1109
+ (url): url is string => typeof url === "string" && url !== "",
1110
+ ),
1111
+ );
1112
+ if (supplement !== null && !isDuplicateNarration([...mediaUrls], supplement.url)) {
1113
+ const { url, ...flags } = supplement;
1114
+ slide.narration = { url, ...flags };
1115
+ }
989
1116
 
990
1117
  // location: canvas navPlace, falling back to a manifest-level navPlace
991
1118
  // aggregated in items order (both are allowed by the proposal)
992
1119
  const location = readLocation(record.navPlace) ?? readFeatureLocation(manifestFeature);
993
1120
  if (location !== null) slide.location = location;
994
- // IIIF xywh region (StrollView-style image stops): [x, y, w, h] pixels.
1121
+ // IIIF xywh region (slideshow-style image stops): [x, y, w, h] pixels.
995
1122
  // The extension term wins over the interoperable spelling — an Image API
996
1123
  // Selector on the painting annotation target
997
1124
  // region: the painting annotation's target selector, the only source since
@@ -1000,6 +1127,72 @@ function canvasToSlide(
1000
1127
  if (region !== null) {
1001
1128
  slide.location = { ...(slide.location ?? {}), region };
1002
1129
  }
1130
+ // View directives as canvas `storymap:` terms: the fallback for slides
1131
+ // without lat/lon (no navPlace to carry them). navPlace wins when both
1132
+ // spell the same key.
1133
+ const canvasRotation = record[STORYMAP_PREFIX + "rotation"];
1134
+ if (
1135
+ slide.location?.rotation === undefined &&
1136
+ typeof canvasRotation === "number" &&
1137
+ Number.isFinite(canvasRotation)
1138
+ ) {
1139
+ slide.location = { ...(slide.location ?? {}), rotation: canvasRotation };
1140
+ }
1141
+ const canvasBasemap = record[STORYMAP_PREFIX + "basemap"];
1142
+ if (
1143
+ slide.location?.basemap === undefined &&
1144
+ typeof canvasBasemap === "string" &&
1145
+ canvasBasemap !== ""
1146
+ ) {
1147
+ slide.location = { ...(slide.location ?? {}), basemap: canvasBasemap };
1148
+ }
1149
+ const canvasFilter = asRecord(record[STORYMAP_PREFIX + "filter"]);
1150
+ if (slide.location?.filter === undefined && canvasFilter !== null) {
1151
+ slide.location = { ...(slide.location ?? {}), filter: canvasFilter };
1152
+ }
1153
+ const canvasMask = asRecord(record[STORYMAP_PREFIX + "mask"]);
1154
+ if (slide.location?.mask === undefined && canvasMask !== null) {
1155
+ const mx = canvasMask.x;
1156
+ const my = canvasMask.y;
1157
+ const mw = canvasMask.w;
1158
+ const mh = canvasMask.h;
1159
+ if (
1160
+ typeof mx === "number" &&
1161
+ typeof my === "number" &&
1162
+ typeof mw === "number" &&
1163
+ typeof mh === "number"
1164
+ ) {
1165
+ slide.location = {
1166
+ ...(slide.location ?? {}),
1167
+ mask: { ...canvasMask, x: mx, y: my, w: mw, h: mh },
1168
+ };
1169
+ }
1170
+ }
1171
+ const canvasSlidetimeout = record[STORYMAP_PREFIX + "slidetimeout"];
1172
+ if (
1173
+ slide.slidetimeout === undefined &&
1174
+ typeof canvasSlidetimeout === "number" &&
1175
+ Number.isFinite(canvasSlidetimeout) &&
1176
+ canvasSlidetimeout >= 0
1177
+ ) {
1178
+ slide.slidetimeout = canvasSlidetimeout;
1179
+ }
1180
+ const canvasImgoverlay = asRecord(record[STORYMAP_PREFIX + "imgoverlay"]);
1181
+ if (slide.imgoverlay === undefined && canvasImgoverlay !== null) {
1182
+ const overlayUrl = asString(canvasImgoverlay.url);
1183
+ if (overlayUrl !== null) slide.imgoverlay = { ...canvasImgoverlay, url: overlayUrl };
1184
+ }
1185
+ const canvasProvenance = asRecord(record[STORYMAP_PREFIX + "provenance"]);
1186
+ if (slide.provenance === undefined && canvasProvenance !== null) {
1187
+ const provenance: Record<string, unknown> = {};
1188
+ for (const key of ["manifest", "canvas", "image"]) {
1189
+ const value = asString(canvasProvenance[key]);
1190
+ if (value !== null) provenance[key] = value;
1191
+ }
1192
+ if (Object.keys(provenance).length > 0) {
1193
+ slide.provenance = provenance as StorymapSlide["provenance"];
1194
+ }
1195
+ }
1003
1196
 
1004
1197
  // StoryMap extension terms
1005
1198
  const slideType = asString(record[STORYMAP_PREFIX + "type"]);
@@ -1027,21 +1220,11 @@ function canvasToSlide(
1027
1220
  */
1028
1221
  const STOP_MOTIVATIONS = new Set(["commenting", "tagging", "classifying", "describing"]);
1029
1222
 
1030
- /** Escapes the five characters that would otherwise be markup, so a
1031
- * `text/plain` annotation body cannot inject HTML. The renderer sanitizes
1032
- * slide text as well; this keeps the stored value honest on its own. */
1033
- function escapeText(value: string): string {
1034
- return value
1035
- .replace(/&/g, "&amp;")
1036
- .replace(/</g, "&lt;")
1037
- .replace(/>/g, "&gt;")
1038
- .replace(/"/g, "&quot;")
1039
- .replace(/'/g, "&#39;");
1040
- }
1041
-
1042
1223
  /**
1043
1224
  * A `text/plain` body becomes one paragraph per blank-line-separated block,
1044
- * escaped. A `text/html` body is passed through as markup — the slide text
1225
+ * escaped (`escapeText` is shared with the slideshow translator, which
1226
+ * wraps single paragraphs the same way). A `text/html` body is passed
1227
+ * through as markup — the slide text
1045
1228
  * pipeline (`sanitizeSlideText`, via `media/types/Text.ts`) sanitizes whatever
1046
1229
  * it is given, which is the same path a storymap JSON `text.text` takes.
1047
1230
  */
@@ -1666,8 +1849,15 @@ function applyMapConfig(data: StorymapData, config: Record<string, unknown>): vo
1666
1849
  if (left !== null && top !== null) data.map_center_offset = { left, top };
1667
1850
  }
1668
1851
 
1669
- const fontCss = asString(readTerm(config, "fontCss"));
1670
- if (fontCss !== null) data.font_css = fontCss;
1852
+ const fontCssRaw = readTerm(config, "fontCss");
1853
+ if (fontCssRaw === false) {
1854
+ // a manifest can disable the injected theme exactly like storymap
1855
+ // JSON with font_css: false (the host bundles the theme itself)
1856
+ data.font_css = false;
1857
+ } else {
1858
+ const fontCss = asString(fontCssRaw);
1859
+ if (fontCss !== null) data.font_css = fontCss;
1860
+ }
1671
1861
 
1672
1862
  const callToAction = asBoolean(readTerm(config, "callToAction"));
1673
1863
  if (callToAction !== null) data.call_to_action = callToAction;
@@ -1731,9 +1921,8 @@ function applyMapConfig(data: StorymapData, config: Record<string, unknown>): vo
1731
1921
 
1732
1922
  /** Reads a `[west, south, east, north]` lon/lat box of four finite numbers. */
1733
1923
  function readLonLatBox(value: unknown): [number, number, number, number] | null {
1734
- if (!Array.isArray(value) || value.length !== 4) return null;
1735
- if (!value.every((n) => typeof n === "number" && Number.isFinite(n))) return null;
1736
- const [west, south, east, north] = value as number[];
1924
+ if (!isLonLatBox(value)) return null;
1925
+ const [west, south, east, north] = value;
1737
1926
  if (east <= west || north <= south) return null;
1738
1927
  return [west, south, east, north];
1739
1928
  }