@projektemacher/storymapjs 0.10.7 → 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 (587) hide show
  1. package/CHANGELOG +431 -19
  2. package/README.md +168 -17
  3. package/dist/assets/demo-bWnOyfUw.js +1 -0
  4. package/dist/assets/harness-C3JUv5Ii.js +1 -0
  5. package/dist/assets/harness-multi-XZNex3tb.js +1 -0
  6. package/dist/assets/harness-support-CjfZIVeb.js +1 -0
  7. package/dist/assets/index-wHPqGrBS.js +1 -0
  8. package/dist/assets/main-DQ32hWBM.js +17 -0
  9. package/dist/context.json +23 -9
  10. package/dist/css/fonts/files/amatic-sc-cyrillic-400-normal.woff2 +0 -0
  11. package/dist/css/fonts/files/amatic-sc-cyrillic-700-normal.woff2 +0 -0
  12. package/dist/css/fonts/files/amatic-sc-hebrew-400-normal.woff2 +0 -0
  13. package/dist/css/fonts/files/amatic-sc-hebrew-700-normal.woff2 +0 -0
  14. package/dist/css/fonts/files/amatic-sc-vietnamese-400-normal.woff2 +0 -0
  15. package/dist/css/fonts/files/amatic-sc-vietnamese-700-normal.woff2 +0 -0
  16. package/dist/css/fonts/files/bitter-cyrillic-100-italic.woff2 +0 -0
  17. package/dist/css/fonts/files/bitter-cyrillic-100-normal.woff2 +0 -0
  18. package/dist/css/fonts/files/bitter-cyrillic-200-italic.woff2 +0 -0
  19. package/dist/css/fonts/files/bitter-cyrillic-200-normal.woff2 +0 -0
  20. package/dist/css/fonts/files/bitter-cyrillic-300-italic.woff2 +0 -0
  21. package/dist/css/fonts/files/bitter-cyrillic-300-normal.woff2 +0 -0
  22. package/dist/css/fonts/files/bitter-cyrillic-400-italic.woff2 +0 -0
  23. package/dist/css/fonts/files/bitter-cyrillic-400-normal.woff2 +0 -0
  24. package/dist/css/fonts/files/bitter-cyrillic-500-italic.woff2 +0 -0
  25. package/dist/css/fonts/files/bitter-cyrillic-500-normal.woff2 +0 -0
  26. package/dist/css/fonts/files/bitter-cyrillic-600-italic.woff2 +0 -0
  27. package/dist/css/fonts/files/bitter-cyrillic-600-normal.woff2 +0 -0
  28. package/dist/css/fonts/files/bitter-cyrillic-700-italic.woff2 +0 -0
  29. package/dist/css/fonts/files/bitter-cyrillic-700-normal.woff2 +0 -0
  30. package/dist/css/fonts/files/bitter-cyrillic-800-italic.woff2 +0 -0
  31. package/dist/css/fonts/files/bitter-cyrillic-800-normal.woff2 +0 -0
  32. package/dist/css/fonts/files/bitter-cyrillic-900-italic.woff2 +0 -0
  33. package/dist/css/fonts/files/bitter-cyrillic-900-normal.woff2 +0 -0
  34. package/dist/css/fonts/files/bitter-cyrillic-ext-100-italic.woff2 +0 -0
  35. package/dist/css/fonts/files/bitter-cyrillic-ext-100-normal.woff2 +0 -0
  36. package/dist/css/fonts/files/bitter-cyrillic-ext-200-italic.woff2 +0 -0
  37. package/dist/css/fonts/files/bitter-cyrillic-ext-200-normal.woff2 +0 -0
  38. package/dist/css/fonts/files/bitter-cyrillic-ext-300-italic.woff2 +0 -0
  39. package/dist/css/fonts/files/bitter-cyrillic-ext-300-normal.woff2 +0 -0
  40. package/dist/css/fonts/files/bitter-cyrillic-ext-400-italic.woff2 +0 -0
  41. package/dist/css/fonts/files/bitter-cyrillic-ext-400-normal.woff2 +0 -0
  42. package/dist/css/fonts/files/bitter-cyrillic-ext-500-italic.woff2 +0 -0
  43. package/dist/css/fonts/files/bitter-cyrillic-ext-500-normal.woff2 +0 -0
  44. package/dist/css/fonts/files/bitter-cyrillic-ext-600-italic.woff2 +0 -0
  45. package/dist/css/fonts/files/bitter-cyrillic-ext-600-normal.woff2 +0 -0
  46. package/dist/css/fonts/files/bitter-cyrillic-ext-700-italic.woff2 +0 -0
  47. package/dist/css/fonts/files/bitter-cyrillic-ext-700-normal.woff2 +0 -0
  48. package/dist/css/fonts/files/bitter-cyrillic-ext-800-italic.woff2 +0 -0
  49. package/dist/css/fonts/files/bitter-cyrillic-ext-800-normal.woff2 +0 -0
  50. package/dist/css/fonts/files/bitter-cyrillic-ext-900-italic.woff2 +0 -0
  51. package/dist/css/fonts/files/bitter-cyrillic-ext-900-normal.woff2 +0 -0
  52. package/dist/css/fonts/files/bitter-cyrillic-ext-wght-italic.woff2 +0 -0
  53. package/dist/css/fonts/files/bitter-cyrillic-ext-wght-normal.woff2 +0 -0
  54. package/dist/css/fonts/files/bitter-cyrillic-wght-italic.woff2 +0 -0
  55. package/dist/css/fonts/files/bitter-cyrillic-wght-normal.woff2 +0 -0
  56. package/dist/css/fonts/files/bitter-vietnamese-100-italic.woff2 +0 -0
  57. package/dist/css/fonts/files/bitter-vietnamese-100-normal.woff2 +0 -0
  58. package/dist/css/fonts/files/bitter-vietnamese-200-italic.woff2 +0 -0
  59. package/dist/css/fonts/files/bitter-vietnamese-200-normal.woff2 +0 -0
  60. package/dist/css/fonts/files/bitter-vietnamese-300-italic.woff2 +0 -0
  61. package/dist/css/fonts/files/bitter-vietnamese-300-normal.woff2 +0 -0
  62. package/dist/css/fonts/files/bitter-vietnamese-400-italic.woff2 +0 -0
  63. package/dist/css/fonts/files/bitter-vietnamese-400-normal.woff2 +0 -0
  64. package/dist/css/fonts/files/bitter-vietnamese-500-italic.woff2 +0 -0
  65. package/dist/css/fonts/files/bitter-vietnamese-500-normal.woff2 +0 -0
  66. package/dist/css/fonts/files/bitter-vietnamese-600-italic.woff2 +0 -0
  67. package/dist/css/fonts/files/bitter-vietnamese-600-normal.woff2 +0 -0
  68. package/dist/css/fonts/files/bitter-vietnamese-700-italic.woff2 +0 -0
  69. package/dist/css/fonts/files/bitter-vietnamese-700-normal.woff2 +0 -0
  70. package/dist/css/fonts/files/bitter-vietnamese-800-italic.woff2 +0 -0
  71. package/dist/css/fonts/files/bitter-vietnamese-800-normal.woff2 +0 -0
  72. package/dist/css/fonts/files/bitter-vietnamese-900-italic.woff2 +0 -0
  73. package/dist/css/fonts/files/bitter-vietnamese-900-normal.woff2 +0 -0
  74. package/dist/css/fonts/files/bitter-vietnamese-wght-italic.woff2 +0 -0
  75. package/dist/css/fonts/files/bitter-vietnamese-wght-normal.woff2 +0 -0
  76. package/dist/css/fonts/files/cairo-arabic-200-normal.woff2 +0 -0
  77. package/dist/css/fonts/files/cairo-arabic-300-normal.woff2 +0 -0
  78. package/dist/css/fonts/files/cairo-arabic-400-normal.woff2 +0 -0
  79. package/dist/css/fonts/files/cairo-arabic-500-normal.woff2 +0 -0
  80. package/dist/css/fonts/files/cairo-arabic-600-normal.woff2 +0 -0
  81. package/dist/css/fonts/files/cairo-arabic-700-normal.woff2 +0 -0
  82. package/dist/css/fonts/files/cairo-arabic-800-normal.woff2 +0 -0
  83. package/dist/css/fonts/files/cairo-arabic-900-normal.woff2 +0 -0
  84. package/dist/css/fonts/files/cairo-latin-200-normal.woff2 +0 -0
  85. package/dist/css/fonts/files/cairo-latin-300-normal.woff2 +0 -0
  86. package/dist/css/fonts/files/cairo-latin-400-normal.woff2 +0 -0
  87. package/dist/css/fonts/files/cairo-latin-500-normal.woff2 +0 -0
  88. package/dist/css/fonts/files/cairo-latin-600-normal.woff2 +0 -0
  89. package/dist/css/fonts/files/cairo-latin-700-normal.woff2 +0 -0
  90. package/dist/css/fonts/files/cairo-latin-800-normal.woff2 +0 -0
  91. package/dist/css/fonts/files/cairo-latin-900-normal.woff2 +0 -0
  92. package/dist/css/fonts/files/cairo-latin-ext-200-normal.woff2 +0 -0
  93. package/dist/css/fonts/files/cairo-latin-ext-300-normal.woff2 +0 -0
  94. package/dist/css/fonts/files/cairo-latin-ext-400-normal.woff2 +0 -0
  95. package/dist/css/fonts/files/cairo-latin-ext-500-normal.woff2 +0 -0
  96. package/dist/css/fonts/files/cairo-latin-ext-600-normal.woff2 +0 -0
  97. package/dist/css/fonts/files/cairo-latin-ext-700-normal.woff2 +0 -0
  98. package/dist/css/fonts/files/cairo-latin-ext-800-normal.woff2 +0 -0
  99. package/dist/css/fonts/files/cairo-latin-ext-900-normal.woff2 +0 -0
  100. package/dist/css/fonts/files/dancing-script-vietnamese-wght-normal.woff2 +0 -0
  101. package/dist/css/fonts/files/eb-garamond-cyrillic-ext-wght-italic.woff2 +0 -0
  102. package/dist/css/fonts/files/eb-garamond-cyrillic-ext-wght-normal.woff2 +0 -0
  103. package/dist/css/fonts/files/eb-garamond-cyrillic-wght-italic.woff2 +0 -0
  104. package/dist/css/fonts/files/eb-garamond-cyrillic-wght-normal.woff2 +0 -0
  105. package/dist/css/fonts/files/eb-garamond-greek-ext-wght-italic.woff2 +0 -0
  106. package/dist/css/fonts/files/eb-garamond-greek-ext-wght-normal.woff2 +0 -0
  107. package/dist/css/fonts/files/eb-garamond-greek-wght-italic.woff2 +0 -0
  108. package/dist/css/fonts/files/eb-garamond-greek-wght-normal.woff2 +0 -0
  109. package/dist/css/fonts/files/eb-garamond-vietnamese-wght-italic.woff2 +0 -0
  110. package/dist/css/fonts/files/eb-garamond-vietnamese-wght-normal.woff2 +0 -0
  111. package/dist/css/fonts/files/gentium-book-plus-cyrillic-400-italic.woff2 +0 -0
  112. package/dist/css/fonts/files/gentium-book-plus-cyrillic-400-normal.woff2 +0 -0
  113. package/dist/css/fonts/files/gentium-book-plus-cyrillic-700-italic.woff2 +0 -0
  114. package/dist/css/fonts/files/gentium-book-plus-cyrillic-700-normal.woff2 +0 -0
  115. package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-400-italic.woff2 +0 -0
  116. package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-400-normal.woff2 +0 -0
  117. package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-700-italic.woff2 +0 -0
  118. package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-700-normal.woff2 +0 -0
  119. package/dist/css/fonts/files/gentium-book-plus-greek-400-italic.woff2 +0 -0
  120. package/dist/css/fonts/files/gentium-book-plus-greek-400-normal.woff2 +0 -0
  121. package/dist/css/fonts/files/gentium-book-plus-greek-700-italic.woff2 +0 -0
  122. package/dist/css/fonts/files/gentium-book-plus-greek-700-normal.woff2 +0 -0
  123. package/dist/css/fonts/files/gentium-book-plus-greek-ext-400-italic.woff2 +0 -0
  124. package/dist/css/fonts/files/gentium-book-plus-greek-ext-400-normal.woff2 +0 -0
  125. package/dist/css/fonts/files/gentium-book-plus-greek-ext-700-italic.woff2 +0 -0
  126. package/dist/css/fonts/files/gentium-book-plus-greek-ext-700-normal.woff2 +0 -0
  127. package/dist/css/fonts/files/gentium-book-plus-vietnamese-400-italic.woff2 +0 -0
  128. package/dist/css/fonts/files/gentium-book-plus-vietnamese-400-normal.woff2 +0 -0
  129. package/dist/css/fonts/files/gentium-book-plus-vietnamese-700-italic.woff2 +0 -0
  130. package/dist/css/fonts/files/gentium-book-plus-vietnamese-700-normal.woff2 +0 -0
  131. package/dist/css/fonts/files/old-standard-tt-cyrillic-400-italic.woff2 +0 -0
  132. package/dist/css/fonts/files/old-standard-tt-cyrillic-400-normal.woff2 +0 -0
  133. package/dist/css/fonts/files/old-standard-tt-cyrillic-700-normal.woff2 +0 -0
  134. package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-400-italic.woff2 +0 -0
  135. package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-400-normal.woff2 +0 -0
  136. package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-700-normal.woff2 +0 -0
  137. package/dist/css/fonts/files/old-standard-tt-vietnamese-400-italic.woff2 +0 -0
  138. package/dist/css/fonts/files/old-standard-tt-vietnamese-400-normal.woff2 +0 -0
  139. package/dist/css/fonts/files/old-standard-tt-vietnamese-700-normal.woff2 +0 -0
  140. package/dist/css/fonts/files/open-sans-cyrillic-ext-wght-italic.woff2 +0 -0
  141. package/dist/css/fonts/files/open-sans-cyrillic-ext-wght-normal.woff2 +0 -0
  142. package/dist/css/fonts/files/open-sans-cyrillic-wght-italic.woff2 +0 -0
  143. package/dist/css/fonts/files/open-sans-cyrillic-wght-normal.woff2 +0 -0
  144. package/dist/css/fonts/files/open-sans-greek-ext-wght-italic.woff2 +0 -0
  145. package/dist/css/fonts/files/open-sans-greek-ext-wght-normal.woff2 +0 -0
  146. package/dist/css/fonts/files/open-sans-greek-wght-italic.woff2 +0 -0
  147. package/dist/css/fonts/files/open-sans-greek-wght-normal.woff2 +0 -0
  148. package/dist/css/fonts/files/open-sans-hebrew-wght-italic.woff2 +0 -0
  149. package/dist/css/fonts/files/open-sans-hebrew-wght-normal.woff2 +0 -0
  150. package/dist/css/fonts/files/open-sans-vietnamese-wght-italic.woff2 +0 -0
  151. package/dist/css/fonts/files/open-sans-vietnamese-wght-normal.woff2 +0 -0
  152. package/dist/css/fonts/files/playfair-display-cyrillic-wght-italic.woff2 +0 -0
  153. package/dist/css/fonts/files/playfair-display-cyrillic-wght-normal.woff2 +0 -0
  154. package/dist/css/fonts/files/playfair-display-sc-cyrillic-400-italic.woff2 +0 -0
  155. package/dist/css/fonts/files/playfair-display-sc-cyrillic-400-normal.woff2 +0 -0
  156. package/dist/css/fonts/files/playfair-display-sc-cyrillic-700-italic.woff2 +0 -0
  157. package/dist/css/fonts/files/playfair-display-sc-cyrillic-700-normal.woff2 +0 -0
  158. package/dist/css/fonts/files/playfair-display-sc-cyrillic-900-italic.woff2 +0 -0
  159. package/dist/css/fonts/files/playfair-display-sc-cyrillic-900-normal.woff2 +0 -0
  160. package/dist/css/fonts/files/playfair-display-sc-vietnamese-400-italic.woff2 +0 -0
  161. package/dist/css/fonts/files/playfair-display-sc-vietnamese-400-normal.woff2 +0 -0
  162. package/dist/css/fonts/files/playfair-display-sc-vietnamese-700-italic.woff2 +0 -0
  163. package/dist/css/fonts/files/playfair-display-sc-vietnamese-700-normal.woff2 +0 -0
  164. package/dist/css/fonts/files/playfair-display-sc-vietnamese-900-italic.woff2 +0 -0
  165. package/dist/css/fonts/files/playfair-display-sc-vietnamese-900-normal.woff2 +0 -0
  166. package/dist/css/fonts/files/playfair-display-vietnamese-wght-italic.woff2 +0 -0
  167. package/dist/css/fonts/files/playfair-display-vietnamese-wght-normal.woff2 +0 -0
  168. package/dist/css/fonts/files/pt-sans-cyrillic-400-italic.woff2 +0 -0
  169. package/dist/css/fonts/files/pt-sans-cyrillic-400-normal.woff2 +0 -0
  170. package/dist/css/fonts/files/pt-sans-cyrillic-700-italic.woff2 +0 -0
  171. package/dist/css/fonts/files/pt-sans-cyrillic-700-normal.woff2 +0 -0
  172. package/dist/css/fonts/files/pt-sans-cyrillic-ext-400-italic.woff2 +0 -0
  173. package/dist/css/fonts/files/pt-sans-cyrillic-ext-400-normal.woff2 +0 -0
  174. package/dist/css/fonts/files/pt-sans-cyrillic-ext-700-italic.woff2 +0 -0
  175. package/dist/css/fonts/files/pt-sans-cyrillic-ext-700-normal.woff2 +0 -0
  176. package/dist/css/fonts/files/pt-sans-narrow-cyrillic-400-normal.woff2 +0 -0
  177. package/dist/css/fonts/files/pt-sans-narrow-cyrillic-700-normal.woff2 +0 -0
  178. package/dist/css/fonts/files/pt-sans-narrow-cyrillic-ext-400-normal.woff2 +0 -0
  179. package/dist/css/fonts/files/pt-sans-narrow-cyrillic-ext-700-normal.woff2 +0 -0
  180. package/dist/css/fonts/files/pt-serif-cyrillic-400-italic.woff2 +0 -0
  181. package/dist/css/fonts/files/pt-serif-cyrillic-400-normal.woff2 +0 -0
  182. package/dist/css/fonts/files/pt-serif-cyrillic-700-italic.woff2 +0 -0
  183. package/dist/css/fonts/files/pt-serif-cyrillic-700-normal.woff2 +0 -0
  184. package/dist/css/fonts/files/pt-serif-cyrillic-ext-400-italic.woff2 +0 -0
  185. package/dist/css/fonts/files/pt-serif-cyrillic-ext-400-normal.woff2 +0 -0
  186. package/dist/css/fonts/files/pt-serif-cyrillic-ext-700-italic.woff2 +0 -0
  187. package/dist/css/fonts/files/pt-serif-cyrillic-ext-700-normal.woff2 +0 -0
  188. package/dist/css/fonts/files/raleway-cyrillic-ext-wght-italic.woff2 +0 -0
  189. package/dist/css/fonts/files/raleway-cyrillic-ext-wght-normal.woff2 +0 -0
  190. package/dist/css/fonts/files/raleway-cyrillic-wght-italic.woff2 +0 -0
  191. package/dist/css/fonts/files/raleway-cyrillic-wght-normal.woff2 +0 -0
  192. package/dist/css/fonts/files/raleway-vietnamese-wght-italic.woff2 +0 -0
  193. package/dist/css/fonts/files/raleway-vietnamese-wght-normal.woff2 +0 -0
  194. package/dist/css/fonts/files/roboto-slab-cyrillic-ext-wght-normal.woff2 +0 -0
  195. package/dist/css/fonts/files/roboto-slab-cyrillic-wght-normal.woff2 +0 -0
  196. package/dist/css/fonts/files/roboto-slab-greek-ext-wght-normal.woff2 +0 -0
  197. package/dist/css/fonts/files/roboto-slab-greek-wght-normal.woff2 +0 -0
  198. package/dist/css/fonts/files/roboto-slab-vietnamese-wght-normal.woff2 +0 -0
  199. package/dist/css/fonts/files/ubuntu-cyrillic-300-italic.woff2 +0 -0
  200. package/dist/css/fonts/files/ubuntu-cyrillic-300-normal.woff2 +0 -0
  201. package/dist/css/fonts/files/ubuntu-cyrillic-400-italic.woff2 +0 -0
  202. package/dist/css/fonts/files/ubuntu-cyrillic-400-normal.woff2 +0 -0
  203. package/dist/css/fonts/files/ubuntu-cyrillic-500-italic.woff2 +0 -0
  204. package/dist/css/fonts/files/ubuntu-cyrillic-500-normal.woff2 +0 -0
  205. package/dist/css/fonts/files/ubuntu-cyrillic-700-italic.woff2 +0 -0
  206. package/dist/css/fonts/files/ubuntu-cyrillic-700-normal.woff2 +0 -0
  207. package/dist/css/fonts/files/ubuntu-cyrillic-ext-300-italic.woff2 +0 -0
  208. package/dist/css/fonts/files/ubuntu-cyrillic-ext-300-normal.woff2 +0 -0
  209. package/dist/css/fonts/files/ubuntu-cyrillic-ext-400-italic.woff2 +0 -0
  210. package/dist/css/fonts/files/ubuntu-cyrillic-ext-400-normal.woff2 +0 -0
  211. package/dist/css/fonts/files/ubuntu-cyrillic-ext-500-italic.woff2 +0 -0
  212. package/dist/css/fonts/files/ubuntu-cyrillic-ext-500-normal.woff2 +0 -0
  213. package/dist/css/fonts/files/ubuntu-cyrillic-ext-700-italic.woff2 +0 -0
  214. package/dist/css/fonts/files/ubuntu-cyrillic-ext-700-normal.woff2 +0 -0
  215. package/dist/css/fonts/files/ubuntu-greek-300-italic.woff2 +0 -0
  216. package/dist/css/fonts/files/ubuntu-greek-300-normal.woff2 +0 -0
  217. package/dist/css/fonts/files/ubuntu-greek-400-italic.woff2 +0 -0
  218. package/dist/css/fonts/files/ubuntu-greek-400-normal.woff2 +0 -0
  219. package/dist/css/fonts/files/ubuntu-greek-500-italic.woff2 +0 -0
  220. package/dist/css/fonts/files/ubuntu-greek-500-normal.woff2 +0 -0
  221. package/dist/css/fonts/files/ubuntu-greek-700-italic.woff2 +0 -0
  222. package/dist/css/fonts/files/ubuntu-greek-700-normal.woff2 +0 -0
  223. package/dist/css/fonts/files/ubuntu-greek-ext-300-italic.woff2 +0 -0
  224. package/dist/css/fonts/files/ubuntu-greek-ext-300-normal.woff2 +0 -0
  225. package/dist/css/fonts/files/ubuntu-greek-ext-400-italic.woff2 +0 -0
  226. package/dist/css/fonts/files/ubuntu-greek-ext-400-normal.woff2 +0 -0
  227. package/dist/css/fonts/files/ubuntu-greek-ext-500-italic.woff2 +0 -0
  228. package/dist/css/fonts/files/ubuntu-greek-ext-500-normal.woff2 +0 -0
  229. package/dist/css/fonts/files/ubuntu-greek-ext-700-italic.woff2 +0 -0
  230. package/dist/css/fonts/files/ubuntu-greek-ext-700-normal.woff2 +0 -0
  231. package/dist/css/fonts/files/unica-one-vietnamese-400-normal.woff2 +0 -0
  232. package/dist/css/fonts/files/vollkorn-cyrillic-ext-wght-italic.woff2 +0 -0
  233. package/dist/css/fonts/files/vollkorn-cyrillic-ext-wght-normal.woff2 +0 -0
  234. package/dist/css/fonts/files/vollkorn-cyrillic-wght-italic.woff2 +0 -0
  235. package/dist/css/fonts/files/vollkorn-cyrillic-wght-normal.woff2 +0 -0
  236. package/dist/css/fonts/files/vollkorn-greek-wght-italic.woff2 +0 -0
  237. package/dist/css/fonts/files/vollkorn-greek-wght-normal.woff2 +0 -0
  238. package/dist/css/fonts/files/vollkorn-vietnamese-wght-italic.woff2 +0 -0
  239. package/dist/css/fonts/files/vollkorn-vietnamese-wght-normal.woff2 +0 -0
  240. package/dist/css/fonts/font.abril-droidsans.css +3 -0
  241. package/dist/css/fonts/font.amatic-andika.css +57 -0
  242. package/dist/css/fonts/font.bitter-raleway.css +111 -0
  243. package/dist/css/fonts/font.cairo.css +312 -0
  244. package/dist/css/fonts/font.clicker-garamond.css +93 -0
  245. package/dist/css/fonts/font.dancing-ledger.css +12 -0
  246. package/dist/css/fonts/font.default.css +183 -0
  247. package/dist/css/fonts/font.fjalla-average.css +3 -0
  248. package/dist/css/fonts/font.georgia-helvetica.css +3 -0
  249. package/dist/css/fonts/font.knightlab.css +642 -0
  250. package/dist/css/fonts/font.lustria-lato.css +3 -0
  251. package/dist/css/fonts/font.medula-lato.css +3 -0
  252. package/dist/css/fonts/font.oldstandard.css +84 -0
  253. package/dist/css/fonts/font.opensans-gentiumbook.css +183 -0
  254. package/dist/css/fonts/font.playfair-faunaone.css +39 -0
  255. package/dist/css/fonts/font.playfair.css +147 -0
  256. package/dist/css/fonts/font.pt.css +183 -0
  257. package/dist/css/fonts/font.roboto-megrim.css +3 -0
  258. package/dist/css/fonts/font.rufina-sintony.css +3 -0
  259. package/dist/css/fonts/font.ubuntu.css +291 -0
  260. package/dist/css/fonts/font.unicaone-vollkorn.css +84 -0
  261. package/dist/css/storymap.css +1 -1
  262. package/dist/demo.html +3 -2
  263. package/dist/docs/api/.nojekyll +1 -0
  264. package/dist/docs/api/assets/custom.css +47 -0
  265. package/dist/docs/api/assets/hierarchy.js +1 -0
  266. package/dist/docs/api/assets/highlight.css +57 -0
  267. package/dist/docs/api/assets/icons.js +18 -0
  268. package/dist/docs/api/assets/icons.svg +1 -0
  269. package/dist/docs/api/assets/main.js +60 -0
  270. package/dist/docs/api/assets/navigation.js +1 -0
  271. package/dist/docs/api/assets/search.js +1 -0
  272. package/dist/docs/api/assets/style.css +1648 -0
  273. package/dist/docs/api/classes/StoryMap.html +2112 -0
  274. package/dist/docs/api/functions/MediaType.html +51 -0
  275. package/dist/docs/api/functions/isPresentation3Collection.html +47 -0
  276. package/dist/docs/api/functions/isPresentation3Manifest.html +53 -0
  277. package/dist/docs/api/functions/isSlideshowCollection.html +51 -0
  278. package/dist/docs/api/functions/loadCSS.html +63 -0
  279. package/dist/docs/api/functions/manifestToStorymapData.html +49 -0
  280. package/dist/docs/api/functions/setLanguage.html +54 -0
  281. package/dist/docs/api/functions/slideshowToStorymapData.html +51 -0
  282. package/dist/docs/api/functions/slideshowVersion.html +48 -0
  283. package/dist/docs/api/functions/storymapToManifest.html +57 -0
  284. package/dist/docs/api/functions/validateStorymap.html +53 -0
  285. package/dist/docs/api/functions/validateStorymapAndReport.html +54 -0
  286. package/dist/docs/api/hierarchy.html +28 -0
  287. package/dist/docs/api/index.html +300 -0
  288. package/dist/docs/api/interfaces/AnimationHandle.html +60 -0
  289. package/dist/docs/api/interfaces/ImagereadyPayload.html +71 -0
  290. package/dist/docs/api/interfaces/LanguageEntry.html +102 -0
  291. package/dist/docs/api/interfaces/LatLngLiteral.html +73 -0
  292. package/dist/docs/api/interfaces/LoadOptions.html +59 -0
  293. package/dist/docs/api/interfaces/MapEvents.html +116 -0
  294. package/dist/docs/api/interfaces/MapMarkerEvents.html +66 -0
  295. package/dist/docs/api/interfaces/MarkerEventPayload.html +57 -0
  296. package/dist/docs/api/interfaces/MediaEvents.html +78 -0
  297. package/dist/docs/api/interfaces/MenuBarEvents.html +114 -0
  298. package/dist/docs/api/interfaces/MessageEvents.html +72 -0
  299. package/dist/docs/api/interfaces/OlLayer.html +1400 -0
  300. package/dist/docs/api/interfaces/OlMap.html +1656 -0
  301. package/dist/docs/api/interfaces/OlProjection.html +332 -0
  302. package/dist/docs/api/interfaces/OlSource.html +784 -0
  303. package/dist/docs/api/interfaces/OlTileLayer.html +1497 -0
  304. package/dist/docs/api/interfaces/OlVectorLayer.html +1559 -0
  305. package/dist/docs/api/interfaces/OlView.html +1958 -0
  306. package/dist/docs/api/interfaces/SlideBackgroundState.html +66 -0
  307. package/dist/docs/api/interfaces/SlideEvents.html +78 -0
  308. package/dist/docs/api/interfaces/SlideNavEvents.html +72 -0
  309. package/dist/docs/api/interfaces/SlideshowPlayerSettings.html +182 -0
  310. package/dist/docs/api/interfaces/SlideshowToStorymapResult.html +60 -0
  311. package/dist/docs/api/interfaces/SlideshowTranslatorOptions.html +65 -0
  312. package/dist/docs/api/interfaces/SlideshowWarning.html +72 -0
  313. package/dist/docs/api/interfaces/StoryMapEvents.html +146 -0
  314. package/dist/docs/api/interfaces/StoryMapMarker.html +763 -0
  315. package/dist/docs/api/interfaces/StorySliderEvents.html +102 -0
  316. package/dist/docs/api/interfaces/StorymapBasemap.html +69 -0
  317. package/dist/docs/api/interfaces/StorymapData.html +77 -0
  318. package/dist/docs/api/interfaces/StorymapDataWrapper.html +60 -0
  319. package/dist/docs/api/interfaces/StorymapDocument.html +68 -0
  320. package/dist/docs/api/interfaces/StorymapError.html +63 -0
  321. package/dist/docs/api/interfaces/StorymapGeoreference.html +93 -0
  322. package/dist/docs/api/interfaces/StorymapGeoreferenceBody.html +74 -0
  323. package/dist/docs/api/interfaces/StorymapGroundControlPoint.html +69 -0
  324. package/dist/docs/api/interfaces/StorymapLanguageMap.html +57 -0
  325. package/dist/docs/api/interfaces/StorymapManifest.html +118 -0
  326. package/dist/docs/api/interfaces/StorymapManifestAnnotation.html +137 -0
  327. package/dist/docs/api/interfaces/StorymapManifestBackground.html +81 -0
  328. package/dist/docs/api/interfaces/StorymapManifestCanvas.html +188 -0
  329. package/dist/docs/api/interfaces/StorymapManifestContentResource.html +97 -0
  330. package/dist/docs/api/interfaces/StorymapManifestFeatureCollection.html +82 -0
  331. package/dist/docs/api/interfaces/StorymapManifestGeoreferencing.html +87 -0
  332. package/dist/docs/api/interfaces/StorymapManifestImageService.html +69 -0
  333. package/dist/docs/api/interfaces/StorymapManifestRange.html +75 -0
  334. package/dist/docs/api/interfaces/StorymapManifestService.html +77 -0
  335. package/dist/docs/api/interfaces/StorymapManifestSpecificResource.html +69 -0
  336. package/dist/docs/api/interfaces/StorymapManifestStatement.html +63 -0
  337. package/dist/docs/api/interfaces/StorymapMapOptions.html +92 -0
  338. package/dist/docs/api/interfaces/StorymapOptions.html +734 -0
  339. package/dist/docs/api/interfaces/StorymapOverlayLayer.html +157 -0
  340. package/dist/docs/api/interfaces/StorymapSlide.html +186 -0
  341. package/dist/docs/api/interfaces/StorymapSlideBackground.html +72 -0
  342. package/dist/docs/api/interfaces/StorymapSlideLocation.html +161 -0
  343. package/dist/docs/api/interfaces/StorymapSlideMarker.html +103 -0
  344. package/dist/docs/api/interfaces/StorymapSlideMedia.html +149 -0
  345. package/dist/docs/api/interfaces/StorymapSlideNarration.html +117 -0
  346. package/dist/docs/api/interfaces/StorymapSlideText.html +72 -0
  347. package/dist/docs/api/interfaces/StorymapTilejson.html +105 -0
  348. package/dist/docs/api/interfaces/SwipableEvents.html +102 -0
  349. package/dist/docs/api/interfaces/TextEvents.html +66 -0
  350. package/dist/docs/api/media/es.json +31 -0
  351. package/dist/docs/api/media/events.md +66 -0
  352. package/dist/docs/api/media/migration-from-knightlab.md +399 -0
  353. package/dist/docs/api/media/slideshow.md +109 -0
  354. package/dist/docs/api/media/storymap-as-iiif-manifest.md +1007 -0
  355. package/dist/docs/api/modules.html +50 -0
  356. package/dist/docs/api/types/ContentState.html +70 -0
  357. package/dist/docs/api/types/FiredEvent.html +43 -0
  358. package/dist/docs/api/types/ImagereadyKind.html +34 -0
  359. package/dist/docs/api/types/MediaTypeMatch.html +78 -0
  360. package/dist/docs/api/types/SwipeDirection.html +36 -0
  361. package/dist/docs/api/types/TileSourceFactory.html +53 -0
  362. package/dist/docs/authoring.html +306 -0
  363. package/dist/docs/events.html +263 -0
  364. package/dist/docs/iiif.html +738 -69
  365. package/dist/docs/migration.html +360 -11
  366. package/dist/docs/readme.html +149 -17
  367. package/dist/embed/index.html +8 -1
  368. package/dist/examples/font-css-false.json +33 -0
  369. package/dist/examples/issue-242-nonlatin.json +55 -0
  370. package/dist/examples/issue-305-start-at-slide-data.json +96 -0
  371. package/dist/examples/issue-358-iframe.json +57 -0
  372. package/dist/examples/issue-iiif-geo.json +32 -0
  373. package/dist/examples/issue-image-alt.json +23 -0
  374. package/dist/examples/issue-image-region.json +42 -0
  375. package/dist/examples/issue-image-srcset.json +24 -0
  376. package/dist/examples/layers-control.json +60 -0
  377. package/dist/examples/map-area-overlays.json +68 -0
  378. package/dist/examples/map-template-provider.json +36 -0
  379. package/dist/examples/multi-media.json +71 -0
  380. package/dist/examples/no-map.json +44 -0
  381. package/dist/examples-iiif/annotated-image.json +204 -0
  382. package/dist/examples-iiif/bob-boilen.json +11 -5
  383. package/dist/examples-iiif/courbet.json +9 -6
  384. package/dist/examples-iiif/empty.json +8 -4
  385. package/dist/examples-iiif/flickr-drewvigal.json +248 -24
  386. package/dist/examples-iiif/flickr-zach.wise.json +8 -4
  387. package/dist/examples-iiif/font-css-false.json +114 -0
  388. package/dist/examples-iiif/georeferenced-layer-unsupported.json +153 -0
  389. package/dist/examples-iiif/georeferenced-layer.json +318 -0
  390. package/dist/examples-iiif/iiif-wellcome.json +9 -6
  391. package/dist/examples-iiif/instagram-chenpamela.json +381 -36
  392. package/dist/examples-iiif/instagram_brentfoster.json +429 -40
  393. package/dist/examples-iiif/instagram_couch.json +261 -26
  394. package/dist/examples-iiif/instagram_joegermuska.json +441 -41
  395. package/dist/examples-iiif/instagram_matteich.json +261 -26
  396. package/dist/examples-iiif/instagram_mirandamulligan.json +261 -26
  397. package/dist/examples-iiif/instagram_raymondmccrea.json +261 -26
  398. package/dist/examples-iiif/instagram_test.json +261 -26
  399. package/dist/examples-iiif/issue-134-no-lines.json +9 -5
  400. package/dist/examples-iiif/issue-177-color-vars.json +9 -5
  401. package/dist/examples-iiif/issue-211-rtl.json +9 -5
  402. package/dist/examples-iiif/issue-242-nonlatin.json +239 -0
  403. package/dist/examples-iiif/issue-272-facebook.json +9 -5
  404. package/dist/examples-iiif/issue-305-start-at-slide-data.json +305 -0
  405. package/dist/examples-iiif/issue-305-start-at-slide.json +9 -5
  406. package/dist/examples-iiif/issue-341-distance.json +9 -5
  407. package/dist/examples-iiif/issue-358-iframe.json +190 -0
  408. package/dist/examples-iiif/issue-360-juxtapose.json +22 -7
  409. package/dist/examples-iiif/issue-368-null-fields.json +9 -5
  410. package/dist/examples-iiif/issue-375-code-lists.json +9 -5
  411. package/dist/examples-iiif/issue-381-dateline.json +9 -5
  412. package/dist/examples-iiif/issue-405-custom-icon.json +203 -0
  413. package/dist/examples-iiif/issue-425-many-slides.json +9 -5
  414. package/dist/examples-iiif/issue-428-background.json +20 -8
  415. package/dist/examples-iiif/issue-435-googlemaps.json +9 -5
  416. package/dist/examples-iiif/issue-437-documentcloud.json +22 -7
  417. package/dist/examples-iiif/issue-451-webp.json +9 -5
  418. package/dist/examples-iiif/issue-455-audio.json +9 -5
  419. package/dist/examples-iiif/issue-480-shorts.json +9 -5
  420. package/dist/examples-iiif/issue-506-marker-sync.json +9 -5
  421. package/dist/examples-iiif/issue-iiif-geo.json +200 -0
  422. package/dist/examples-iiif/issue-image-alt.json +90 -0
  423. package/dist/examples-iiif/issue-image-region.json +203 -0
  424. package/dist/examples-iiif/issue-image-srcset.json +87 -0
  425. package/dist/examples-iiif/jansteen.json +9 -6
  426. package/dist/examples-iiif/katrina.json +197 -41
  427. package/dist/examples-iiif/layers-control.json +226 -0
  428. package/dist/examples-iiif/map-area-overlays.json +289 -0
  429. package/dist/examples-iiif/map-template-provider.json +133 -0
  430. package/dist/examples-iiif/marktwain.json +255 -58
  431. package/dist/examples-iiif/multi-media.json +237 -0
  432. package/dist/examples-iiif/no-map.json +148 -0
  433. package/dist/examples-iiif/population.json +440 -76
  434. package/dist/examples-iiif/president.json +456 -83
  435. package/dist/examples-iiif/scroll-hint.json +190 -0
  436. package/dist/examples-iiif/seurat.json +113 -22
  437. package/dist/examples-iiif/zoomify-clamp.json +153 -0
  438. package/dist/examples-slideshow/single.json +79 -0
  439. package/dist/examples-slideshow/static/pixel.png +0 -0
  440. package/dist/examples-slideshow/static.json +44 -0
  441. package/dist/harness-multi.html +17 -0
  442. package/dist/harness.html +4 -2
  443. package/dist/index.html +19 -12
  444. package/dist/js/storymap.d.ts +2669 -307
  445. package/dist/js/storymap.js +10820 -6196
  446. package/dist/js/storymap.js.map +1 -1
  447. package/dist/navplace-properties.json +54 -0
  448. package/dist/site.css +54 -36
  449. package/docs/migration-from-knightlab.md +279 -19
  450. package/docs/storymap-as-iiif-manifest.md +633 -136
  451. package/package.json +12 -7
  452. package/schema/storymap.schema.json +733 -80
  453. package/src/animation/easings.ts +8 -1
  454. package/src/animation/tween.ts +186 -0
  455. package/src/core/Browser.ts +6 -5
  456. package/src/core/Load.ts +54 -2
  457. package/src/core/Util.ts +257 -193
  458. package/src/core/mixins.ts +66 -35
  459. package/src/core/viewers.ts +94 -0
  460. package/src/dom/DomEvent.ts +27 -8
  461. package/src/globals.d.ts +8 -0
  462. package/src/language/Language.ts +206 -25
  463. package/src/language/locale/.expected-gaps.json +346 -0
  464. package/src/language/locale/be.json +15 -2
  465. package/src/language/locale/bg.json +15 -2
  466. package/src/language/locale/cs.json +15 -2
  467. package/src/language/locale/de.json +23 -2
  468. package/src/language/locale/el.json +15 -2
  469. package/src/language/locale/en.json +17 -3
  470. package/src/language/locale/es.json +15 -2
  471. package/src/language/locale/et.json +5 -1
  472. package/src/language/locale/fr.json +15 -2
  473. package/src/language/locale/he.json +16 -3
  474. package/src/language/locale/hu.json +15 -2
  475. package/src/language/locale/is.json +5 -1
  476. package/src/language/locale/it.json +15 -2
  477. package/src/language/locale/jp.json +15 -2
  478. package/src/language/locale/ko.json +15 -2
  479. package/src/language/locale/nl.json +15 -2
  480. package/src/language/locale/nn.json +5 -1
  481. package/src/language/locale/no.json +15 -2
  482. package/src/language/locale/pl.json +15 -2
  483. package/src/language/locale/pt.json +15 -2
  484. package/src/language/locale/ru.json +15 -2
  485. package/src/language/locale/sk.json +15 -2
  486. package/src/language/locale/sr.json +5 -1
  487. package/src/language/locale/sv.json +15 -2
  488. package/src/language/locale/tr.json +15 -2
  489. package/src/language/locale/uk.json +15 -2
  490. package/src/language/locale/ur.json +5 -1
  491. package/src/language/locale/zh-cn.json +15 -2
  492. package/src/language/locale/zh-tw.json +15 -2
  493. package/src/main.ts +143 -0
  494. package/src/map/Map.ts +227 -112
  495. package/src/map/MapMarker.ts +44 -8
  496. package/src/map/georeference.ts +325 -0
  497. package/src/map/openlayers/Map.OpenLayers.ts +1601 -263
  498. package/src/map/openlayers/MapMarker.OpenLayers.ts +348 -24
  499. package/src/map/openlayers/zoomifyTiles.ts +1 -4
  500. package/src/map/slideView.ts +149 -0
  501. package/src/map/types.ts +2 -0
  502. package/src/media/EmbedUtil.ts +333 -24
  503. package/src/media/Media.ts +270 -59
  504. package/src/media/embedId.ts +110 -0
  505. package/src/media/types/Audio.ts +9 -82
  506. package/src/media/types/Blockquote.ts +2 -3
  507. package/src/media/types/DailyMotion.ts +5 -10
  508. package/src/media/types/DocumentCloud.ts +6 -35
  509. package/src/media/types/Facebook.ts +3 -4
  510. package/src/media/types/Flickr.ts +23 -12
  511. package/src/media/types/GoogleDoc.ts +3 -4
  512. package/src/media/types/HtmlMedia.ts +233 -0
  513. package/src/media/types/IFrame.ts +3 -4
  514. package/src/media/types/Image.ts +87 -9
  515. package/src/media/types/Juxtapose.ts +14 -5
  516. package/src/media/types/SoundCloud.ts +28 -6
  517. package/src/media/types/Text.ts +46 -21
  518. package/src/media/types/Twitter.ts +35 -20
  519. package/src/media/types/Video.ts +9 -79
  520. package/src/media/types/Vimeo.ts +11 -11
  521. package/src/media/types/Website.ts +23 -13
  522. package/src/media/types/Wikipedia.ts +18 -8
  523. package/src/media/types/YouTube.ts +72 -40
  524. package/src/scss/Typography.scss +16 -18
  525. package/src/scss/VCO.StoryMap.scss +208 -12
  526. package/src/scss/Variables.scss +3 -2
  527. package/src/scss/core/Mixins.scss +5 -63
  528. package/src/scss/core/Reset.scss +5 -18
  529. package/src/scss/fonts/font.amatic-andika.scss +3 -0
  530. package/src/scss/fonts/font.bitter-raleway.scss +6 -0
  531. package/src/scss/fonts/font.cairo.scss +79 -0
  532. package/src/scss/fonts/font.clicker-garamond.scss +5 -0
  533. package/src/scss/fonts/font.dancing-ledger.scss +1 -0
  534. package/src/scss/fonts/font.knightlab.scss +14 -0
  535. package/src/scss/fonts/font.oldstandard.scss +6 -0
  536. package/src/scss/fonts/font.opensans-gentiumbook.scss +5 -0
  537. package/src/scss/fonts/font.playfair-faunaone.scss +2 -0
  538. package/src/scss/fonts/font.playfair.scss +4 -0
  539. package/src/scss/fonts/font.pt.scss +6 -0
  540. package/src/scss/fonts/font.ubuntu.scss +4 -0
  541. package/src/scss/fonts/font.unicaone-vollkorn.scss +5 -0
  542. package/src/scss/map/VCO.Map.scss +1 -1
  543. package/src/scss/map/VCO.MapMarker.scss +114 -8
  544. package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +7 -4
  545. package/src/scss/media/VCO.Media.scss +14 -17
  546. package/src/scss/media/types/VCO.Media.Blockquote.scss +1 -1
  547. package/src/scss/media/types/VCO.Media.Text.scss +13 -5
  548. package/src/scss/media/types/VCO.Media.Twitter.scss +4 -4
  549. package/src/scss/media/types/VCO.Media.Wikipedia.scss +3 -19
  550. package/src/scss/site/site.scss +55 -36
  551. package/src/scss/slider/VCO.Slide.scss +61 -13
  552. package/src/scss/slider/VCO.SlideNav.scss +34 -24
  553. package/src/scss/slider/VCO.StorySlider.scss +53 -1
  554. package/src/scss/snapmap/VCO.SnapMap.scss +3 -3
  555. package/src/scss/ui/VCO.Consent.scss +63 -13
  556. package/src/scss/ui/VCO.Layers.scss +119 -0
  557. package/src/scss/ui/VCO.MenuBar.Button.scss +143 -14
  558. package/src/scss/ui/VCO.MenuBar.scss +9 -2
  559. package/src/scss/ui/VCO.Message.scss +16 -5
  560. package/src/site/site.ts +2 -1
  561. package/src/slider/Slide.ts +326 -74
  562. package/src/slider/SlideNav.ts +54 -9
  563. package/src/slider/StorySlider.ts +352 -60
  564. package/src/storymap/Consent.ts +429 -82
  565. package/src/storymap/StoryMap.ts +2163 -340
  566. package/src/storymap/content-state.ts +180 -0
  567. package/src/storymap/font-css.ts +72 -0
  568. package/src/storymap/from-slideshow.ts +785 -0
  569. package/src/storymap/iiif-shared.ts +228 -0
  570. package/src/storymap/iiif.ts +1742 -95
  571. package/src/storymap/to-iiif.ts +1008 -0
  572. package/src/storymap/validate.ts +80 -7
  573. package/src/types.ts +591 -29
  574. package/src/ui/LayersControl.ts +302 -0
  575. package/src/ui/MenuBar.ts +325 -61
  576. package/src/ui/Message.ts +44 -2
  577. package/src/ui/Swipable.ts +170 -85
  578. package/dist/assets/demo-D6cTmzn6.js +0 -1
  579. package/dist/assets/harness-BPFlzutp.js +0 -1
  580. package/dist/assets/index-DDsPBPZA.js +0 -1
  581. package/dist/assets/main-Y-MYUSJ4.js +0 -17
  582. package/src/animation/morpheus.d.ts +0 -41
  583. package/src/scss/VCO.StoryMap.Dark.scss +0 -138
  584. package/src/scss/Variables.Dark.scss +0 -56
  585. package/src/scss/media/types/VCO.Media.Instagram.scss +0 -7
  586. package/src/scss/media/types/VCO.Media.Profile.scss +0 -5
  587. package/src/ui/Draggable.ts +0 -374
