@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.
- package/LICENSE +28 -0
- package/README.MD +27 -0
- package/dist/index.js +2536 -0
- package/dist/index.umd.cjs +2 -0
- package/package.json +73 -0
- package/src/fetcher/Fetcher.ts +37 -0
- package/src/fetcher/HttpFetcher.ts +122 -0
- package/src/fetcher/Resource.ts +37 -0
- package/src/fetcher/index.ts +2 -0
- package/src/index.ts +4 -0
- package/src/opds/Acquisition.ts +54 -0
- package/src/opds/Availability.ts +61 -0
- package/src/opds/Copies.ts +43 -0
- package/src/opds/Holds.ts +43 -0
- package/src/opds/Price.ts +51 -0
- package/src/opds/index.ts +5 -0
- package/src/publication/BelongsTo.ts +51 -0
- package/src/publication/Contributor.ts +129 -0
- package/src/publication/GuidedNavigation.ts +176 -0
- package/src/publication/Link.ts +332 -0
- package/src/publication/LocalizedString.ts +74 -0
- package/src/publication/Locator.ts +225 -0
- package/src/publication/LocatorCollection.ts +133 -0
- package/src/publication/Manifest.ts +256 -0
- package/src/publication/Metadata.ts +329 -0
- package/src/publication/Properties.ts +44 -0
- package/src/publication/Publication.ts +148 -0
- package/src/publication/PublicationCollection.ts +144 -0
- package/src/publication/ReadingProgression.ts +28 -0
- package/src/publication/Subject.ts +115 -0
- package/src/publication/encryption/Encryption.ts +68 -0
- package/src/publication/encryption/Properties.ts +20 -0
- package/src/publication/encryption/index.ts +2 -0
- package/src/publication/epub/EPUBLayout.ts +10 -0
- package/src/publication/epub/Presentation.ts +16 -0
- package/src/publication/epub/Properties.ts +28 -0
- package/src/publication/epub/Publication.ts +51 -0
- package/src/publication/epub/index.ts +4 -0
- package/src/publication/html/DomRange.ts +63 -0
- package/src/publication/html/DomRangePoint.ts +64 -0
- package/src/publication/html/Locations.ts +129 -0
- package/src/publication/html/index.ts +3 -0
- package/src/publication/index.ts +19 -0
- package/src/publication/opds/Properties.ts +84 -0
- package/src/publication/opds/Publication.ts +14 -0
- package/src/publication/opds/index.ts +2 -0
- package/src/publication/presentation/Metadata.ts +19 -0
- package/src/publication/presentation/Presentation.ts +155 -0
- package/src/publication/presentation/Properties.ts +70 -0
- package/src/publication/presentation/index.ts +3 -0
- package/src/publication/services/content/Content.ts +36 -0
- package/src/publication/services/content/ContentTokenizer.ts +62 -0
- package/src/publication/services/content/Iterator.ts +46 -0
- package/src/publication/services/content/element/attributes.ts +44 -0
- package/src/publication/services/content/element/element.ts +118 -0
- package/src/publication/services/content/element/index.ts +3 -0
- package/src/publication/services/content/element/text_role.ts +60 -0
- package/src/publication/services/content/index.ts +5 -0
- package/src/publication/services/content/iterators/HTMLResourceContentIterator.ts +571 -0
- package/src/publication/services/content/iterators/PDFTextContentIterator.ts +184 -0
- package/src/publication/services/content/iterators/PublicationContentIterator.ts +144 -0
- package/src/publication/services/content/iterators/helpers.ts +146 -0
- package/src/publication/services/content/iterators/index.ts +3 -0
- package/src/publication/services/index.ts +1 -0
- package/src/util/JSONParse.ts +31 -0
- package/src/util/Language.ts +5 -0
- package/src/util/URITemplate.ts +83 -0
- package/src/util/index.ts +5 -0
- package/src/util/mediatype/MediaType.ts +563 -0
- package/src/util/mediatype/index.ts +1 -0
- package/src/util/tokenizer/TextTokenizer.ts +126 -0
- package/src/util/tokenizer/Tokenizer.ts +8 -0
- package/src/util/tokenizer/index.ts +2 -0
- package/src/util/tokenizer/tokenize-english/README.md +7 -0
- package/src/util/tokenizer/tokenize-english/abbreviations.js +52 -0
- package/src/util/tokenizer/tokenize-english/index.d.ts +3 -0
- package/src/util/tokenizer/tokenize-english/index.js +243 -0
- package/src/util/tokenizer/tokenize-english/utils.js +16 -0
- package/src/util/tokenizer/tokenize-text/README.MD +6 -0
- package/src/util/tokenizer/tokenize-text/index.d.ts +52 -0
- package/src/util/tokenizer/tokenize-text/index.js +256 -0
- package/src/util/tokenizer/tokenize-text/tokens.js +100 -0
- package/types/src/fetcher/Fetcher.d.ts +27 -0
- package/types/src/fetcher/HttpFetcher.d.ts +28 -0
- package/types/src/fetcher/Resource.d.ts +15 -0
- package/types/src/fetcher/index.d.ts +2 -0
- package/types/src/index.d.ts +4 -0
- package/types/src/opds/Acquisition.d.ts +26 -0
- package/types/src/opds/Availability.d.ts +32 -0
- package/types/src/opds/Copies.d.ts +22 -0
- package/types/src/opds/Holds.d.ts +22 -0
- package/types/src/opds/Price.d.ts +29 -0
- package/types/src/opds/index.d.ts +5 -0
- package/types/src/publication/BelongsTo.d.ts +23 -0
- package/types/src/publication/Contributor.d.ts +60 -0
- package/types/src/publication/GuidedNavigation.d.ts +70 -0
- package/types/src/publication/Link.d.ts +128 -0
- package/types/src/publication/LocalizedString.d.ts +48 -0
- package/types/src/publication/Locator.d.ts +101 -0
- package/types/src/publication/LocatorCollection.d.ts +54 -0
- package/types/src/publication/Manifest.d.ts +59 -0
- package/types/src/publication/Metadata.d.ts +101 -0
- package/types/src/publication/Properties.d.ts +28 -0
- package/types/src/publication/Publication.d.ts +53 -0
- package/types/src/publication/PublicationCollection.d.ts +34 -0
- package/types/src/publication/ReadingProgression.d.ts +9 -0
- package/types/src/publication/Subject.d.ts +56 -0
- package/types/src/publication/encryption/Encryption.d.ts +33 -0
- package/types/src/publication/encryption/Properties.d.ts +10 -0
- package/types/src/publication/encryption/index.d.ts +2 -0
- package/types/src/publication/epub/EPUBLayout.d.ts +5 -0
- package/types/src/publication/epub/Presentation.d.ts +7 -0
- package/types/src/publication/epub/Properties.d.ts +14 -0
- package/types/src/publication/epub/Publication.d.ts +19 -0
- package/types/src/publication/epub/index.d.ts +4 -0
- package/types/src/publication/html/DomRange.d.ts +40 -0
- package/types/src/publication/html/DomRangePoint.d.ts +36 -0
- package/types/src/publication/html/Locations.d.ts +45 -0
- package/types/src/publication/html/index.d.ts +3 -0
- package/types/src/publication/index.d.ts +19 -0
- package/types/src/publication/opds/Properties.d.ts +38 -0
- package/types/src/publication/opds/Publication.d.ts +6 -0
- package/types/src/publication/opds/index.d.ts +2 -0
- package/types/src/publication/presentation/Metadata.d.ts +6 -0
- package/types/src/publication/presentation/Presentation.d.ts +95 -0
- package/types/src/publication/presentation/Properties.d.ts +32 -0
- package/types/src/publication/presentation/index.d.ts +3 -0
- package/types/src/publication/services/content/Content.d.ts +20 -0
- package/types/src/publication/services/content/ContentTokenizer.d.ts +19 -0
- package/types/src/publication/services/content/Iterator.d.ts +38 -0
- package/types/src/publication/services/content/element/attributes.d.ts +31 -0
- package/types/src/publication/services/content/element/element.d.ts +107 -0
- package/types/src/publication/services/content/element/index.d.ts +3 -0
- package/types/src/publication/services/content/element/text_role.d.ts +53 -0
- package/types/src/publication/services/content/index.d.ts +5 -0
- package/types/src/publication/services/content/iterators/HTMLResourceContentIterator.d.ts +30 -0
- package/types/src/publication/services/content/iterators/PDFTextContentIterator.d.ts +60 -0
- package/types/src/publication/services/content/iterators/PublicationContentIterator.d.ts +59 -0
- package/types/src/publication/services/content/iterators/helpers.d.ts +10 -0
- package/types/src/publication/services/content/iterators/index.d.ts +3 -0
- package/types/src/publication/services/index.d.ts +1 -0
- package/types/src/util/JSONParse.d.ts +11 -0
- package/types/src/util/Language.d.ts +1 -0
- package/types/src/util/URITemplate.d.ts +24 -0
- package/types/src/util/index.d.ts +5 -0
- package/types/src/util/mediatype/MediaType.d.ts +148 -0
- package/types/src/util/mediatype/index.d.ts +1 -0
- package/types/src/util/tokenizer/TextTokenizer.d.ts +39 -0
- package/types/src/util/tokenizer/Tokenizer.d.ts +6 -0
- 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,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
|
+
}
|