@readium/shared 1.2.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 (150) hide show
  1. package/LICENSE +28 -0
  2. package/README.MD +27 -0
  3. package/dist/index.js +2536 -0
  4. package/dist/index.umd.cjs +2 -0
  5. package/package.json +73 -0
  6. package/src/fetcher/Fetcher.ts +37 -0
  7. package/src/fetcher/HttpFetcher.ts +122 -0
  8. package/src/fetcher/Resource.ts +37 -0
  9. package/src/fetcher/index.ts +2 -0
  10. package/src/index.ts +4 -0
  11. package/src/opds/Acquisition.ts +54 -0
  12. package/src/opds/Availability.ts +61 -0
  13. package/src/opds/Copies.ts +43 -0
  14. package/src/opds/Holds.ts +43 -0
  15. package/src/opds/Price.ts +51 -0
  16. package/src/opds/index.ts +5 -0
  17. package/src/publication/BelongsTo.ts +51 -0
  18. package/src/publication/Contributor.ts +129 -0
  19. package/src/publication/GuidedNavigation.ts +176 -0
  20. package/src/publication/Link.ts +332 -0
  21. package/src/publication/LocalizedString.ts +74 -0
  22. package/src/publication/Locator.ts +225 -0
  23. package/src/publication/LocatorCollection.ts +133 -0
  24. package/src/publication/Manifest.ts +256 -0
  25. package/src/publication/Metadata.ts +329 -0
  26. package/src/publication/Properties.ts +44 -0
  27. package/src/publication/Publication.ts +148 -0
  28. package/src/publication/PublicationCollection.ts +144 -0
  29. package/src/publication/ReadingProgression.ts +28 -0
  30. package/src/publication/Subject.ts +115 -0
  31. package/src/publication/encryption/Encryption.ts +68 -0
  32. package/src/publication/encryption/Properties.ts +20 -0
  33. package/src/publication/encryption/index.ts +2 -0
  34. package/src/publication/epub/EPUBLayout.ts +10 -0
  35. package/src/publication/epub/Presentation.ts +16 -0
  36. package/src/publication/epub/Properties.ts +28 -0
  37. package/src/publication/epub/Publication.ts +51 -0
  38. package/src/publication/epub/index.ts +4 -0
  39. package/src/publication/html/DomRange.ts +63 -0
  40. package/src/publication/html/DomRangePoint.ts +64 -0
  41. package/src/publication/html/Locations.ts +129 -0
  42. package/src/publication/html/index.ts +3 -0
  43. package/src/publication/index.ts +19 -0
  44. package/src/publication/opds/Properties.ts +84 -0
  45. package/src/publication/opds/Publication.ts +14 -0
  46. package/src/publication/opds/index.ts +2 -0
  47. package/src/publication/presentation/Metadata.ts +19 -0
  48. package/src/publication/presentation/Presentation.ts +155 -0
  49. package/src/publication/presentation/Properties.ts +70 -0
  50. package/src/publication/presentation/index.ts +3 -0
  51. package/src/publication/services/content/Content.ts +36 -0
  52. package/src/publication/services/content/ContentTokenizer.ts +62 -0
  53. package/src/publication/services/content/Iterator.ts +46 -0
  54. package/src/publication/services/content/element/attributes.ts +44 -0
  55. package/src/publication/services/content/element/element.ts +118 -0
  56. package/src/publication/services/content/element/index.ts +3 -0
  57. package/src/publication/services/content/element/text_role.ts +60 -0
  58. package/src/publication/services/content/index.ts +5 -0
  59. package/src/publication/services/content/iterators/HTMLResourceContentIterator.ts +571 -0
  60. package/src/publication/services/content/iterators/PDFTextContentIterator.ts +184 -0
  61. package/src/publication/services/content/iterators/PublicationContentIterator.ts +144 -0
  62. package/src/publication/services/content/iterators/helpers.ts +146 -0
  63. package/src/publication/services/content/iterators/index.ts +3 -0
  64. package/src/publication/services/index.ts +1 -0
  65. package/src/util/JSONParse.ts +31 -0
  66. package/src/util/Language.ts +5 -0
  67. package/src/util/URITemplate.ts +83 -0
  68. package/src/util/index.ts +5 -0
  69. package/src/util/mediatype/MediaType.ts +563 -0
  70. package/src/util/mediatype/index.ts +1 -0
  71. package/src/util/tokenizer/TextTokenizer.ts +126 -0
  72. package/src/util/tokenizer/Tokenizer.ts +8 -0
  73. package/src/util/tokenizer/index.ts +2 -0
  74. package/src/util/tokenizer/tokenize-english/README.md +7 -0
  75. package/src/util/tokenizer/tokenize-english/abbreviations.js +52 -0
  76. package/src/util/tokenizer/tokenize-english/index.d.ts +3 -0
  77. package/src/util/tokenizer/tokenize-english/index.js +243 -0
  78. package/src/util/tokenizer/tokenize-english/utils.js +16 -0
  79. package/src/util/tokenizer/tokenize-text/README.MD +6 -0
  80. package/src/util/tokenizer/tokenize-text/index.d.ts +52 -0
  81. package/src/util/tokenizer/tokenize-text/index.js +256 -0
  82. package/src/util/tokenizer/tokenize-text/tokens.js +100 -0
  83. package/types/src/fetcher/Fetcher.d.ts +27 -0
  84. package/types/src/fetcher/HttpFetcher.d.ts +28 -0
  85. package/types/src/fetcher/Resource.d.ts +15 -0
  86. package/types/src/fetcher/index.d.ts +2 -0
  87. package/types/src/index.d.ts +4 -0
  88. package/types/src/opds/Acquisition.d.ts +26 -0
  89. package/types/src/opds/Availability.d.ts +32 -0
  90. package/types/src/opds/Copies.d.ts +22 -0
  91. package/types/src/opds/Holds.d.ts +22 -0
  92. package/types/src/opds/Price.d.ts +29 -0
  93. package/types/src/opds/index.d.ts +5 -0
  94. package/types/src/publication/BelongsTo.d.ts +23 -0
  95. package/types/src/publication/Contributor.d.ts +60 -0
  96. package/types/src/publication/GuidedNavigation.d.ts +70 -0
  97. package/types/src/publication/Link.d.ts +128 -0
  98. package/types/src/publication/LocalizedString.d.ts +48 -0
  99. package/types/src/publication/Locator.d.ts +101 -0
  100. package/types/src/publication/LocatorCollection.d.ts +54 -0
  101. package/types/src/publication/Manifest.d.ts +59 -0
  102. package/types/src/publication/Metadata.d.ts +101 -0
  103. package/types/src/publication/Properties.d.ts +28 -0
  104. package/types/src/publication/Publication.d.ts +53 -0
  105. package/types/src/publication/PublicationCollection.d.ts +34 -0
  106. package/types/src/publication/ReadingProgression.d.ts +9 -0
  107. package/types/src/publication/Subject.d.ts +56 -0
  108. package/types/src/publication/encryption/Encryption.d.ts +33 -0
  109. package/types/src/publication/encryption/Properties.d.ts +10 -0
  110. package/types/src/publication/encryption/index.d.ts +2 -0
  111. package/types/src/publication/epub/EPUBLayout.d.ts +5 -0
  112. package/types/src/publication/epub/Presentation.d.ts +7 -0
  113. package/types/src/publication/epub/Properties.d.ts +14 -0
  114. package/types/src/publication/epub/Publication.d.ts +19 -0
  115. package/types/src/publication/epub/index.d.ts +4 -0
  116. package/types/src/publication/html/DomRange.d.ts +40 -0
  117. package/types/src/publication/html/DomRangePoint.d.ts +36 -0
  118. package/types/src/publication/html/Locations.d.ts +45 -0
  119. package/types/src/publication/html/index.d.ts +3 -0
  120. package/types/src/publication/index.d.ts +19 -0
  121. package/types/src/publication/opds/Properties.d.ts +38 -0
  122. package/types/src/publication/opds/Publication.d.ts +6 -0
  123. package/types/src/publication/opds/index.d.ts +2 -0
  124. package/types/src/publication/presentation/Metadata.d.ts +6 -0
  125. package/types/src/publication/presentation/Presentation.d.ts +95 -0
  126. package/types/src/publication/presentation/Properties.d.ts +32 -0
  127. package/types/src/publication/presentation/index.d.ts +3 -0
  128. package/types/src/publication/services/content/Content.d.ts +20 -0
  129. package/types/src/publication/services/content/ContentTokenizer.d.ts +19 -0
  130. package/types/src/publication/services/content/Iterator.d.ts +38 -0
  131. package/types/src/publication/services/content/element/attributes.d.ts +31 -0
  132. package/types/src/publication/services/content/element/element.d.ts +107 -0
  133. package/types/src/publication/services/content/element/index.d.ts +3 -0
  134. package/types/src/publication/services/content/element/text_role.d.ts +53 -0
  135. package/types/src/publication/services/content/index.d.ts +5 -0
  136. package/types/src/publication/services/content/iterators/HTMLResourceContentIterator.d.ts +30 -0
  137. package/types/src/publication/services/content/iterators/PDFTextContentIterator.d.ts +60 -0
  138. package/types/src/publication/services/content/iterators/PublicationContentIterator.d.ts +59 -0
  139. package/types/src/publication/services/content/iterators/helpers.d.ts +10 -0
  140. package/types/src/publication/services/content/iterators/index.d.ts +3 -0
  141. package/types/src/publication/services/index.d.ts +1 -0
  142. package/types/src/util/JSONParse.d.ts +11 -0
  143. package/types/src/util/Language.d.ts +1 -0
  144. package/types/src/util/URITemplate.d.ts +24 -0
  145. package/types/src/util/index.d.ts +5 -0
  146. package/types/src/util/mediatype/MediaType.d.ts +148 -0
  147. package/types/src/util/mediatype/index.d.ts +1 -0
  148. package/types/src/util/tokenizer/TextTokenizer.d.ts +39 -0
  149. package/types/src/util/tokenizer/Tokenizer.d.ts +6 -0
  150. package/types/src/util/tokenizer/index.d.ts +2 -0