@@ -6,52 +6,258 @@
6
6
 
7
7
  import type {
8
8
  StorymapData,
9
+ StorymapGeoreference,
10
+ StorymapTilejson,
11
+ StorymapOverlayLayer,
9
12
  StorymapSlide,
10
13
  StorymapSlideBackground,
11
14
  StorymapSlideLocation,
12
15
  StorymapSlideMedia,
13
16
  } from "../types";
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";
14
31
 
15
32
  const PRESENTATION_3_CONTEXT = "iiif.io/api/presentation/3/context.json";
16
33
  const MAPCONFIG_PROFILE = "mapconfig";
17
34
  const STORYMAP_PREFIX = "storymap:";
18
35
 
19
- /** Keys copied verbatim from navPlace Feature properties onto the location. */
20
- const LOCATION_PROPERTIES = [
21
- "name",
22
- "zoom",
23
- "line",
24
- "icon",
25
- "iconSize",
26
- "image",
27
- "use_custom_marker",
28
- ] as const;
29
-
30
- function asRecord(value: unknown): Record<string, unknown> | null {
31
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
32
- return null;
36
+ /**
37
+ * Reads a IIIF xywh image region (`storymap:imageRegion`): an array of
38
+ * exactly 4 finite numbers ([x, y, w, h] in image pixels). Returns null
39
+ * for anything else — invalid regions are ignored.
40
+ */
41
+ /**
42
+ * A language map reduced to one string in the language the viewer is using,
43
+ * together with the language that was actually chosen (§3.4).
44
+ *
45
+ * `flattenLanguageMap` used to concatenate *every* language, which loses which
46
+ * language each string was in — for a bilingual tour that produced a headline
47
+ * of "Hallo Hello" and no way to tell a host what the viewer had picked. The
48
+ * choice is: the configured language, then its base subtag (`de-AT` → `de`),
49
+ * then the language-neutral `none`, then the first key present.
50
+ *
51
+ * `language` is null for `none`, which is genuinely language-neutral and not a
52
+ * language a host could offer in a switch.
53
+ */
54
+ export function pickLanguageMap(
55
+ value: unknown,
56
+ preferred: string | null = null,
57
+ ): { value: string; language: string | null } {
58
+ if (typeof value === "string") return { value, language: null };
59
+ const record = asRecord(value);
60
+ if (!record) return { value: "", language: null };
61
+ // a TextualBody carries its text in `value`; that is a single string, not
62
+ // a language map, so it keeps flattenLanguageMap's shape
63
+ const textual = record.value;
64
+ if (typeof textual === "string") return { value: textual, language: null };
65
+ if (Array.isArray(textual)) {
66
+ return { value: asStringArray(textual).join(" ").trim(), language: null };
67
+ }
68
+
69
+ const keys = Object.keys(record);
70
+ if (keys.length === 0) return { value: "", language: null };
71
+ const join = (key: string): string => asStringArray(record[key]).join(" ").trim();
72
+
73
+ if (preferred !== null && preferred !== "") {
74
+ for (const key of keys) {
75
+ if (key.toLowerCase() === preferred.toLowerCase()) {
76
+ return { value: join(key), language: key };
77
+ }
78
+ }
79
+ const base = preferred.split("-")[0].toLowerCase();
80
+ for (const key of keys) {
81
+ if (key.toLowerCase() === base) return { value: join(key), language: key };
82
+ }
33
83
  }
34
- return value as Record<string, unknown>;
84
+ if ("none" in record) return { value: join("none"), language: null };
85
+ return { value: join(keys[0]), language: keys[0] };
35
86
  }
36
87
 
37
- function asString(value: unknown): string | null {
38
- return typeof value === "string" && value !== "" ? value : null;
88
+ /**
89
+ * A selector, normalized. Every field is optional and the object is a
90
+ * *superset* carrier: an annotation may carry a region, a point, a quote and a
91
+ * time range at once, and we keep what we understand instead of stopping at
92
+ * the first hit.
93
+ *
94
+ * - `region`: xywh image pixels. From an Image API Selector / FragmentSelector
95
+ * (`xywh=`), or synthesized from a `point` when the canvas size is known.
96
+ * - `point`: x/y image pixels, from a `PointSelector`.
97
+ * - `quote`: a `TextQuoteSelector`. Preserved, **not resolved** — a canvas
98
+ * carries no transcript, so matching the quoted text needs one the tour
99
+ * supplies. See docs/iiif-authoring.md.
100
+ * - `time`: start/end in seconds, from a `TimeState` or `start`/`end` on a
101
+ * SpecificResource, for time-anchored stops and narration.
102
+ * - `svg`: an `SvgSelector`'s shape markup. Preserved for round-tripping; the
103
+ * viewer fits `region`, so a non-rectangular selection is not highlighted
104
+ * with its true outline yet.
105
+ */
106
+ export interface ReadSelector {
107
+ region: [number, number, number, number] | null;
108
+ point: { x: number; y: number } | null;
109
+ quote: { exact: string; prefix?: string; suffix?: string } | null;
110
+ time: { start?: number; end?: number } | null;
111
+ svg: string | null;
39
112
  }
40
113
 
41
- function asNumber(value: unknown): number | null {
42
- return typeof value === "number" && !isNaN(value) ? value : null;
114
+ const EMPTY_SELECTOR: ReadSelector = {
115
+ region: null,
116
+ point: null,
117
+ quote: null,
118
+ time: null,
119
+ svg: null,
120
+ };
121
+
122
+ function isEmptySelector(sel: ReadSelector): boolean {
123
+ return (
124
+ sel.region === null &&
125
+ sel.point === null &&
126
+ sel.quote === null &&
127
+ sel.time === null &&
128
+ sel.svg === null
129
+ );
43
130
  }
44
131
 
45
- function asBoolean(value: unknown): boolean | null {
46
- return typeof value === "boolean" ? value : null;
132
+ /** A `PointSelector` is a pin, which the viewer cannot fit, so we synthesize
133
+ * a square of 5% of the canvas's smaller side, centred on the point and
134
+ * clamped to the canvas. Stated once, here, so annotation-driven stops
135
+ * share it rather than re-deriving it. */
136
+ const POINT_SQUARE_FRACTION = 0.05;
137
+
138
+ function pointToRegion(
139
+ point: { x: number; y: number },
140
+ width: number | null,
141
+ height: number | null,
142
+ ): [number, number, number, number] | null {
143
+ if (width === null || height === null) return null;
144
+ if (!(width > 0) || !(height > 0)) return null;
145
+ const side = Math.max(1, Math.round(Math.min(width, height) * POINT_SQUARE_FRACTION));
146
+ const x = Math.max(0, Math.min(width - side, Math.round(point.x - side / 2)));
147
+ const y = Math.max(0, Math.min(height - side, Math.round(point.y - side / 2)));
148
+ return [x, y, side, side];
47
149
  }
48
150
 
49
- function asStringArray(value: unknown): string[] {
50
- if (typeof value === "string") return [value];
51
- if (Array.isArray(value)) {
52
- return value.filter((entry): entry is string => typeof entry === "string");
151
+ /** One selector entry, plus one level of `refinedBy`. */
152
+ function readSelectorEntry(entry: unknown, out: ReadSelector, depth = 0): void {
153
+ const selector = asRecord(entry);
154
+ if (!selector) return;
155
+ const type = asString(selector.type);
156
+
157
+ if (type === "ImageApiSelector" || type === "FragmentSelector") {
158
+ const raw = asString(selector.value);
159
+ if (raw !== null && out.region === null) {
160
+ const match = /xywh=(pixel:)?([^,]+),([^,]+),([^,]+),([^,]+)/.exec(raw);
161
+ if (match) {
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.
166
+ if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) {
167
+ if (parts[2] > 0 && parts[3] > 0) {
168
+ out.region = parts as [number, number, number, number];
169
+ }
170
+ }
171
+ }
172
+ }
173
+ } else if (type === "PointSelector") {
174
+ const x = asNumber(selector.x);
175
+ const y = asNumber(selector.y);
176
+ if (x !== null && y !== null && out.point === null) {
177
+ out.point = { x, y };
178
+ }
179
+ } else if (type === "TextQuoteSelector") {
180
+ const exact = asString(selector.exact);
181
+ if (exact !== null && out.quote === null) {
182
+ const prefix = asString(selector.prefix);
183
+ const suffix = asString(selector.suffix);
184
+ out.quote = {
185
+ exact,
186
+ ...(prefix !== null ? { prefix } : {}),
187
+ ...(suffix !== null ? { suffix } : {}),
188
+ };
189
+ }
190
+ } else if (type === "SvgSelector") {
191
+ const value = asString(selector.value);
192
+ if (value !== null && out.svg === null) out.svg = value;
193
+ } else if (type === "TimeState" || type === "oa:TimeState") {
194
+ const state = asRecord(selector);
195
+ const start = asNumber(state?.start);
196
+ const end = asNumber(state?.end);
197
+ if ((start !== null || end !== null) && out.time === null) {
198
+ out.time = { ...(start !== null ? { start } : {}), ...(end !== null ? { end } : {}) };
199
+ }
200
+ }
201
+
202
+ if (depth < 1) {
203
+ const refined = Array.isArray(selector.refinedBy)
204
+ ? selector.refinedBy
205
+ : [selector.refinedBy];
206
+ for (const nested of refined) readSelectorEntry(nested, out, depth + 1);
53
207
  }
54
- return [];
208
+ }
209
+
210
+ /**
211
+ * Reads a Web Annotation target (a string fragment, a SpecificResource, or
212
+ * either carrying `selector`/`state` arrays) into a normalized
213
+ * {@link ReadSelector}. `width`/`height` are the canvas size in pixels, used
214
+ * only to synthesize a region from a point selector; pass null when unknown.
215
+ */
216
+ export function readSelector(
217
+ target: unknown,
218
+ width: number | null = null,
219
+ height: number | null = null,
220
+ ): ReadSelector {
221
+ const out: ReadSelector = { ...EMPTY_SELECTOR };
222
+
223
+ // A bare string target may carry the fragment: "…/canvas/2#xywh=10,20,30,40"
224
+ const asText = asString(target);
225
+ if (asText !== null) {
226
+ readSelectorEntry({ type: "FragmentSelector", value: asText }, out);
227
+ }
228
+
229
+ const record = asRecord(target);
230
+ if (record) {
231
+ const selectors = Array.isArray(record.selector) ? record.selector : [record.selector];
232
+ for (const entry of selectors) readSelectorEntry(entry, out);
233
+ const states = Array.isArray(record.state) ? record.state : [record.state];
234
+ for (const entry of states) readSelectorEntry(entry, out);
235
+ }
236
+
237
+ // A SpecificResource may carry the time range directly.
238
+ if (record && out.time === null) {
239
+ const start = asNumber(record.start);
240
+ const end = asNumber(record.end);
241
+ if (start !== null || end !== null) {
242
+ out.time = {
243
+ ...(start !== null ? { start } : {}),
244
+ ...(end !== null ? { end } : {}),
245
+ };
246
+ }
247
+ }
248
+
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) {
257
+ out.region = pointToRegion(out.point, width, height);
258
+ }
259
+
260
+ return isEmptySelector(out) ? { ...EMPTY_SELECTOR } : out;
55
261
  }
56
262
 
57
263
  /**
@@ -78,6 +284,46 @@ export function flattenLanguageMap(value: unknown): string {
78
284
  return parts.join(" ").trim();
79
285
  }
80
286
 
287
+ /**
288
+ * A `requiredStatement` reduced to its label and value.
289
+ *
290
+ * In Presentation 3 `requiredStatement` is a **single `{label, value}`
291
+ * object** — the official IIIF validator rejects the array form with "is not of
292
+ * type 'object'" — so the array branch below is producer leniency, not
293
+ * conformance. The `label` ("Credit", "Rights holder", a language-tagged term)
294
+ * is half of what a real institutional manifest ships, and reading only
295
+ * `.value` dropped it.
296
+ */
297
+ function readRequiredStatement(statement: unknown): { label: string; value: string } {
298
+ const entries = Array.isArray(statement)
299
+ ? statement
300
+ : statement !== undefined
301
+ ? [statement]
302
+ : [];
303
+ for (const entry of entries) {
304
+ const record = asRecord(entry);
305
+ if (!record) continue;
306
+ const value = flattenLanguageMap(record.value);
307
+ if (value === "") continue;
308
+ return { label: flattenLanguageMap(record.label), value };
309
+ }
310
+ return { label: "", value: "" };
311
+ }
312
+
313
+ /**
314
+ * Attribution text for a `requiredStatement`, keeping its label.
315
+ *
316
+ * A labelled statement reads as "Label: value", because that is how the value
317
+ * was meant to be attributed. An unlabelled one is returned verbatim, exactly
318
+ * as before — plenty of manifests state a bare rights line and prefixing it
319
+ * would be noise.
320
+ */
321
+ function formatAttribution(statement: { label: string; value: string }): string {
322
+ if (statement.value === "") return "";
323
+ if (statement.label === "") return statement.value;
324
+ return `${statement.label}: ${statement.value}`;
325
+ }
326
+
81
327
  /**
82
328
  * Reads a StoryMap extension term from a Canvas or service object. Manifests
83
329
  * use the `storymap:`-prefixed form, but JSON-LD processors may emit the bare
@@ -92,10 +338,20 @@ function readTerm(record: Record<string, unknown>, term: string): unknown {
92
338
  return term === "type" ? undefined : record[term];
93
339
  }
94
340
 
95
- /** True when `data` looks like a Presentation API 3.0 manifest. */
341
+ /**
342
+ * True when `data` looks like a Presentation API 3.0 **Manifest**.
343
+ *
344
+ * A `Collection` is explicitly *not* one, even though it carries the same
345
+ * `@context`. Detection used to accept anything with the P3 context, so a
346
+ * Collection passed and its member Manifests were fed to `canvasToSlide` as if
347
+ * they were Canvases — every member became a text-only slide, with no media, no
348
+ * locations and no warning. `within`-style Collections are now handled by the
349
+ * private `collectionToStorymapData` below, and anything else is rejected here.
350
+ */
96
351
  export function isPresentation3Manifest(data: unknown): boolean {
97
352
  const record = asRecord(data);
98
353
  if (!record) return false;
354
+ if (record.type === "Collection") return false;
99
355
  if (record.type === "Manifest") return true;
100
356
  const context = record["@context"];
101
357
  const candidates = Array.isArray(context) ? context : [context];
@@ -104,10 +360,159 @@ export function isPresentation3Manifest(data: unknown): boolean {
104
360
  );
105
361
  }
106
362
 
363
+ /** True when `data` is a Presentation API 3.0 Collection. */
364
+ export function isPresentation3Collection(data: unknown): boolean {
365
+ const record = asRecord(data);
366
+ return record !== null && record.type === "Collection";
367
+ }
368
+
369
+ /**
370
+ * A Collection: reported and rejected, with nothing silently mangled.
371
+ *
372
+ * §2.2 offered two branches — flatten the member Manifests, or reject with an
373
+ * explicit error. Neither survives contact with the specification, and the
374
+ * choice is worth recording:
375
+ *
376
+ * - **Flattening is impossible here.** A Presentation 3 `Collection`'s
377
+ * `items` are `id` **references** to manifests in other documents, so
378
+ * flattening means fetching them, and this converter is synchronous and does
379
+ * no I/O.
380
+ * - **A Collection fixture could not be authored either.** The official IIIF
381
+ * validator rejects both shapes of member the obvious forms suggest: an
382
+ * embedded `Manifest` object ("not valid under any of the given schemas") and
383
+ * a bare id string (the same). So no `public/examples-iiif/` fixture can
384
+ * exercise this path, and shipping one that fails `npm run validate:iiif` is
385
+ * not an option.
386
+ *
387
+ * So the honest outcome is the one that removes a defect and promises no
388
+ * feature: previously a Collection was *accepted* and its members were fed to
389
+ * `canvasToSlide` as if they were Canvases, producing text-only slides with no
390
+ * media, no locations and no warning. Now it is named and skipped.
391
+ *
392
+ * A host that wants a multi-manifest tour composes it: fetch the members, call
393
+ * {@link manifestToStorymapData} on each, and concatenate the `slides`. That
394
+ * keeps the "no I/O on the load path" property §5.2's `seeAlso` reader is being
395
+ * held back for.
396
+ */
397
+ function collectionToStorymapData(collection: Record<string, unknown>): StorymapData {
398
+ const data: StorymapData = { slides: [] };
399
+ const title = flattenLanguageMap(collection.label);
400
+ if (title !== "") data.title = title;
401
+
402
+ const required = formatAttribution(readRequiredStatement(collection.requiredStatement));
403
+ if (required !== "") {
404
+ const iiif = (data.iiif as { url?: string; attribution?: string } | undefined) ?? {};
405
+ iiif.attribution = required;
406
+ if (iiif.url === undefined) iiif.url = "";
407
+ data.iiif = iiif;
408
+ }
409
+
410
+ const members = Array.isArray(collection.items) ? collection.items : [];
411
+ const memberIds = members
412
+ .map((member) => (typeof member === "string" ? member : asString(asRecord(member)?.id)))
413
+ .filter((id): id is string => typeof id === "string");
414
+ console.warn(
415
+ "StoryMapJS: this is a IIIF Collection with " +
416
+ `${memberIds.length} member manifest(s) [${memberIds.join(", ")}]. ` +
417
+ "A Presentation 3 Collection references its members from other documents, and " +
418
+ "the converter is synchronous, so it contributes no slides rather than " +
419
+ "mangling them. To build a multi-manifest tour, fetch the members and " +
420
+ "concatenate their manifestToStorymapData() slides yourself.",
421
+ );
422
+
423
+ return data;
424
+ }
425
+
107
426
  /**
108
427
  * Finds the manifest-level map configuration service (profile containing
109
428
  * "mapconfig") and returns its properties, or null when absent.
110
429
  */
430
+ /**
431
+ * The `info.json` URL of the image basemap, taken from the `ImageService3` a
432
+ * painting body carries (§2.4).
433
+ *
434
+ * `service[0].id` is the service base, so the description is that plus
435
+ * `/info.json`. A producer that put the description URL in `body.id` instead
436
+ * is handled by the fallback. `map_type: "iiif"` means every canvas paints the
437
+ * same basemap image, so the first one carrying a service is the basemap.
438
+ */
439
+ function readImageServiceUrl(manifest: Record<string, unknown>): string | null {
440
+ const canvases = Array.isArray(manifest.items) ? manifest.items : [];
441
+ for (const canvas of canvases) {
442
+ const record = asRecord(canvas);
443
+ if (!record) continue;
444
+ const pages = Array.isArray(record.items) ? record.items : [];
445
+ for (const page of pages) {
446
+ const pageRecord = asRecord(page);
447
+ if (!pageRecord) continue;
448
+ const annotations = Array.isArray(pageRecord.items) ? pageRecord.items : [];
449
+ for (const annotation of annotations) {
450
+ const annotationRecord = asRecord(annotation);
451
+ if (!annotationRecord) continue;
452
+ const bodies = Array.isArray(annotationRecord.body)
453
+ ? annotationRecord.body
454
+ : [annotationRecord.body];
455
+ for (const entry of bodies) {
456
+ const body = asRecord(entry);
457
+ if (!body) continue;
458
+ const services = Array.isArray(body.service) ? body.service : [];
459
+ for (const service of services) {
460
+ const serviceRecord = asRecord(service);
461
+ if (!serviceRecord) continue;
462
+ const id = asString(serviceRecord.id);
463
+ if (id === null) continue;
464
+ return id.endsWith("/info.json") ? id : `${id}/info.json`;
465
+ }
466
+ // A body that is itself the service description
467
+ const bodyId = asString(body.id);
468
+ if (bodyId !== null && bodyId.endsWith("/info.json")) return bodyId;
469
+ }
470
+ }
471
+ }
472
+ }
473
+ return null;
474
+ }
475
+
476
+ /**
477
+ * TileJSON 2.1 metadata from a manifest's map configuration service (§2.9).
478
+ *
479
+ * `tiles` is the only required member, and the value we need is the template,
480
+ * so anything without one is not a tile source. `minzoom`/`maxzoom` are
481
+ * clamped to sane numbers rather than rejected, because a service that
482
+ * advertises a maxzoom of 200 should not fail to load — the map caps it.
483
+ */
484
+ function readTilejson(config: Record<string, unknown>): StorymapTilejson | null {
485
+ const record = asRecord(config.tilejson);
486
+ if (!record) return null;
487
+ const tiles = Array.isArray(record.tiles)
488
+ ? asStringArray(record.tiles).filter((t) => t !== "")
489
+ : asString(record.tiles);
490
+ if (Array.isArray(tiles) ? tiles.length === 0 : tiles === null) return null;
491
+ const template = tiles as string | string[];
492
+
493
+ const out: StorymapTilejson = { tiles: template };
494
+ const minzoom = asNumber(record.minzoom);
495
+ if (minzoom !== null && minzoom >= 0) out.minzoom = minzoom;
496
+ const maxzoom = asNumber(record.maxzoom);
497
+ if (maxzoom !== null && maxzoom >= 0) out.maxzoom = maxzoom;
498
+ const bounds = readLonLatBox(record.bounds);
499
+ if (bounds !== null) out.bounds = bounds;
500
+ const scheme = asString(record.scheme);
501
+ if (scheme === "xyz" || scheme === "tms") out.scheme = scheme;
502
+ // TileJSON's `center` is [lon, lat, zoom]; the third member is the zoom
503
+ const center = Array.isArray(record.center) ? record.center : null;
504
+ if (center && center.length >= 2) {
505
+ const lon = asNumber(center[0]);
506
+ const lat = asNumber(center[1]);
507
+ if (lon !== null && lat !== null && Math.abs(lon) <= 180 && Math.abs(lat) <= 90) {
508
+ const zoom = center.length > 2 ? asNumber(center[2]) : null;
509
+ if (zoom !== null) out.center = [lon, lat, zoom];
510
+ else out.center = [lon, lat, 0];
511
+ }
512
+ }
513
+ return out;
514
+ }
515
+
111
516
  function readMapConfig(manifest: Record<string, unknown>): Record<string, unknown> | null {
112
517
  const services = Array.isArray(manifest.service) ? manifest.service : [manifest.service];
113
518
  for (const service of services) {
@@ -122,11 +527,148 @@ function readMapConfig(manifest: Record<string, unknown>): Record<string, unknow
122
527
  }
123
528
 
124
529
  /**
125
- * Reads the slide media URL from the canvas's painting annotation body:
530
+ * What a painting annotation tells us about a slide's media.
531
+ *
532
+ * This is the **shared body record** for annotation-driven stops:
533
+ * `type` is how a `Sound` body is told
534
+ * from an `Image` or a `TextualBody`. Anything else that needs a field from
535
+ * the body belongs on this record rather than in a second reader.
536
+ */
537
+ export interface PaintingBody {
538
+ url: string;
539
+ region: [number, number, number, number] | null;
540
+ /** Body class: `Image`, `Sound`, `Video`, `Text`, `Dataset`, … */
541
+ type: string | null;
542
+ format: string | null;
543
+ /** `body.label` — the interoperable caption. */
544
+ label: string | null;
545
+ /** `body.accessibilitySummary` — the interoperable alt text. */
546
+ accessibilitySummary: string | null;
547
+ /**
548
+ * `body.requiredStatement` (label and value), or `body.provider`.
549
+ * See {@link readRequiredStatement} on the single-object shape.
550
+ */
551
+ credit: string | null;
552
+ thumbnail: string | null;
553
+ /** `duration` in seconds, and an explicit `start`/`end` range. */
554
+ duration: number | null;
555
+ start: number | null;
556
+ end: number | null;
557
+ /**
558
+ * A WebVTT subtitle file, from a `TextualBody` body with
559
+ * `format: "text/vtt"` and an `id`. IIIF has no subtitle term, so this is
560
+ * the one standard shape we opportunistically accept; `media.subtitles`
561
+ * in storymap JSON is the documented route.
562
+ */
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
+ }
586
+
587
+ /**
588
+ * Credit from `body.requiredStatement`, or `body.provider`, which is where a
589
+ * manifest records who made the media. The statement's `label` is kept, like
590
+ * the manifest-level attribution (§2.1) — a body that says
591
+ * `{label: "Photographer", value: "Someone"}` should not reduce to a bare
592
+ * "Someone".
593
+ */
594
+ function readBodyCredit(body: unknown): string | null {
595
+ const record = asRecord(body);
596
+ if (!record) return null;
597
+ const statement = formatAttribution(readRequiredStatement(record.requiredStatement));
598
+ if (statement !== "") return statement;
599
+ const provider = asRecord(record.provider);
600
+ const providerLabel = provider === null ? "" : flattenLanguageMap(provider.label);
601
+ if (providerLabel !== "") return providerLabel;
602
+ return null;
603
+ }
604
+
605
+ /**
606
+ * Credit from the painting annotation's own `requiredStatement`, which is
607
+ * where P3 defines it and where our converter now writes it (§2.7). The
608
+ * `label` is kept, as everywhere else.
609
+ */
610
+ function readAnnotationCredit(annotation: Record<string, unknown>): string | null {
611
+ if (annotation.requiredStatement === undefined) return null;
612
+ const statement = formatAttribution(readRequiredStatement(annotation.requiredStatement));
613
+ return statement === "" ? null : statement;
614
+ }
615
+
616
+ /** A `TextualBody` carrying WebVTT is the closest standard spelling of a
617
+ * subtitle track, so accept it alongside `media.subtitles`. */
618
+ function readBodySubtitles(body: unknown): string | null {
619
+ const entries = Array.isArray(body) ? body : [body];
620
+ for (const entry of entries) {
621
+ const record = asRecord(entry);
622
+ if (!record) continue;
623
+ const format = asString(record.format);
624
+ const id = asString(record.id);
625
+ if (format !== null && /^text\/vtt$/i.test(format) && id !== null) return id;
626
+ }
627
+ return null;
628
+ }
629
+
630
+ /** Picks the first body of a `body` array that yields a URL. */
631
+ function readFirstTypedBody(body: unknown): Record<string, unknown> | null {
632
+ if (Array.isArray(body)) {
633
+ for (const entry of body) {
634
+ const found = readFirstTypedBody(entry);
635
+ if (found !== null) return found;
636
+ }
637
+ return null;
638
+ }
639
+ const record = asRecord(body);
640
+ if (!record) return null;
641
+ const url = asString(record.id) ?? asString(record.value);
642
+ return url === null ? null : record;
643
+ }
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
+
656
+ /**
657
+ * Reads the slide media URLs from the canvas's painting annotation bodies:
126
658
  * typed bodies contribute their `id`, TextualBody (HTML) content its `value`
127
- * - the legacy format stores both in `media.url`.
659
+ * - the legacy format stores both in `media.url`. Also returns the region of
660
+ * a selector on the annotation target, and the body fields listed on
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`.
128
665
  */
129
- function readPaintingBodyUrl(canvas: Record<string, unknown>): string | null {
666
+ function readPaintings(
667
+ canvas: Record<string, unknown>,
668
+ width: number | null = null,
669
+ height: number | null = null,
670
+ ): PaintingBody[] {
671
+ const found: PaintingBody[] = [];
130
672
  const annotationPages = Array.isArray(canvas.items) ? canvas.items : [];
131
673
  for (const page of annotationPages) {
132
674
  const pageRecord = asRecord(page);
@@ -135,29 +677,94 @@ function readPaintingBodyUrl(canvas: Record<string, unknown>): string | null {
135
677
  for (const annotation of annotations) {
136
678
  const annotationRecord = asRecord(annotation);
137
679
  if (!annotationRecord) continue;
138
- const motivation = asString(annotationRecord.motivation);
139
- if (motivation !== null && motivation !== "painting") continue;
140
- const url = readBodyUrl(annotationRecord.body);
141
- if (url !== null) return url;
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
+ }
142
718
  }
143
719
  }
144
- return null;
720
+ return found;
145
721
  }
146
722
 
147
- function readBodyUrl(body: unknown): string | null {
148
- if (Array.isArray(body)) {
149
- for (const entry of body) {
150
- const url = readBodyUrl(entry);
151
- if (url !== null) return url;
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 {
740
+ const annotationPages = Array.isArray(canvas.items) ? canvas.items : [];
741
+ for (const page of annotationPages) {
742
+ const pageRecord = asRecord(page);
743
+ if (!pageRecord) continue;
744
+ const annotations = Array.isArray(pageRecord.items) ? pageRecord.items : [];
745
+ for (const annotation of annotations) {
746
+ const annotationRecord = asRecord(annotation);
747
+ if (!annotationRecord) continue;
748
+ const rawMotivation = annotationRecord.motivation;
749
+ const motivations = Array.isArray(rawMotivation) ? rawMotivation : [rawMotivation];
750
+ if (!motivations.some((m) => asString(m) === "supplementing")) continue;
751
+ const body = readFirstTypedBody(annotationRecord.body);
752
+ if (body === null) continue;
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;
152
766
  }
153
- return null;
154
767
  }
155
- const record = asRecord(body);
156
- if (!record) return null;
157
- const id = asString(record.id);
158
- if (id !== null) return id;
159
- const value = asString(record.value);
160
- if (value !== null) return value;
161
768
  return null;
162
769
  }
163
770
 
@@ -195,73 +802,857 @@ function readFeatureLocation(feature: unknown): StorymapSlideLocation | null {
195
802
  if (value === undefined || value === null || value === "") continue;
196
803
  locationProps[key] = value;
197
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
+ }
198
827
  }
199
828
  return location;
200
829
  }
201
830
 
202
- function readBackground(value: unknown): StorymapSlideBackground | string | null {
203
- const record = asRecord(value);
204
- if (record) {
205
- const background: StorymapSlideBackground = {};
206
- const url = asString(record.url);
207
- const color = asString(record.color);
208
- if (url !== null) background.url = url;
209
- if (color !== null) background.color = color;
210
- // slide background opacity is accepted but never read by the viewer
211
- return Object.keys(background).length > 0 ? background : null;
831
+ /**
832
+ * Reads a lon/lat bounding box from a navPlace FeatureCollection holding a
833
+ * Polygon or MultiPolygon. The navPlace extension lists "supplying a single
834
+ * geographic bounding box" as a use case, which is the interoperable way to
835
+ * state the extent of a story — the legacy format has no such field, so it
836
+ * becomes `map_bbox` (the map is constrained to the box). Returns null when
837
+ * the collection has no polygon.
838
+ */
839
+ function readNavPlaceBbox(navPlace: unknown): [number, number, number, number] | null {
840
+ const collection = asRecord(navPlace);
841
+ if (!collection) return null;
842
+ const features = Array.isArray(collection.features) ? collection.features : [];
843
+ for (const feature of features) {
844
+ const featureRecord = asRecord(feature);
845
+ if (!featureRecord) continue;
846
+ const geometry = asRecord(featureRecord.geometry);
847
+ const type = geometry ? asString(geometry.type) : null;
848
+ if (type !== "Polygon" && type !== "MultiPolygon") continue;
849
+ const positions: number[][] = [];
850
+ collectPositions(geometry?.coordinates, positions, 0);
851
+ const lons = positions.map((p) => p[0]).filter((n) => n !== undefined);
852
+ const lats = positions.map((p) => p[1]).filter((n) => n !== undefined);
853
+ if (lons.length === 0 || lats.length === 0) continue;
854
+ return [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];
855
+ }
856
+ return null;
857
+ }
858
+
859
+ /** Flattens arbitrarily nested GeoJSON coordinate arrays into positions. */
860
+ function collectPositions(value: unknown, out: number[][], depth: number): void {
861
+ if (depth > 4 || !Array.isArray(value)) return;
862
+ if (value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") {
863
+ const lon = asNumber(value[0]);
864
+ const lat = asNumber(value[1]);
865
+ if (lon !== null && lat !== null) out.push([lon, lat]);
866
+ return;
867
+ }
868
+ for (const entry of value) {
869
+ collectPositions(entry, out, depth + 1);
870
+ }
871
+ }
872
+
873
+ /**
874
+ * The first id of a P3 `thumbnail`, which is a *list* of content resources
875
+ * (§3.1). A bare resource is accepted too, since that is what a one-element
876
+ * list collapses to once it has been through a few tools.
877
+ */
878
+ /**
879
+ * A manifest's `structures` as a slide order and a group per canvas (§3.5).
880
+ *
881
+ * Two jobs, both of which the ecosystem expects — Annona's range storyboard
882
+ * and TimelineJS's groups:
883
+ *
884
+ * 1. a Range with a `label` and **no `start`** is a *group* for its member
885
+ * canvases, which is the first meaning the inert `slide.group` field ever
886
+ * had. A Range with a `start` is a time segment of one canvas, not a group,
887
+ * so it is skipped.
888
+ * 2. the order canvases appear in, which is the curated sequence. That is the
889
+ * whole point of a storyboard: it may run the canvases in an order the
890
+ * document does not.
891
+ *
892
+ * Nested Ranges are chapters inside the group, so the outermost labelled Range
893
+ * is the group a slide belongs to — `slide.group` is one string, and a
894
+ * "Part 1 / Chapter 2" pair is more use to a host as the part.
895
+ */
896
+ function readStructures(value: unknown): {
897
+ order: string[];
898
+ groups: Map<string, string>;
899
+ } {
900
+ const order: string[] = [];
901
+ const groups = new Map<string, string>();
902
+ const seen = new Set<string>();
903
+
904
+ const walk = (entries: unknown, group: string | null): void => {
905
+ for (const entry of Array.isArray(entries) ? entries : [entries]) {
906
+ // P3 lets a Range's `items` be a list of ids, of resource objects,
907
+ // or a mix; only the ids matter here
908
+ const bare = asString(entry);
909
+ if (bare !== null) {
910
+ if (!seen.has(bare)) {
911
+ seen.add(bare);
912
+ order.push(bare);
913
+ if (group !== null) groups.set(bare, group);
914
+ }
915
+ continue;
916
+ }
917
+ const record = asRecord(entry);
918
+ if (!record) continue;
919
+ const isRange = asString(record.type) === "Range";
920
+ if (!isRange) {
921
+ // a canvas or annotation resource reference in items
922
+ const id = asString(record.id);
923
+ if (id !== null && !seen.has(id)) {
924
+ seen.add(id);
925
+ order.push(id);
926
+ if (group !== null) groups.set(id, group);
927
+ }
928
+ continue;
929
+ }
930
+ const label = flattenLanguageMap(record.label);
931
+ const isGroup = label !== "" && record.start === undefined;
932
+ const nextGroup = isGroup ? (group ?? label) : group;
933
+ walk(record.items, nextGroup);
934
+ }
935
+ };
936
+
937
+ walk(value, null);
938
+ return { order, groups };
939
+ }
940
+
941
+ /**
942
+ * An Agent as a credit fragment: its label, or its id when it has none. An
943
+ * empty `label` produces a bare fragment, which is what a homepage-only Agent
944
+ * wants (§3.2).
945
+ */
946
+ function agentCredit(value: unknown, label: string): string {
947
+ const entries = Array.isArray(value) ? value : [value];
948
+ const parts: string[] = [];
949
+ for (const entry of entries) {
950
+ const record = asRecord(entry);
951
+ if (!record) continue;
952
+ const name = flattenLanguageMap(record.label) || asString(record.id) || "";
953
+ if (name === "") continue;
954
+ parts.push(label === "" ? name : `${label}: ${name}`);
212
955
  }
213
- if (typeof value === "string" && value !== "") {
214
- return value;
956
+ return parts.join(", ");
957
+ }
958
+
959
+ /**
960
+ * P3 `metadata` as plain `{label, value}` pairs (§3.2). Entries without both a
961
+ * label and a value are dropped, since a pair with neither says nothing.
962
+ */
963
+ function readMetadata(value: unknown): { label: string; value: string }[] {
964
+ const entries = Array.isArray(value) ? value : [value];
965
+ const out: { label: string; value: string }[] = [];
966
+ for (const entry of entries) {
967
+ const record = asRecord(entry);
968
+ if (!record) continue;
969
+ const label = flattenLanguageMap(record.label);
970
+ const text = flattenLanguageMap(record.value);
971
+ if (label === "" && text === "") continue;
972
+ out.push({ label, value: text });
973
+ }
974
+ return out;
975
+ }
976
+
977
+ function readThumbnailId(value: unknown): string | null {
978
+ const entries = Array.isArray(value) ? value : [value];
979
+ for (const entry of entries) {
980
+ const id = asString(asRecord(entry)?.id);
981
+ if (id !== null) return id;
215
982
  }
216
983
  return null;
217
984
  }
218
985
 
219
- function canvasToSlide(canvas: unknown, manifestFeature: unknown): StorymapSlide | null {
986
+ /**
987
+ * A canvas's P3 `background` annotation into a slide background (§2.6).
988
+ *
989
+ * The annotation is a painting annotation whose body is the image and/or the
990
+ * colour; an image body contributes `url` and a `Color` body contributes
991
+ * `color`.
992
+ *
993
+ * The result is always the object form. The bare-string form of
994
+ * `slide.background` means a *colour* — that is what the converter has always
995
+ * done with it — so returning a url as a bare string would read back as a
996
+ * colour on the next trip.
997
+ *
998
+ * `opacity` is not read. IIIF has no vocabulary for a background opacity and
999
+ * the viewer never rendered one, so nothing is lost by leaving it out.
1000
+ */
1001
+ function readBackground(annotation: unknown): StorymapSlideBackground | string | null {
1002
+ const record = asRecord(annotation);
1003
+ if (!record) return null;
1004
+ const bodies = Array.isArray(record.body) ? record.body : [record.body];
1005
+ let url: string | null = null;
1006
+ let color: string | null = null;
1007
+ for (const entry of bodies) {
1008
+ const body = asRecord(entry);
1009
+ if (!body) continue;
1010
+ if (url === null && isMediaBody(body)) {
1011
+ url = asString(body.id);
1012
+ }
1013
+ if (color === null) {
1014
+ const type = asString(body.type);
1015
+ const value = asString(body.value);
1016
+ if (value !== null && (type === "Color" || type === null)) color = value;
1017
+ }
1018
+ }
1019
+ const background: StorymapSlideBackground = {};
1020
+ if (url !== null) background.url = url;
1021
+ if (color !== null) background.color = color;
1022
+ return Object.keys(background).length > 0 ? background : null;
1023
+ }
1024
+
1025
+ function canvasToSlide(
1026
+ canvas: unknown,
1027
+ manifestFeature: unknown,
1028
+ preferredLanguage: string | null = null,
1029
+ ): StorymapSlide | null {
220
1030
  const record = asRecord(canvas);
221
1031
  if (!record) return null;
222
1032
 
223
1033
  const slide: StorymapSlide = {};
224
1034
 
225
- // text: Canvas label → headline, Canvas summary → body text
226
- const headline = flattenLanguageMap(record.label);
227
- const text = flattenLanguageMap(record.summary);
1035
+ // text: Canvas label → headline, Canvas summary → body text, both in the
1036
+ // language the viewer is configured for (§3.4)
1037
+ const label = pickLanguageMap(record.label, preferredLanguage);
1038
+ const summary = pickLanguageMap(record.summary, preferredLanguage);
1039
+ const headline = label.value;
1040
+ const text = summary.value;
228
1041
  if (headline !== "" || text !== "") {
229
1042
  slide.text = {};
230
1043
  if (headline !== "") slide.text.headline = headline;
231
1044
  if (text !== "") slide.text.text = text;
232
1045
  }
1046
+ // Which language this slide's text came from, so a host can offer a
1047
+ // language switch. `none` is language-neutral, so it is not reported.
1048
+ const language = label.language ?? summary.language;
1049
+ if (language !== null) slide.language = language;
233
1050
 
234
- // media: painting annotation body plus the caption/credit extension terms
235
- const mediaUrl = readPaintingBodyUrl(record);
236
- const caption = asString(readTerm(record, "mediaCaption"));
237
- const credit = asString(readTerm(record, "mediaCredit"));
238
- if (mediaUrl !== null || caption !== null || credit !== null) {
1051
+ // media: the painting annotation's body, with its label /
1052
+ // requiredStatement / accessibilitySummary as caption, credit and alt
1053
+ // text (§2.7 — the `mediaCaption`/`mediaCredit`/`mediaAlt` canvas terms are
1054
+ // gone). srcset/sizes remain terms: IIIF has no vocabulary for either.
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);
1060
+ const caption = painting?.label ?? null;
1061
+ const credit = painting?.credit ?? null;
1062
+ const alt = painting?.accessibilitySummary ?? null;
1063
+ const srcset = asString(readTerm(record, "mediaSrcset"));
1064
+ const sizes = asString(readTerm(record, "mediaSizes"));
1065
+ // P3 allows the thumbnail on the body or on the canvas, and real manifests
1066
+ // use both; the body's wins because it describes the painted resource
1067
+ // specifically
1068
+ const thumbnail = painting?.thumbnail ?? readThumbnailId(record.thumbnail);
1069
+ if (
1070
+ painting !== null ||
1071
+ caption !== null ||
1072
+ credit !== null ||
1073
+ alt !== null ||
1074
+ srcset !== null ||
1075
+ sizes !== null ||
1076
+ thumbnail !== null
1077
+ ) {
239
1078
  const media: StorymapSlideMedia = {};
240
- if (mediaUrl !== null) media.url = mediaUrl;
1079
+ if (painting !== null) media.url = painting.url;
241
1080
  if (caption !== null) media.caption = caption;
242
1081
  if (credit !== null) media.credit = credit;
1082
+ if (alt !== null) media.alt = alt;
1083
+ if (srcset !== null) media.srcset = srcset;
1084
+ if (sizes !== null) media.sizes = sizes;
1085
+ if (thumbnail !== null) media.thumb = thumbnail;
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;
243
1091
  slide.media = media;
244
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
+ }
245
1116
 
246
1117
  // location: canvas navPlace, falling back to a manifest-level navPlace
247
1118
  // aggregated in items order (both are allowed by the proposal)
248
1119
  const location = readLocation(record.navPlace) ?? readFeatureLocation(manifestFeature);
249
1120
  if (location !== null) slide.location = location;
1121
+ // IIIF xywh region (slideshow-style image stops): [x, y, w, h] pixels.
1122
+ // The extension term wins over the interoperable spelling — an Image API
1123
+ // Selector on the painting annotation target
1124
+ // region: the painting annotation's target selector, the only source since
1125
+ // the storymap:imageRegion term went (§2.8)
1126
+ const region = painting?.region ?? null;
1127
+ if (region !== null) {
1128
+ slide.location = { ...(slide.location ?? {}), region };
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
+ }
250
1196
 
251
1197
  // StoryMap extension terms
252
1198
  const slideType = asString(record[STORYMAP_PREFIX + "type"]);
253
1199
  if (slideType !== null) slide.type = slideType;
254
- const date = readTerm(record, "date");
255
- const dateString = asString(date);
256
- const dateRecord = asRecord(date);
257
- if (dateString !== null) slide.date = dateString;
1200
+ // P3's `navDate`, a string. A language map is read for leniency, since
1201
+ // other producers do emit one, but the official validator rejects it and
1202
+ // we never write one (§2.5).
1203
+ const navDate = record.navDate;
1204
+ const dateString = asString(navDate) ?? flattenLanguageMap(navDate);
1205
+ const dateRecord = asRecord(navDate);
1206
+ if (dateString !== null && dateString !== "") slide.date = dateString;
258
1207
  else if (dateRecord !== null) slide.date = dateRecord;
259
- const background = readBackground(readTerm(record, "background"));
1208
+ // background: the canvas's standard `background` painting annotation. The
1209
+ // storymap:background term is gone (§2.6).
1210
+ const background = readBackground(record.background);
260
1211
  if (background !== null) slide.background = background;
261
1212
 
262
1213
  return slide;
263
1214
  }
264
1215
 
1216
+ /**
1217
+ * Motivations that turn an annotation into a tour stop. `painting` is the
1218
+ * canvas's image and is handled separately; everything here is commentary
1219
+ * about it, which is what a guided tour is made of.
1220
+ */
1221
+ const STOP_MOTIVATIONS = new Set(["commenting", "tagging", "classifying", "describing"]);
1222
+
1223
+ /**
1224
+ * A `text/plain` body becomes one paragraph per blank-line-separated block,
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
1228
+ * pipeline (`sanitizeSlideText`, via `media/types/Text.ts`) sanitizes whatever
1229
+ * it is given, which is the same path a storymap JSON `text.text` takes.
1230
+ */
1231
+ function annotationTextToSlideText(format: string | null, value: string): string | null {
1232
+ if (value.trim() === "") return null;
1233
+ const is_html = format !== null && /^text\/html/i.test(format);
1234
+ if (is_html) return value;
1235
+ const blocks = value
1236
+ .split(/\n\s*\n/)
1237
+ .map((block) => block.trim())
1238
+ .filter((block) => block !== "");
1239
+ if (blocks.length === 0) return null;
1240
+ return blocks.map((block) => `<p>${escapeText(block)}</p>`).join("");
1241
+ }
1242
+
1243
+ /** True for a body that is a media resource rather than text. */
1244
+ function isMediaBody(body: Record<string, unknown>): boolean {
1245
+ const id = asString(body.id);
1246
+ if (id === null) return false;
1247
+ const type = asString(body.type);
1248
+ if (type !== null) {
1249
+ return ["Image", "Sound", "Video", "Dataset", "Model"].includes(type);
1250
+ }
1251
+ // An untyped body with an id is a media resource by P3 convention.
1252
+ return true;
1253
+ }
1254
+
1255
+ /**
1256
+ * One annotation → one tour stop, or null when it is not a stop.
1257
+ *
1258
+ * A stop needs somewhere to go: the target has to resolve to a region, either
1259
+ * from an `xywh` fragment / Image API Selector or from a `PointSelector`
1260
+ * (synthesized into a square by `readSelector`, which needs the canvas size).
1261
+ * An annotation targeting the whole canvas is not a stop — the canvas slide
1262
+ * already covers it.
1263
+ *
1264
+ * The body may be one resource or an array. A `TextualBody` contributes the
1265
+ * slide text, a typed media body (typically a `Sound`) contributes
1266
+ * `media.url`, and both contribute caption/credit/alt.
1267
+ */
1268
+ function readAnnotationStop(
1269
+ annotation: unknown,
1270
+ width: number | null,
1271
+ height: number | null,
1272
+ ): StorymapSlide | null {
1273
+ const record = asRecord(annotation);
1274
+ if (!record) return null;
1275
+
1276
+ const motivations = Array.isArray(record.motivation) ? record.motivation : [record.motivation];
1277
+ const is_stop = motivations.some((m) => {
1278
+ const value = asString(m);
1279
+ return value !== null && STOP_MOTIVATIONS.has(value);
1280
+ });
1281
+ if (!is_stop) return null;
1282
+
1283
+ const selector = readSelector(record.target, width, height);
1284
+ if (selector.region === null) return null;
1285
+
1286
+ const bodies = Array.isArray(record.body) ? record.body : [record.body];
1287
+ let text: string | null = null;
1288
+ let media: StorymapSlideMedia | null = null;
1289
+
1290
+ for (const entry of bodies) {
1291
+ const body = asRecord(entry);
1292
+ if (!body) continue;
1293
+ if (isMediaBody(body)) {
1294
+ if (media === null) {
1295
+ const url = asString(body.id);
1296
+ if (url !== null) {
1297
+ // Same precedence as readPainting: the annotation's own
1298
+ // P3 properties first, the body's as a fallback (§2.7)
1299
+ const caption =
1300
+ flattenLanguageMap(record.label) || flattenLanguageMap(body.label);
1301
+ const credit = readAnnotationCredit(record) ?? readBodyCredit(body);
1302
+ const alt =
1303
+ flattenLanguageMap(record.accessibilitySummary) ||
1304
+ flattenLanguageMap(body.accessibilitySummary);
1305
+ media = {
1306
+ url,
1307
+ ...(caption !== "" ? { caption } : {}),
1308
+ ...(credit !== null ? { credit } : {}),
1309
+ ...(alt !== "" ? { alt } : {}),
1310
+ ...(readThumbnailId(body.thumbnail) !== null
1311
+ ? { thumb: readThumbnailId(body.thumbnail) as string }
1312
+ : {}),
1313
+ };
1314
+ }
1315
+ }
1316
+ continue;
1317
+ }
1318
+ if (text === null) {
1319
+ const value = asString(body.value);
1320
+ if (value !== null) {
1321
+ text = annotationTextToSlideText(asString(body.format), value);
1322
+ }
1323
+ }
1324
+ }
1325
+
1326
+ // An annotation carrying nothing but a target is still a usable stop: it
1327
+ // focuses the image on a region.
1328
+ if (text === null && media === null) return null;
1329
+
1330
+ // The annotation's own label is the stop's headline, which is what the
1331
+ // slider and the marker label show. A body `label` is the media caption and
1332
+ // is handled above.
1333
+ const headline = flattenLanguageMap(record.label);
1334
+
1335
+ const slide: StorymapSlide = { location: { region: selector.region } };
1336
+ if (text !== null || headline !== "") {
1337
+ slide.text = {
1338
+ ...(text !== null ? { text } : {}),
1339
+ ...(headline !== "" ? { headline } : {}),
1340
+ };
1341
+ }
1342
+ if (media !== null) slide.media = media;
1343
+ return slide;
1344
+ }
1345
+
1346
+ /**
1347
+ * Annotation-driven tour stops for one canvas, in annotation page order.
1348
+ * Appended after the canvas's own slide, so the story reads "here is the
1349
+ * whole picture, then here is each detail".
1350
+ */
1351
+ export function readCommentingAnnotations(canvas: unknown): StorymapSlide[] {
1352
+ const record = asRecord(canvas);
1353
+ if (!record) return [];
1354
+ const width = asNumber(record.width);
1355
+ const height = asNumber(record.height);
1356
+
1357
+ const stops: StorymapSlide[] = [];
1358
+ const annotationPages = Array.isArray(record.items) ? record.items : [];
1359
+ for (const page of annotationPages) {
1360
+ const annotations = asRecord(page);
1361
+ if (!annotations || !Array.isArray(annotations.items)) continue;
1362
+ for (const annotation of annotations.items) {
1363
+ const stop = readAnnotationStop(annotation, width, height);
1364
+ if (stop !== null) stops.push(stop);
1365
+ }
1366
+ }
1367
+ return stops;
1368
+ }
1369
+
1370
+ /** An external resource a manifest points at with `seeAlso`. */
1371
+ export type SeeAlsoReference = {
1372
+ id: string;
1373
+ /** The IIIF class, which is what tells us how to read it. */
1374
+ type: string;
1375
+ };
1376
+
1377
+ /**
1378
+ * The `seeAlso` targets of a manifest, canvas or range.
1379
+ *
1380
+ * The class is recorded because it is what decides how a target is read: an
1381
+ * `AnnotationCollection` or `AnnotationPage` is a list of annotations to merge,
1382
+ * while a `SearchService1` is an endpoint to hand to a host rather than
1383
+ * annotations to load. An untyped entry is reported as `unknown` rather than
1384
+ * guessed at, because fetching a URI on the strength of a guess is how a viewer
1385
+ * ends up dereferencing something it should not have.
1386
+ */
1387
+ export function collectSeeAlso(value: unknown): SeeAlsoReference[] {
1388
+ const record = asRecord(value);
1389
+ if (!record) return [];
1390
+ const entries = Array.isArray(record.seeAlso) ? record.seeAlso : [record.seeAlso];
1391
+ const out: SeeAlsoReference[] = [];
1392
+ for (const entry of entries) {
1393
+ const linked = asRecord(entry);
1394
+ if (!linked) continue;
1395
+ const id = asString(linked.id) ?? asString(linked);
1396
+ if (id === null) continue;
1397
+ out.push({ id, type: asString(linked.type) ?? "unknown" });
1398
+ }
1399
+ return out;
1400
+ }
1401
+
1402
+ /** Every `seeAlso` a manifest points at: its own, plus one per canvas. */
1403
+ export function collectManifestSeeAlso(manifest: unknown): SeeAlsoReference[] {
1404
+ const record = asRecord(manifest);
1405
+ if (!record) return [];
1406
+ const seen = new Set<string>();
1407
+ const out: SeeAlsoReference[] = [];
1408
+ const add = (reference: SeeAlsoReference) => {
1409
+ if (seen.has(reference.id)) return;
1410
+ seen.add(reference.id);
1411
+ out.push(reference);
1412
+ };
1413
+ for (const reference of collectSeeAlso(record)) add(reference);
1414
+ const canvases = Array.isArray(record.items) ? record.items : [];
1415
+ for (const canvas of canvases) {
1416
+ for (const reference of collectSeeAlso(canvas)) add(reference);
1417
+ }
1418
+ return out;
1419
+ }
1420
+
1421
+ /** The canvas an annotation targets, with a `#xywh=` fragment stripped. */
1422
+ function canvasIdOfTarget(target: unknown): string | null {
1423
+ const text = asString(target);
1424
+ if (text !== null) {
1425
+ const at = text.indexOf("#");
1426
+ return at === -1 ? text : text.slice(0, at);
1427
+ }
1428
+ const record = asRecord(target);
1429
+ if (!record) return null;
1430
+ if (record.source !== undefined) {
1431
+ const source = asString(record.source) ?? asString(asRecord(record.source)?.id);
1432
+ return source;
1433
+ }
1434
+ return asString(record.id);
1435
+ }
1436
+
1437
+ function pushAnnotation(annotation: unknown, byCanvas: Map<string, unknown[]>): void {
1438
+ const record = asRecord(annotation);
1439
+ if (!record) return;
1440
+ const canvasId = canvasIdOfTarget(record.target);
1441
+ if (canvasId === null) return;
1442
+ const list = byCanvas.get(canvasId);
1443
+ if (list === undefined) byCanvas.set(canvasId, [annotation]);
1444
+ else list.push(annotation);
1445
+ }
1446
+
1447
+ function pushPageItems(page: Record<string, unknown>, byCanvas: Map<string, unknown[]>): void {
1448
+ const items = Array.isArray(page.items) ? page.items : [];
1449
+ for (const annotation of items) {
1450
+ pushAnnotation(annotation, byCanvas);
1451
+ }
1452
+ }
1453
+
1454
+ /**
1455
+ * The annotations of an external page, indexed by canvas id, plus the
1456
+ * annotation-page URLs it refers to.
1457
+ *
1458
+ * One `AnnotationCollection` can carry annotations for many canvases, and a
1459
+ * canvas's annotations may be split across several referenced pages, so a
1460
+ * collection, a single page, and a bare list of annotations are all accepted.
1461
+ */
1462
+ function indexExternalAnnotations(document: unknown): {
1463
+ byCanvas: Map<string, unknown[]>;
1464
+ pages: string[];
1465
+ } {
1466
+ const byCanvas = new Map<string, unknown[]>();
1467
+ const pages: string[] = [];
1468
+ const record = asRecord(document);
1469
+ if (!record) return { byCanvas, pages };
1470
+
1471
+ const type = asString(record.type);
1472
+ if (type === "AnnotationPage") {
1473
+ pushPageItems(record, byCanvas);
1474
+ const id = asString(record.id);
1475
+ if (id !== null) pages.push(id);
1476
+ return { byCanvas, pages };
1477
+ }
1478
+ if (type === "AnnotationCollection") {
1479
+ const items = Array.isArray(record.items) ? record.items : [];
1480
+ for (const item of items) {
1481
+ const entry = asRecord(item);
1482
+ if (!entry) continue;
1483
+ // A referenced page rather than an embedded annotation. The type
1484
+ // is what tells them apart: an `Annotation` also has an `id` and
1485
+ // no `items`, so keying on the absence of `items` alone sent every
1486
+ // annotation back out to be fetched as a page of its own.
1487
+ const entryType = asString(entry.type);
1488
+ const isReference =
1489
+ entry.items === undefined &&
1490
+ (entryType === "AnnotationPage" || entryType === "AnnotationCollection") &&
1491
+ asString(entry.id) !== null;
1492
+ if (isReference) {
1493
+ pages.push(asString(entry.id) as string);
1494
+ continue;
1495
+ }
1496
+ // A collection's item is either an embedded page (which carries its
1497
+ // own `items`) or a bare annotation, and a bare annotation has no
1498
+ // `items` of its own — reading it as a page-shaped object silently
1499
+ // dropped every annotation in the collection.
1500
+ if (entry.items !== undefined) pushPageItems(entry, byCanvas);
1501
+ else pushAnnotation(entry, byCanvas);
1502
+ }
1503
+ return { byCanvas, pages };
1504
+ }
1505
+ return { byCanvas, pages };
1506
+ }
1507
+
1508
+ export type ExternalAnnotations = {
1509
+ /** Tour stops, keyed by the canvas id they annotate. */
1510
+ stops: Map<string, StorymapSlide[]>;
1511
+ /** A `SearchService1` seen along the way, for a host to query. */
1512
+ searchService: string | null;
1513
+ /** Annotation-page URLs that could not be read, for a console report. */
1514
+ failed: string[];
1515
+ };
1516
+
1517
+ /**
1518
+ * A per-(id, type) cache of fetched annotation documents.
1519
+ *
1520
+ * Keyed on both, because the same URI can be reached as a collection and as a
1521
+ * page and the two are read differently. The *promise* is cached rather than
1522
+ * the result, so two callers racing the same page share one request.
1523
+ */
1524
+ const annotationCache = new Map<string, Promise<unknown>>();
1525
+
1526
+ /** Forget every cached annotation document. Exported for tests. */
1527
+ export function clearSeeAlsoCache(): void {
1528
+ annotationCache.clear();
1529
+ }
1530
+
1531
+ /**
1532
+ * Read the annotation documents a manifest points at with `seeAlso`.
1533
+ *
1534
+ * Asynchronous by design, and deliberately not called from
1535
+ * `manifestToStorymapData`: a manifest can point at anything, a viewer should
1536
+ * not block its first paint on a third party, and a host needs to decide whether
1537
+ * it wants the extra round trips at all. One level is followed — a referenced
1538
+ * `AnnotationPage` inside a collection is fetched, but a `seeAlso` *inside* that
1539
+ * page is not — which is what keeps a cycle from becoming an infinite walk.
1540
+ *
1541
+ * A `SearchService1` is recorded and not followed: it is an endpoint for a host
1542
+ * to query, not a list of annotations to load.
1543
+ */
1544
+ export async function loadSeeAlso(
1545
+ manifest: unknown,
1546
+ options: { fetchImpl?: typeof fetch } = {},
1547
+ ): Promise<ExternalAnnotations> {
1548
+ const result: ExternalAnnotations = { stops: new Map(), searchService: null, failed: [] };
1549
+ const record = asRecord(manifest);
1550
+ if (!record) return result;
1551
+
1552
+ const impl = options.fetchImpl ?? fetch;
1553
+ const canvases = Array.isArray(record.items) ? record.items : [];
1554
+ const wanted = collectManifestSeeAlso(record);
1555
+
1556
+ const stopCollector = async (annotations: unknown[], canvasId: string): Promise<void> => {
1557
+ const canvas = canvasStub(canvasId, canvases);
1558
+ if (canvas === null) return;
1559
+ const stops = readCommentingAnnotations({
1560
+ ...(asRecord(canvas) as Record<string, unknown>),
1561
+ items: [{ type: "AnnotationPage", items: annotations }],
1562
+ });
1563
+ if (stops.length === 0) return;
1564
+ const existing = result.stops.get(canvasId);
1565
+ if (existing === undefined) result.stops.set(canvasId, stops);
1566
+ else existing.push(...stops);
1567
+ };
1568
+
1569
+ await Promise.all(
1570
+ wanted.map(async (reference) => {
1571
+ if (reference.type === "SearchService1") {
1572
+ result.searchService = reference.id;
1573
+ return;
1574
+ }
1575
+ if (
1576
+ reference.type !== "AnnotationCollection" &&
1577
+ reference.type !== "AnnotationPage" &&
1578
+ reference.type !== "unknown"
1579
+ ) {
1580
+ return;
1581
+ }
1582
+ const loaded = await loadDocument(reference, impl, result);
1583
+ if (loaded === null) return;
1584
+ for (const [canvasId, annotations] of loaded.byCanvas) {
1585
+ await stopCollector(annotations, canvasId);
1586
+ }
1587
+ for (const pageId of loaded.pages) {
1588
+ const page = await loadDocument(
1589
+ { id: pageId, type: "AnnotationPage" },
1590
+ impl,
1591
+ result,
1592
+ );
1593
+ if (page === null) continue;
1594
+ for (const [canvasId, annotations] of page.byCanvas) {
1595
+ await stopCollector(annotations, canvasId);
1596
+ }
1597
+ }
1598
+ }),
1599
+ );
1600
+
1601
+ return result;
1602
+ }
1603
+
1604
+ /**
1605
+ * The canvas object an external annotation names, or null when the manifest
1606
+ * does not have that canvas.
1607
+ *
1608
+ * Null rather than a synthetic stub on purpose: the plan merges external
1609
+ * annotations "for the canvases we render", so an annotation aimed at a canvas
1610
+ * this story does not carry is not a tour stop. A stub would also invent a
1611
+ * canvas with no size, leaving a region with nothing to be relative to.
1612
+ */
1613
+ function canvasStub(canvasId: string, manifestCanvases: unknown[]): unknown {
1614
+ for (const canvas of manifestCanvases) {
1615
+ const record = asRecord(canvas);
1616
+ if (record !== null && asString(record.id) === canvasId) return record;
1617
+ }
1618
+ return null;
1619
+ }
1620
+
1621
+ async function loadDocument(
1622
+ reference: SeeAlsoReference,
1623
+ impl: typeof fetch,
1624
+ result: ExternalAnnotations,
1625
+ ): Promise<{ byCanvas: Map<string, unknown[]>; pages: string[] } | null> {
1626
+ const key = `${reference.type} ${reference.id}`;
1627
+ let pending = annotationCache.get(key);
1628
+ if (pending === undefined) {
1629
+ pending = impl(reference.id, {
1630
+ headers: { Accept: "application/ld+json, application/json" },
1631
+ })
1632
+ .then((response) => {
1633
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1634
+ return response.json() as Promise<unknown>;
1635
+ })
1636
+ .catch((error: unknown) => {
1637
+ // a cached rejection would be remembered forever, so drop it
1638
+ // and let a later call retry
1639
+ annotationCache.delete(key);
1640
+ throw error;
1641
+ });
1642
+ annotationCache.set(key, pending);
1643
+ }
1644
+ try {
1645
+ return indexExternalAnnotations(await pending);
1646
+ } catch (error) {
1647
+ result.failed.push(reference.id);
1648
+ console.warn(
1649
+ `StoryMapJS: the seeAlso target could not be read: ${reference.id}`,
1650
+ error instanceof Error ? error.message : error,
1651
+ );
1652
+ return null;
1653
+ }
1654
+ }
1655
+
265
1656
  /**
266
1657
  * Converts a IIIF Presentation API 3.0 manifest into legacy StoryMapJS data
267
1658
  * (the `storymap` object). Malformed or missing pieces are skipped; the
@@ -272,25 +1663,85 @@ export function manifestToStorymapData(manifest: unknown): StorymapData {
272
1663
  const record = asRecord(manifest);
273
1664
  if (!record) return data;
274
1665
 
1666
+ // A Collection is not a Manifest, but it is a legitimate IIIF input: its
1667
+ // member Manifests' canvases concatenate into one linear story (§2.2)
1668
+ if (isPresentation3Collection(record)) {
1669
+ return collectionToStorymapData(record);
1670
+ }
1671
+
275
1672
  const title = flattenLanguageMap(record.label);
276
1673
  if (title !== "") data.title = title;
277
1674
 
1675
+ // A slide is addressed by its Canvas id, falling back to the Manifest's
1676
+ // (§2.3). The viewer already resolves deep links by `uniqueid`
1677
+ // (`StorySlider.goToId`) and generates one when falsy, so this is what
1678
+ // makes a manifest stop shareable by its canonical id.
1679
+ const manifestId = asString(record.id);
1680
+
278
1681
  // Manifest-level map configuration service → storymap options fields.
279
1682
  const config = readMapConfig(record);
280
1683
  if (config) {
281
1684
  applyMapConfig(data, config);
282
1685
  }
283
1686
 
284
- // requiredStatement → iiif.attribution
285
- const requiredStatement = asRecord(record.requiredStatement);
286
- const attribution = requiredStatement ? flattenLanguageMap(requiredStatement.value) : "";
287
- if (attribution !== "") {
1687
+ // georeferenced IIIF images (Georeference Extension) sit above the
1688
+ // term-based overlay layers. The annotations are canvas-scoped but the
1689
+ // layers they describe are map-wide, so they are collected from *every*
1690
+ // canvas: a layer annotated on canvas 0 must not disappear for the rest
1691
+ // of the story (§2.10).
1692
+ const georeferenced = readGeoreferencedLayers(record);
1693
+ if (georeferenced.length > 0) {
1694
+ const existing = (data.overlays as StorymapOverlayLayer[] | undefined) ?? [];
1695
+ data.overlays = [...existing, ...georeferenced];
1696
+ }
1697
+
1698
+ // An image basemap is a painting body carrying an Image API service in
1699
+ // `service[]`; its base is where `iiif.url` comes from (§2.4). Gated on
1700
+ // map_type "iiif" because an ordinary slide can just as easily be an IIIF
1701
+ // image, and only the basemap one is the map.
1702
+ if (data.map_type === "iiif") {
1703
+ const imageService = readImageServiceUrl(record);
1704
+ if (imageService !== null) data.iiif = { url: imageService, attribution: "" };
1705
+ }
1706
+
1707
+ // Institutional credit: the manifest's own `requiredStatement`, then who
1708
+ // provided it and under what licence. All of it lands on the one credit
1709
+ // string the viewer renders, so a licence stated in the manifest is
1710
+ // actually shown rather than silently dropped (§3.2).
1711
+ const credit = [
1712
+ formatAttribution(readRequiredStatement(record.requiredStatement)),
1713
+ agentCredit(record.provider, "Provider"),
1714
+ asString(record.rights) !== null ? `Licence: ${asString(record.rights)}` : "",
1715
+ // an Agent with only a homepage still says who published this
1716
+ agentCredit(record.homepage, ""),
1717
+ ]
1718
+ .filter((part) => part !== "")
1719
+ .join(" · ");
1720
+ if (credit !== "") {
288
1721
  const iiif = (data.iiif as { url?: string; attribution?: string } | undefined) ?? {};
289
- iiif.attribution = attribution;
1722
+ iiif.attribution = credit;
290
1723
  if (iiif.url === undefined) iiif.url = "";
291
1724
  data.iiif = iiif;
292
1725
  }
293
1726
 
1727
+ // `logo` is an image, not a credit line, so it is offered as data for a
1728
+ // host to show (§3.2)
1729
+ const logo = asString(asRecord(record.logo)?.id);
1730
+ if (logo !== null) data.logo = logo;
1731
+
1732
+ // `metadata` is a list of label/value pairs, which cannot be rendered
1733
+ // generically — they are handed on as data (§3.2)
1734
+ const metadata = readMetadata(record.metadata);
1735
+ if (metadata.length > 0) data.metadata = metadata;
1736
+
1737
+ // The `seeAlso` targets are recorded but not followed here: reading them is
1738
+ // a network round trip per document, and a viewer should not block its
1739
+ // first paint on a third party. `loadSeeAlso()` fetches them on request,
1740
+ // behind its own cache, and the storymap's own slides stand on their own
1741
+ // meanwhile (§5.2).
1742
+ const seeAlso = collectManifestSeeAlso(record);
1743
+ if (seeAlso.length > 0) data.see_also = seeAlso;
1744
+
294
1745
  // items[] (Canvases) → slides[], in order
295
1746
  const items = Array.isArray(record.items) ? record.items : [];
296
1747
  const manifestNavPlace = asRecord(record.navPlace);
@@ -298,9 +1749,67 @@ export function manifestToStorymapData(manifest: unknown): StorymapData {
298
1749
  manifestNavPlace && Array.isArray(manifestNavPlace.features)
299
1750
  ? manifestNavPlace.features
300
1751
  : [];
301
- for (let index = 0; index < items.length; index++) {
302
- const slide = canvasToSlide(items[index], manifestFeatures[index]);
303
- if (slide !== null) data.slides.push(slide);
1752
+
1753
+ // `structures` states the curated order and the groups (§3.5). A Range
1754
+ // with a label and no `start` is a group; nested Ranges are chapters
1755
+ // within it, and a Range whose items run in a different order than the
1756
+ // canvases is a storyboard. Either way the order comes from here, not
1757
+ // from the document.
1758
+ const structures = readStructures(record.structures);
1759
+ const order = new Map<string, number>();
1760
+ for (const [position, canvasId] of structures.order.entries()) {
1761
+ order.set(canvasId, position);
1762
+ }
1763
+ const indexes = items.map((_item, index) => index);
1764
+ indexes.sort((a, b) => {
1765
+ const ia = order.get(asString(asRecord(items[a])?.id) ?? "");
1766
+ const ib = order.get(asString(asRecord(items[b])?.id) ?? "");
1767
+ // a canvas no Range mentions keeps its document position, after the
1768
+ // ones a Range does mention
1769
+ if (ia === undefined && ib === undefined) return a - b;
1770
+ if (ia === undefined) return 1;
1771
+ if (ib === undefined) return -1;
1772
+ return ia - ib;
1773
+ });
1774
+
1775
+ for (const index of indexes) {
1776
+ // The canvas index, not the output-slide index: annotation stops
1777
+ // appended below shift the slide array, and the manifest-level
1778
+ // navPlace features line up with canvases.
1779
+ const canvasId = asString(asRecord(items[index])?.id);
1780
+ const slide = canvasToSlide(
1781
+ items[index],
1782
+ manifestFeatures[index],
1783
+ asString(data.language) ?? null,
1784
+ );
1785
+ if (slide !== null) {
1786
+ slide.uniqueid = canvasId ?? manifestId ?? "";
1787
+ if (canvasId !== null) {
1788
+ const group = structures.groups.get(canvasId);
1789
+ if (group !== undefined) slide.group = group;
1790
+ }
1791
+ data.slides.push(slide);
1792
+ }
1793
+ // Annotation-driven tour stops, in annotation page order. They share
1794
+ // the canvas they annotate, suffixed so they stay addressable and do
1795
+ // not collide with it.
1796
+ for (const stop of readCommentingAnnotations(items[index])) {
1797
+ if (canvasId) {
1798
+ stop.uniqueid = `${canvasId}#${data.slides.length}`;
1799
+ }
1800
+ data.slides.push(stop);
1801
+ }
1802
+ // A polygon navPlace states the geographic extent of the story
1803
+ // ("supplying a single geographic bounding box" in the navPlace
1804
+ // extension), which the legacy format expresses as map_bbox. The
1805
+ // first canvas carrying one wins; Point navPlaces are unaffected.
1806
+ if (data.map_bbox === undefined) {
1807
+ const canvas = asRecord(items[index]);
1808
+ const bbox =
1809
+ (canvas ? readNavPlaceBbox(canvas.navPlace) : null) ??
1810
+ readNavPlaceBbox(record.navPlace);
1811
+ if (bbox !== null) data.map_bbox = bbox;
1812
+ }
304
1813
  }
305
1814
 
306
1815
  return data;
@@ -308,8 +1817,21 @@ export function manifestToStorymapData(manifest: unknown): StorymapData {
308
1817
 
309
1818
  /** Copies the mapconfig service terms onto the storymap data root (legacy keys). */
310
1819
  function applyMapConfig(data: StorymapData, config: Record<string, unknown>): void {
311
- const mapType = asString(readTerm(config, "mapType"));
312
- if (mapType !== null && mapType !== "") data.map_type = mapType;
1820
+ // The basemap is either a keyword or a tile source: `storymap:basemap`
1821
+ // names one the viewer knows how to configure (osm, stadia, iiif, …) and
1822
+ // TileJSON describes an arbitrary tile service. `mapType` used to carry
1823
+ // both in one string, which is why a URL template and a vendor keyword
1824
+ // were the same field (§2.9).
1825
+ const basemap = asString(readTerm(config, "basemap"));
1826
+ if (basemap !== null && basemap !== "") {
1827
+ data.map_type = basemap;
1828
+ } else {
1829
+ const tilejson = readTilejson(config);
1830
+ if (tilejson !== null) {
1831
+ data.map_type = Array.isArray(tilejson.tiles) ? tilejson.tiles[0] : tilejson.tiles;
1832
+ data.tilejson = tilejson;
1833
+ }
1834
+ }
313
1835
 
314
1836
  const mapAsImage = asBoolean(readTerm(config, "mapAsImage"));
315
1837
  if (mapAsImage !== null) data.map_as_image = mapAsImage;
@@ -327,14 +1849,15 @@ function applyMapConfig(data: StorymapData, config: Record<string, unknown>): vo
327
1849
  if (left !== null && top !== null) data.map_center_offset = { left, top };
328
1850
  }
329
1851
 
330
- const mapSubdomains = asString(readTerm(config, "mapSubdomains"));
331
- if (mapSubdomains !== null) data.map_subdomains = mapSubdomains;
332
-
333
- const iiifUrl = asString(readTerm(config, "iiifUrl"));
334
- if (iiifUrl !== null) data.iiif = { url: iiifUrl, attribution: "" };
335
-
336
- const fontCss = asString(readTerm(config, "fontCss"));
337
- 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
+ }
338
1861
 
339
1862
  const callToAction = asBoolean(readTerm(config, "callToAction"));
340
1863
  if (callToAction !== null) data.call_to_action = callToAction;
@@ -351,9 +1874,6 @@ function applyMapConfig(data: StorymapData, config: Record<string, unknown>): vo
351
1874
  const calculateZoom = asBoolean(readTerm(config, "calculateZoom"));
352
1875
  if (calculateZoom !== null) data.calculate_zoom = calculateZoom;
353
1876
 
354
- const lessBounce = asBoolean(readTerm(config, "lessBounce"));
355
- if (lessBounce !== null) data.less_bounce = lessBounce;
356
-
357
1877
  const lineFollowsPath = asBoolean(readTerm(config, "lineFollowsPath"));
358
1878
  if (lineFollowsPath !== null) data.line_follows_path = lineFollowsPath;
359
1879
 
@@ -383,4 +1903,131 @@ function applyMapConfig(data: StorymapData, config: Record<string, unknown>): vo
383
1903
 
384
1904
  const useCustomMarkers = asBoolean(readTerm(config, "useCustomMarkers"));
385
1905
  if (useCustomMarkers !== null) data.use_custom_markers = useCustomMarkers;
1906
+
1907
+ // landscape map layout: "full" (default) or "left"
1908
+ const mapArea = asString(readTerm(config, "mapArea"));
1909
+ if (mapArea === "full" || mapArea === "left") data.map_area = mapArea;
1910
+
1911
+ const overviewExtent = readLonLatBox(readTerm(config, "overviewExtent"));
1912
+ if (overviewExtent !== null) data.overview_extent = overviewExtent;
1913
+
1914
+ const keyboard = asBoolean(readTerm(config, "keyboard"));
1915
+ if (keyboard !== null) data.keyboard = keyboard;
1916
+
1917
+ // stacked layers above the basemap, as extension-term entries
1918
+ const overlays = readOverlays(readTerm(config, "overlays"));
1919
+ if (overlays.length > 0) data.overlays = overlays;
1920
+ }
1921
+
1922
+ /** Reads a `[west, south, east, north]` lon/lat box of four finite numbers. */
1923
+ function readLonLatBox(value: unknown): [number, number, number, number] | null {
1924
+ if (!isLonLatBox(value)) return null;
1925
+ const [west, south, east, north] = value;
1926
+ if (east <= west || north <= south) return null;
1927
+ return [west, south, east, north];
1928
+ }
1929
+
1930
+ /** Reads the shared presentation keys of an `overlays[]` entry. */
1931
+ function readOverlayPresentation(
1932
+ record: Record<string, unknown>,
1933
+ entry: StorymapOverlayLayer,
1934
+ ): void {
1935
+ const opacity = asNumber(record.opacity);
1936
+ if (opacity !== null) entry.opacity = opacity;
1937
+ const visible = asBoolean(record.visible);
1938
+ if (visible !== null) entry.visible = visible;
1939
+ const attribution = asString(record.attribution);
1940
+ if (attribution !== null) entry.attribution = attribution;
1941
+ const className = asString(record.className);
1942
+ if (className !== null) entry.className = className;
1943
+ const blendMode = asString(record.blendMode);
1944
+ if (blendMode !== null) entry.blendMode = blendMode;
1945
+ const extent = readLonLatBox(record.extent);
1946
+ if (extent !== null) entry.extent = extent;
1947
+ }
1948
+
1949
+ /**
1950
+ * Reads `storymap:overlays`: stacked raster layers, each naming any
1951
+ * `map_type` the tile layer factory accepts plus declarative presentation.
1952
+ * Entries without a `map_type` are dropped here (the map skips them too).
1953
+ */
1954
+ function readOverlays(value: unknown): StorymapOverlayLayer[] {
1955
+ if (!Array.isArray(value)) return [];
1956
+ const overlays: StorymapOverlayLayer[] = [];
1957
+ for (const item of value) {
1958
+ const record = asRecord(item);
1959
+ if (!record) continue;
1960
+ const mapType = asString(record.map_type);
1961
+ if (mapType === null || mapType === "") continue;
1962
+ const entry: StorymapOverlayLayer = { map_type: mapType };
1963
+ readOverlayPresentation(record, entry);
1964
+ overlays.push(entry);
1965
+ }
1966
+ return overlays;
1967
+ }
1968
+
1969
+ /**
1970
+ * IIIF images placed on the geographic map, from the Georeference Extension's
1971
+ * `motivation: "georeferencing"` annotations (§2.10).
1972
+ *
1973
+ * Per the extension, a layer that is not part of the canvas it ships in — which
1974
+ * is every map layer — carries the image it places in `target` as an embedded
1975
+ * resource, and the ground control points in `body` as a FeatureCollection
1976
+ * whose features carry `resourceCoords`.
1977
+ *
1978
+ * The annotations are read from every canvas, because the layers they describe
1979
+ * are map-wide. Entries without a usable image or without ground control points
1980
+ * are dropped; whether the points can be placed affinely is the map's decision.
1981
+ *
1982
+ * `requiredStatement` is read as the layer's attribution, which is where P3 puts
1983
+ * attribution. The term also carried `opacity`, `visible`, `className`,
1984
+ * `blendMode` and `extent`; none of those has IIIF vocabulary for a
1985
+ * georeferencing annotation, so they no longer round-trip. The extent is not
1986
+ * lost in substance — the control points describe it.
1987
+ */
1988
+ function readGeoreferencedLayers(manifest: Record<string, unknown>): StorymapOverlayLayer[] {
1989
+ const overlays: StorymapOverlayLayer[] = [];
1990
+ const canvases = Array.isArray(manifest.items) ? manifest.items : [];
1991
+ for (const canvas of canvases) {
1992
+ const canvasRecord = asRecord(canvas);
1993
+ if (!canvasRecord) continue;
1994
+ const pages = Array.isArray(canvasRecord.items) ? canvasRecord.items : [];
1995
+ for (const page of pages) {
1996
+ const pageRecord = asRecord(page);
1997
+ if (!pageRecord) continue;
1998
+ const annotations = Array.isArray(pageRecord.items) ? pageRecord.items : [];
1999
+ for (const annotation of annotations) {
2000
+ const record = asRecord(annotation);
2001
+ if (!record) continue;
2002
+ const motivations = Array.isArray(record.motivation)
2003
+ ? record.motivation
2004
+ : [record.motivation];
2005
+ if (!motivations.some((m) => asString(m) === "georeferencing")) continue;
2006
+
2007
+ const target = asRecord(record.target);
2008
+ if (!target) continue;
2009
+ const url = asString(target.id);
2010
+ if (url === null) continue;
2011
+ const width = asNumber(target.width);
2012
+ const height = asNumber(target.height);
2013
+ if (width === null || height === null || width <= 0 || height <= 0) continue;
2014
+ const body = asRecord(record.body);
2015
+ if (!body || readGroundControlPoints(body) === null) continue;
2016
+
2017
+ const georeference: StorymapGeoreference = {
2018
+ url,
2019
+ width,
2020
+ height,
2021
+ body: body as unknown as StorymapGeoreference["body"],
2022
+ };
2023
+ const entry: StorymapOverlayLayer = { georeference };
2024
+ const attribution = formatAttribution(
2025
+ readRequiredStatement(record.requiredStatement),
2026
+ );
2027
+ if (attribution !== "") entry.attribution = attribution;
2028
+ overlays.push(entry);
2029
+ }
2030
+ }
2031
+ }
2032
+ return overlays;
386
2033
  }