@@ -0,0 +1,60 @@
1
+
2
+ // Different role types.
3
+ export type Role =
4
+ "heading-1" | "heading-2" | "heading-3" | "heading-4" | "heading-5" | "heading-6" |
5
+ "body" | "footnote" | "quote";
6
+
7
+ /**
8
+ * Represents a purpose of an element in the broader context of the document.
9
+ */
10
+ export interface TextRole {
11
+ get role(): Role;
12
+ }
13
+
14
+ /**
15
+ * Title of a section.
16
+ */
17
+ export class Heading implements TextRole {
18
+ constructor(
19
+ /**
20
+ * Heading importance, 1 being the highest.
21
+ */
22
+ public readonly level: number
23
+ ) { }
24
+
25
+ get role(): Role {
26
+ return `heading-${this.level}` as Role;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Normal body of content.
32
+ */
33
+ export const Body: TextRole = {
34
+ role: "body"
35
+ }
36
+
37
+ /**
38
+ * A footnote at the bottom of a document.
39
+ */
40
+ export const Footnote: TextRole = {
41
+ role: "footnote"
42
+ }
43
+
44
+ /**
45
+ * A quotation.
46
+ */
47
+ export class TextQuote implements TextRole {
48
+ public readonly role: Role = "quote";
49
+
50
+ constructor(
51
+ /**
52
+ * URL to the source for this quote.
53
+ */
54
+ public readonly referenceUrl: URL | undefined,
55
+ /**
56
+ * Name of the source for this quote.
57
+ */
58
+ public readonly referenceTitle: string | undefined
59
+ ) { }
60
+ }
@@ -0,0 +1,5 @@
1
+ export * from './Content';
2
+ export * from './ContentTokenizer';
3
+ export * from './Iterator';
4
+ export * from './element';
5
+ export * from './iterators';
@@ -0,0 +1,571 @@
1
+ import { Resource } from "../../../../fetcher/Resource";
2
+ import { Link, Links } from "../../../Link";
3
+ import { Locator, LocatorLocations } from "../../../Locator";
4
+ import { IllegalStateError, Iterator } from "../Iterator";
5
+ import { Attribute, AttributeKeys, AudioElement, Body, ContentElement, Footnote, Heading, ImageElement, TextElement, TextQuote, TextRole, TextSegment, VideoElement } from "../element";
6
+ import { appendNormalizedWhitespace, elementLanguage, isBlank, isInlineTag, srcRelativeToHref, trimUnicodeSpace, trimUnicodeSpaceEnd, trimUnicodeSpaceStart, trimmingTextLocator } from "./helpers";
7
+ import { getCssSelector } from "css-selector-generator";
8
+
9
+ interface ElementWithDelta {
10
+ element: ContentElement;
11
+ delta: number;
12
+ }
13
+
14
+ /**
15
+ * Iterates an HTML [resource], starting from the given [locator].
16
+ *
17
+ * If you want to start mid-resource, the [locator] must contain a `cssSelector` key in its
18
+ * [Locator.Locations] object.
19
+ *
20
+ * If you want to start from the end of the resource, the [locator] must have a `progression` of 1.0.
21
+ *
22
+ * Locators will contain a `before` context of up to `beforeMaxLength` characters.
23
+ */
24
+ export class HTMLResourceContentIterator extends Iterator {
25
+ private currentElement: ElementWithDelta | null = null;
26
+
27
+ constructor(
28
+ private resource: Resource,
29
+ private locator: Locator,
30
+ private beforeMaxLength = 50
31
+ ) {
32
+ super();
33
+ }
34
+
35
+ async hasPrevious(): Promise<boolean> {
36
+ if (this.currentElement?.delta === -1) return true;
37
+
38
+ const elements = await this.elements();
39
+ const index = (this.currentIndex ?? elements.startIndex) - 1;
40
+
41
+ const content = elements.elements.at(index);
42
+ if (!content) return false;
43
+
44
+ this.currentIndex = index;
45
+ this.currentElement = {
46
+ delta: -1,
47
+ element: this.parser!.executeRecipe(content)
48
+ };
49
+ return true;
50
+ }
51
+
52
+ previous(): ContentElement {
53
+ if (!this.currentElement || this.currentElement.delta !== -1) throw new IllegalStateError("Called previous() without a successful call to hasPrevious() first");
54
+ const el = this.currentElement.element;
55
+ this.currentElement = null;
56
+ return el;
57
+ }
58
+
59
+ async hasNext(): Promise<boolean> {
60
+ if (this.currentElement?.delta === 1) return true;
61
+
62
+ const elements = await this.elements();
63
+ const index = (this.currentIndex ?? (elements.startIndex - 1)) + 1;
64
+
65
+ const content = elements.elements.at(index);
66
+ if (!content) return false;
67
+
68
+ this.currentIndex = index;
69
+ this.currentElement = {
70
+ delta: 1,
71
+ element: this.parser!.executeRecipe(content)
72
+ };
73
+ return true;
74
+ }
75
+
76
+ next(): ContentElement {
77
+ if (!this.currentElement || this.currentElement.delta !== 1) throw new IllegalStateError("Called next() without a successful call to hasNext() first");
78
+ const el = this.currentElement.element;
79
+ this.currentElement = null;
80
+ return el;
81
+ }
82
+
83
+ private currentIndex: number | null = null;
84
+ private parsedElementRecipes: ParsedElementRecipes | null = null;
85
+
86
+ private async elements(): Promise<ParsedElementRecipes> {
87
+ this.parsedElementRecipes ??= await this.parseElements();
88
+ return this.parsedElementRecipes;
89
+ }
90
+
91
+ private parser: ContentParser | null = null;
92
+
93
+ private async parseElements(): Promise<ParsedElementRecipes> {
94
+ const raw = await this.resource.readAsString();
95
+ const doc = (new DOMParser()).parseFromString(raw!, this.locator.type as DOMParserSupportedType);
96
+ this.parser = new ContentParser(
97
+ doc,
98
+ this.locator,
99
+ this.locator.locations.getCssSelector() ? doc.querySelector(this.locator.locations.getCssSelector()!) as Element : null,
100
+ this.beforeMaxLength
101
+ );
102
+
103
+ TraverseNode(this.parser, doc.body);
104
+ return this.parser.result();
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Holds the result of parsing the HTML resource into a list of [ElementRecipe].
110
+ * The recipes can be used to generate ContentElement instances, but are used
111
+ * instead to reduce memory usage that happens due to string copies
112
+ * The [startIndex] will be calculated from the element matched by the base
113
+ * [locator], if possible. Defaults to 0.
114
+ */
115
+ interface ParsedElementRecipes {
116
+ elements: ElementRecipe[];
117
+ startIndex: number;
118
+ }
119
+
120
+ // https://jsoup.org/apidocs/org/jsoup/select/NodeVisitor.html (simplified)
121
+ interface NodeVisitor {
122
+ head(node: Node, depth: number): void; // Callback for when a node is first visited.
123
+ tail(node: Node, depth: number): void; // Callback for when a node is last visited, after all of its descendants have been visited.
124
+ }
125
+
126
+ // Start a depth-first traverse of the root and all of its descendants.
127
+ // This implementation does not use recursion, so a deep DOM does not risk blowing the stack.
128
+ // From JSoup: https://github.com/jhy/jsoup/blob/1762412a28fa7b08ccf71d93fc4c98dc73086e03/src/main/java/org/jsoup/select/NodeTraversor.java#L20
129
+ // NOTE: Unlike the JSoup implementation, we expect any implementor of NodeVisitor to be read-only, because it simplifies implementation.
130
+ function TraverseNode(visitor: NodeVisitor, root: Node | null) {
131
+ let node = root;
132
+ let depth = 0;
133
+
134
+ while (node != null) {
135
+ visitor.head(node, depth); // visit current node
136
+
137
+ // DON'T check if removed or replaced
138
+
139
+ if (node.firstChild != null) { // descend
140
+ node = node.firstChild;
141
+ depth++;
142
+ } else {
143
+ while (true) {
144
+ if (!(node?.nextSibling == null && depth > 0)) break;
145
+ node && visitor.tail(node, depth); // when no more siblings, ascend
146
+ node = node?.parentNode || null;
147
+ depth--;
148
+ }
149
+ node && visitor.tail(node, depth);
150
+ if (node === root) {
151
+ break;
152
+ }
153
+ node = node?.nextSibling || null;
154
+ }
155
+ }
156
+ }
157
+
158
+ interface LocatorRecipe {
159
+ cssSelectorNode: Node | undefined;
160
+ }
161
+
162
+ type TextSegmentRecipe = LocatorRecipe & {
163
+ text: string;
164
+ beforeRange: [number, number];
165
+ attributes: Attribute<any>[];
166
+ }
167
+
168
+ type TextElementRecipe = LocatorRecipe & {
169
+ text: string;
170
+ role: TextRole;
171
+ segments: TextSegmentRecipe[];
172
+ }
173
+
174
+ type ImageElementRecipe = LocatorRecipe & {
175
+ href: string;
176
+ caption: string | undefined;
177
+ attributes: Attribute<any>[];
178
+ }
179
+
180
+ type AVElementRecipe = LocatorRecipe & {
181
+ link: Link;
182
+ }
183
+
184
+ enum ElementRecipeType {
185
+ TextRecipe,
186
+ ImageRecipe,
187
+ AudioRecipe,
188
+ VideoRecipe
189
+ }
190
+
191
+ type ElementRecipe = {
192
+ type: ElementRecipeType.TextRecipe;
193
+ recipe: TextElementRecipe;
194
+ } | {
195
+ type: ElementRecipeType.ImageRecipe;
196
+ recipe: ImageElementRecipe;
197
+ } | {
198
+ type: ElementRecipeType.AudioRecipe | ElementRecipeType.VideoRecipe;
199
+ recipe: AVElementRecipe;
200
+ };
201
+
202
+ // Note that this whole thing is based off of JSoup's Node-related classes, with simplifications
203
+ // https://jsoup.org/apidocs/org/jsoup/select/NodeTraversor.html
204
+ class ContentParser implements NodeVisitor {
205
+ private readonly elementRecipes: ElementRecipe[] = [];
206
+ private startIndex = 0;
207
+ private segmentRecipesAcc: TextSegmentRecipe[] = []; // Segments accumulated for the current element.
208
+ private textAcc = ""; // Text since the beginning of the current segment, after coalescing whitespaces.
209
+ private wholeRawTextAcc: string | null = null; // Text content since the beginning of the resource, including whitespaces.
210
+ private elementRawTextAcc = ""; // Text content since the beginning of the current element, including whitespaces.
211
+ private rawTextAcc = ""; // Text content since the beginning of the current segment, including whitespaces.
212
+ private currentLanguage: string | null = null; // Language of the current segment.
213
+ private breadcrumbs: Node[] = []; // LIFO stack of the current element's block ancestors.
214
+ private recipeCssSelectorCache = new WeakMap<HTMLElement, string>();
215
+
216
+ constructor(
217
+ private doc: Document,
218
+ private baseLocator: Locator,
219
+ private startElement: Element | null,
220
+ private beforeMaxLength: number
221
+ ) { }
222
+
223
+ result(): ParsedElementRecipes {
224
+ return {
225
+ elements: this.elementRecipes,
226
+ startIndex: this.baseLocator.locations.progression === 1.0
227
+ ? this.elementRecipes.length : this.startIndex
228
+ } as ParsedElementRecipes;
229
+ }
230
+
231
+ executeRecipe(item: ElementRecipe): ContentElement {
232
+ const cssSelector = item.recipe.cssSelectorNode ? this.cssSelector(item.recipe.cssSelectorNode) : null;
233
+
234
+ if(item.type === ElementRecipeType.ImageRecipe) {
235
+ // Image
236
+ const recipe = item.recipe;
237
+ return new ImageElement(
238
+ new Locator({
239
+ href: this.baseLocator.href,
240
+ type: this.baseLocator.type,
241
+ title: this.baseLocator.title,
242
+ locations: new LocatorLocations(cssSelector ? {
243
+ otherLocations: new Map([
244
+ ["cssSelector", cssSelector]
245
+ ])
246
+ } : {})
247
+ }),
248
+ new Link({
249
+ href: recipe.href
250
+ }),
251
+ recipe.caption,
252
+ recipe.attributes
253
+ );
254
+ } else if(item.type === ElementRecipeType.AudioRecipe || item.type === ElementRecipeType.VideoRecipe) {
255
+ // Audio or Video
256
+ const locator = new Locator({
257
+ href: this.baseLocator.href,
258
+ type: this.baseLocator.type,
259
+ title: this.baseLocator.title,
260
+ locations: new LocatorLocations(cssSelector ? {
261
+ otherLocations: new Map([
262
+ ["cssSelector", cssSelector]
263
+ ])
264
+ } : {})
265
+ });
266
+ const recipe = item.recipe;
267
+
268
+ if(item.type === ElementRecipeType.VideoRecipe) {
269
+ return new VideoElement(
270
+ locator,
271
+ recipe.link
272
+ );
273
+ } else {
274
+ return new AudioElement(
275
+ locator,
276
+ recipe.link
277
+ );
278
+ }
279
+ } else if(item.type === ElementRecipeType.TextRecipe) {
280
+ // Text
281
+ const recipe = item.recipe;
282
+ const segments: TextSegment[] = recipe.segments.map(sr => {
283
+ const segmentCssSelector = sr.cssSelectorNode ? this.cssSelector(sr.cssSelectorNode) : null;
284
+ return new TextSegment(
285
+ new Locator({
286
+ href: this.baseLocator.href,
287
+ type: this.baseLocator.type,
288
+ title: this.baseLocator.title,
289
+ text: trimmingTextLocator(sr.text, this.wholeRawTextAcc?.substring(sr.beforeRange[0], sr.beforeRange[1])),
290
+ locations: new LocatorLocations(segmentCssSelector ? {
291
+ otherLocations: new Map([
292
+ ["cssSelector", segmentCssSelector]
293
+ ])
294
+ } : {})
295
+ }),
296
+ sr.text,
297
+ sr.attributes
298
+ );
299
+ });
300
+
301
+ return new TextElement(
302
+ new Locator({
303
+ href: this.baseLocator.href,
304
+ type: this.baseLocator.type,
305
+ title: this.baseLocator.title,
306
+ text: trimmingTextLocator(recipe.text, segments.length && segments[0].locator.text?.before || ""),
307
+ locations: new LocatorLocations(cssSelector ? {
308
+ otherLocations: new Map([
309
+ ["cssSelector", cssSelector]
310
+ ])
311
+ } : {})
312
+ }),
313
+ recipe.role,
314
+ segments,
315
+ );
316
+ } else {
317
+ throw new Error(`Unknown recipe type`);
318
+ }
319
+ }
320
+
321
+ private cssSelector(node: Node) {
322
+ const el = node as HTMLElement;
323
+
324
+ const cached = this.recipeCssSelectorCache.get(el);
325
+ if(cached) return cached;
326
+
327
+ /**
328
+ * The css-selector-generator library chokes when generating a selector and using element
329
+ * attributes that have a namespace prefix, for example `xml:lang="fr"`. It's also not very
330
+ * useful to use these elements to generate a CSS selector that's usable with the querySelector
331
+ * DOM API, because that API can't use attributes with prefixes, unless you just select for any prefix,
332
+ * which, in certain edge cases, could result in the incorrect element being selected. So, in order to
333
+ * to solve the breakage in the library and avoid using these attributes in the first place, we just
334
+ * remove any attributes with namespace prefixes. There's still enough "meat" left to properly
335
+ * generate a CSS selector that can reliably be used with querySelector. Afterwards, we add them back
336
+ * to the element, so we can, for example, still select the xml:lang for accessibility purposes.
337
+ */
338
+ const removedAttributes = new Map<HTMLElement, Attr[]>();
339
+ let currEl: HTMLElement | null = el;
340
+ while(currEl && currEl !== this.doc.body) {
341
+ for (let i = 0; i < currEl.attributes.length; i++) {
342
+ const attr = currEl.attributes[i];
343
+ if(attr.prefix) {
344
+ if(!removedAttributes.has(currEl)) {
345
+ removedAttributes.set(currEl, [attr])
346
+ } else {
347
+ removedAttributes.set(currEl, removedAttributes.get(currEl)!.concat([attr]))
348
+ }
349
+ currEl.attributes.removeNamedItem(attr.name);
350
+ }
351
+ }
352
+ currEl = currEl.parentElement;
353
+ }
354
+ const sel = getCssSelector(el, {
355
+ root: this.doc
356
+ });
357
+ removedAttributes.forEach((value, key) => value.forEach(v => key.attributes.setNamedItem(v)));
358
+ this.recipeCssSelectorCache.set(el, sel);
359
+ return sel;
360
+ }
361
+
362
+ // Implements NodeTraversor
363
+ head(node: Node, _depth: number) {
364
+ if (node.nodeType == Node.ELEMENT_NODE) {
365
+ const isBlock = !isInlineTag(node);
366
+ if (isBlock) {
367
+ // Flush text
368
+ this.flushText();
369
+
370
+ // Add blocks to breadcrumbs
371
+ this.breadcrumbs.push(node);
372
+ }
373
+
374
+ const nodeName = node.nodeName.toUpperCase();
375
+ if (nodeName === "BR") {
376
+ this.flushText();
377
+ } else if (
378
+ nodeName === "IMG" ||
379
+ nodeName === "AUDIO" ||
380
+ nodeName === "VIDEO"
381
+ ) {
382
+ this.flushText();
383
+
384
+ if (nodeName === "IMG") {
385
+ const el = node as HTMLImageElement;
386
+ const href = srcRelativeToHref(el, this.baseLocator.href);
387
+ let alt = el.getAttribute("alt");
388
+ if (!alt?.length)
389
+ // Try fallback to title if no alt
390
+ alt = el.getAttribute("title");
391
+ if (href != null) {
392
+ this.elementRecipes.push({
393
+ type: ElementRecipeType.ImageRecipe,
394
+ recipe: {
395
+ href,
396
+ caption: undefined, // FIXME: Get the caption from figcaption
397
+ cssSelectorNode: node,
398
+ attributes: alt?.length ? [new Attribute(
399
+ AttributeKeys.ACCESSIBILITY_LABEL,
400
+ alt
401
+ )] : []
402
+ }
403
+ });
404
+ }
405
+ } else { // Audio or Video
406
+ const el = node as HTMLAudioElement | HTMLVideoElement;
407
+ const href = srcRelativeToHref(el, this.baseLocator.href);
408
+ let link: Link | undefined;
409
+ if (href != null) {
410
+ link = new Link({
411
+ href
412
+ });
413
+ } else {
414
+ const sources = [...el.getElementsByTagName("source")].map(s => {
415
+ const src = srcRelativeToHref(s, this.baseLocator.href);
416
+ if (!src) return null;
417
+ return new Link({
418
+ href: src,
419
+ type: s.getAttribute("type") || undefined
420
+ });
421
+ }).filter(s => !!s) as Link[];
422
+
423
+ if (sources.length > 0) {
424
+ link = new Link({
425
+ href: sources[0].href,
426
+ type: sources[0].type,
427
+ alternates: sources.length > 1 ? new Links(sources.slice(1)) : undefined
428
+ });
429
+ }
430
+ }
431
+
432
+ if (link) {
433
+ if (nodeName === "AUDIO") {
434
+ this.elementRecipes.push({
435
+ type: ElementRecipeType.AudioRecipe,
436
+ recipe: {
437
+ cssSelectorNode: node,
438
+ link,
439
+ }
440
+ });
441
+ } else if (nodeName === "VIDEO") {
442
+ this.elementRecipes.push({
443
+ type: ElementRecipeType.VideoRecipe,
444
+ recipe: {
445
+ cssSelectorNode: node,
446
+ link,
447
+ }
448
+ });
449
+ }
450
+ }
451
+ }
452
+ }
453
+
454
+ if (isBlock) {
455
+ this.flushText();
456
+ }
457
+ }
458
+ }
459
+
460
+ // Implements NodeTraversor
461
+ tail(node: Node, _depth: number) {
462
+ if (node.nodeType === Node.TEXT_NODE && !isBlank(node.textContent)) {
463
+ const language = elementLanguage(node.parentElement);
464
+ if (this.currentLanguage !== language) {
465
+ this.flushSegment();
466
+ this.currentLanguage = language;
467
+ }
468
+
469
+ this.rawTextAcc += node.textContent;
470
+ this.textAcc = appendNormalizedWhitespace(this.textAcc, node.textContent!);
471
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
472
+ if (!isInlineTag(node)) {
473
+ if (
474
+ !this.breadcrumbs.length ||
475
+ this.breadcrumbs.at(-1) !== node
476
+ ) throw new Error("HTMLContentIterator: breadcrumbs mismatch"); // Kotlin does assert(breadcrumbs.last() == node) which throws
477
+ this.flushText();
478
+ this.breadcrumbs.pop();
479
+ }
480
+ }
481
+ }
482
+
483
+ flushText() {
484
+ this.flushSegment();
485
+
486
+ if (
487
+ this.startIndex === 0 &&
488
+ this.startElement &&
489
+ this.breadcrumbs.length &&
490
+ this.breadcrumbs.at(-1) === this.startElement
491
+ ) {
492
+ this.startIndex = this.elementRecipes.length;
493
+ }
494
+
495
+ if (!this.segmentRecipesAcc.length) return;
496
+
497
+ // Trim the end of the last segment's text to get a cleaner output for the TextElement.
498
+ // Only whitespaces between the segments are meaningful.
499
+ this.segmentRecipesAcc[this.segmentRecipesAcc.length - 1].text = trimUnicodeSpaceEnd(
500
+ this.segmentRecipesAcc[this.segmentRecipesAcc.length - 1].text
501
+ );
502
+
503
+ // Determine the role of the element
504
+ let bestRole = Body;
505
+ if (this.breadcrumbs.length > 0) {
506
+ const el = this.breadcrumbs.at(-1) as HTMLElement;
507
+ if(el.getAttributeNS("http://www.idpf.org/2007/ops", "type") === "footnote") {
508
+ bestRole = Footnote;
509
+ } else {
510
+ switch (el.nodeName.toUpperCase()) {
511
+ case "H1":
512
+ case "H2":
513
+ case "H3":
514
+ case "H4":
515
+ case "H5":
516
+ case "H6":
517
+ bestRole = new Heading(parseInt(el.nodeName[1]));
518
+ break;
519
+ case "BLOCKQUOTE":
520
+ case "Q":
521
+ bestRole = new TextQuote(
522
+ (el.getAttribute("cite")?.length || 0 > 0) ? new URL(el.getAttribute("cite")!) : undefined,
523
+ el.getAttribute("title") || undefined
524
+ );
525
+ break;
526
+ }
527
+ }
528
+ }
529
+
530
+ this.elementRecipes.push({
531
+ type: ElementRecipeType.TextRecipe,
532
+ recipe: {
533
+ text: this.elementRawTextAcc,
534
+ role: bestRole,
535
+ segments: [...this.segmentRecipesAcc],
536
+ cssSelectorNode: this.breadcrumbs.at(-1),
537
+ }
538
+ });
539
+ this.elementRawTextAcc = "";
540
+ this.segmentRecipesAcc = [];
541
+ }
542
+
543
+ flushSegment() {
544
+ let text = this.textAcc;
545
+ const trimmedText = trimUnicodeSpace(text);
546
+ if (!isBlank(trimmedText)) {
547
+ if (!this.segmentRecipesAcc.length) {
548
+ text = trimUnicodeSpaceStart(text)
549
+ const whitespaceSuffix = (text.length && isBlank(text[text.length - 1])) ? text[text.length - 1] : "";
550
+ text = trimmedText + whitespaceSuffix;
551
+ }
552
+
553
+ this.segmentRecipesAcc.push({
554
+ text,
555
+ attributes: this.currentLanguage ? [new Attribute(
556
+ AttributeKeys.LANGUAGE,
557
+ this.currentLanguage
558
+ )] : [],
559
+ beforeRange: this.wholeRawTextAcc ? [this.wholeRawTextAcc.length - this.beforeMaxLength, this.wholeRawTextAcc.length] : [0, 0],
560
+ cssSelectorNode: this.breadcrumbs.at(-1)
561
+ });
562
+ }
563
+
564
+ if (this.rawTextAcc.length) {
565
+ this.wholeRawTextAcc = (this.wholeRawTextAcc || "") + this.rawTextAcc;
566
+ this.elementRawTextAcc += this.rawTextAcc;
567
+ }
568
+ this.rawTextAcc = "";
569
+ this.textAcc = "";
570
+ }
571
+ }