@readium/shared 2.2.3 → 2.3.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/README.MD +8 -7
- package/dist/_virtual/preload-helper.js +1 -0
- package/dist/fetcher/Fetcher.js +1 -0
- package/dist/fetcher/HttpFetcher.js +1 -0
- package/dist/fetcher/Resource.js +1 -0
- package/dist/index.js +1 -4993
- package/dist/locales/publication-metadata/ar.json.js +1 -0
- package/dist/locales/publication-metadata/da.json.js +1 -0
- package/dist/locales/publication-metadata/el.json.js +1 -0
- package/dist/locales/publication-metadata/en.json.js +1 -0
- package/dist/locales/publication-metadata/es.json.js +1 -0
- package/dist/locales/publication-metadata/et.json.js +1 -0
- package/dist/locales/publication-metadata/fi.json.js +1 -0
- package/dist/locales/publication-metadata/fr.json.js +1 -0
- package/dist/locales/publication-metadata/it.json.js +1 -0
- package/dist/locales/publication-metadata/pl.json.js +1 -0
- package/dist/locales/publication-metadata/pt_PT.json.js +1 -0
- package/dist/locales/publication-metadata/sv.json.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/constants.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/index.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/selector-attribute.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/selector-class.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/selector-fallback.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/selector-id.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/selector-nth-child.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/selector-nth-of-type.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/selector-tag.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/types.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-cartesian.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-data.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-dom.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-element-data.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-iselement.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-messages.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-options.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-powerset.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-selectors.js +1 -0
- package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-typescript.js +1 -0
- package/dist/opds/Acquisition.js +1 -0
- package/dist/opds/Availability.js +1 -0
- package/dist/opds/Copies.js +1 -0
- package/dist/opds/Holds.js +1 -0
- package/dist/opds/Price.js +1 -0
- package/dist/publication/AltIdentifier.js +1 -0
- package/dist/publication/BelongsTo.js +1 -0
- package/dist/publication/Contributor.js +1 -0
- package/dist/publication/GuidedNavigation.js +1 -0
- package/dist/publication/Layout.js +1 -0
- package/dist/publication/Link.js +1 -0
- package/dist/publication/LocalizedString.js +1 -0
- package/dist/publication/Locator.js +1 -0
- package/dist/publication/LocatorCollection.js +1 -0
- package/dist/publication/Manifest.js +1 -0
- package/dist/publication/Metadata.js +1 -0
- package/dist/publication/Profiles.js +1 -0
- package/dist/publication/Properties.js +1 -0
- package/dist/publication/Publication.js +1 -0
- package/dist/publication/PublicationCollection.js +1 -0
- package/dist/publication/ReadingProgression.js +1 -0
- package/dist/publication/Subject.js +1 -0
- package/dist/publication/TDM.js +1 -0
- package/dist/publication/accessibility/Accessibility.js +1 -0
- package/dist/publication/accessibility/AccessibilityMetadataDisplayGuide.js +1 -0
- package/dist/publication/accessibility/Localization.js +1 -0
- package/dist/publication/accessibility/SupportedLanguages.js +1 -0
- package/dist/publication/encryption/Encryption.js +1 -0
- package/dist/publication/encryption/Properties.js +1 -0
- package/dist/publication/encryption/index.js +1 -0
- package/dist/publication/epub/MediaOverlay.js +1 -0
- package/dist/publication/epub/Metadata.js +1 -0
- package/dist/publication/epub/Properties.js +1 -0
- package/dist/publication/epub/Publication.js +1 -0
- package/dist/publication/epub/index.js +1 -0
- package/dist/publication/html/DomRange.js +1 -0
- package/dist/publication/html/DomRangePoint.js +1 -0
- package/dist/publication/html/Locations.js +1 -0
- package/dist/publication/html/index.js +1 -0
- package/dist/publication/opds/Properties.js +1 -0
- package/dist/publication/opds/Publication.js +1 -0
- package/dist/publication/opds/index.js +1 -0
- package/dist/publication/services/content/Content.js +2 -0
- package/dist/publication/services/content/ContentTokenizer.js +1 -0
- package/dist/publication/services/content/Iterator.js +1 -0
- package/dist/publication/services/content/element/attributes.js +1 -0
- package/dist/publication/services/content/element/element.js +1 -0
- package/dist/publication/services/content/element/text_role.js +1 -0
- package/dist/publication/services/content/iterators/HTMLResourceContentIterator.js +1 -0
- package/dist/publication/services/content/iterators/PDFTextContentIterator.js +1 -0
- package/dist/publication/services/content/iterators/PublicationContentIterator.js +1 -0
- package/dist/publication/services/content/iterators/helpers.js +1 -0
- package/dist/publication/services/timeline/Timeline.js +1 -0
- package/dist/publication/services/timeline/audio.js +1 -0
- package/dist/publication/services/timeline/html.js +1 -0
- package/dist/publication/services/timeline/index.js +1 -0
- package/dist/util/JSONParse.js +1 -0
- package/dist/util/URITemplate.js +1 -0
- package/dist/util/mediatype/MediaType.js +1 -0
- package/dist/util/npt.js +1 -0
- package/dist/util/tokenizer/TextTokenizer.js +1 -0
- package/dist/util/tokenizer/tokenize-english/abbreviations.js +1 -0
- package/dist/util/tokenizer/tokenize-english/index.js +2 -0
- package/dist/util/tokenizer/tokenize-english/utils.js +1 -0
- package/dist/util/tokenizer/tokenize-text/index.js +1 -0
- package/dist/util/tokenizer/tokenize-text/tokens.js +1 -0
- package/package.json +26 -19
- package/src/locales/publication-metadata/ar.json +402 -0
- package/src/locales/publication-metadata/brh.json +1 -0
- package/src/locales/publication-metadata/da.json +310 -0
- package/src/locales/publication-metadata/el.json +310 -0
- package/src/locales/publication-metadata/en.json +310 -0
- package/src/locales/publication-metadata/es.json +333 -0
- package/src/locales/publication-metadata/et.json +310 -0
- package/src/locales/publication-metadata/fi.json +310 -0
- package/src/locales/publication-metadata/fr.json +333 -0
- package/src/locales/publication-metadata/he.json +9 -0
- package/src/locales/publication-metadata/it.json +333 -0
- package/src/locales/publication-metadata/lt.json +8 -0
- package/src/locales/publication-metadata/pl.json +333 -0
- package/src/locales/publication-metadata/pt_PT.json +329 -0
- package/src/locales/publication-metadata/sv.json +310 -0
- package/src/locales/publication-metadata/tr.json +51 -0
- package/src/locales/publication-metadata/uk.json +1 -0
- package/src/publication/Link.ts +2 -6
- package/src/publication/Locator.ts +21 -3
- package/src/publication/Manifest.ts +5 -7
- package/src/publication/Publication.ts +2 -1
- package/src/publication/accessibility/Localization.ts +12 -14
- package/src/publication/accessibility/SupportedLanguages.ts +1 -1
- package/src/publication/encryption/Properties.ts +3 -15
- package/src/publication/epub/Metadata.ts +6 -12
- package/src/publication/epub/Properties.ts +3 -13
- package/src/publication/epub/Publication.ts +18 -39
- package/src/publication/html/Locations.ts +54 -101
- package/src/publication/opds/Properties.ts +21 -66
- package/src/publication/opds/Publication.ts +3 -9
- package/src/publication/services/content/iterators/HTMLResourceContentIterator.ts +3 -1
- package/src/publication/services/timeline/Timeline.ts +268 -59
- package/src/publication/services/timeline/TimelineItem.ts +3 -3
- package/src/publication/services/timeline/audio.ts +31 -0
- package/src/publication/services/timeline/html.ts +5 -0
- package/src/publication/services/timeline/index.ts +12 -0
- package/src/util/npt.ts +19 -0
- package/types/src/publication/encryption/Properties.d.ts +2 -9
- package/types/src/publication/epub/Metadata.d.ts +3 -6
- package/types/src/publication/epub/Properties.d.ts +2 -10
- package/types/src/publication/epub/Publication.d.ts +7 -18
- package/types/src/publication/html/Locations.d.ts +15 -44
- package/types/src/publication/opds/Properties.d.ts +8 -36
- package/types/src/publication/opds/Publication.d.ts +2 -5
- package/types/src/publication/services/timeline/Timeline.d.ts +78 -12
- package/types/src/publication/services/timeline/TimelineItem.d.ts +3 -3
- package/types/src/publication/services/timeline/audio.d.ts +4 -0
- package/types/src/publication/services/timeline/html.d.ts +4 -0
- package/types/src/publication/services/timeline/index.d.ts +6 -0
- package/types/src/util/npt.d.ts +10 -0
- package/dist/ar-DyHX_uy2.js +0 -7
- package/dist/da-Dct0PS3E.js +0 -7
- package/dist/fr-C5HEel98.js +0 -7
- package/dist/index.umd.cjs +0 -3
- package/dist/it-DFOBoXGy.js +0 -7
- package/dist/pt_PT-Di3sVjze.js +0 -7
- package/dist/sv-BfzAFsVN.js +0 -7
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import { Links } from '../Link.ts';
|
|
2
2
|
import { Publication } from '../Publication.ts';
|
|
3
3
|
|
|
4
|
-
// OPDS extensions for
|
|
4
|
+
// OPDS extensions for Publication.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
getImages(): Links | undefined;
|
|
9
|
-
}
|
|
6
|
+
export function getImages(pub: Publication): Links | undefined {
|
|
7
|
+
return pub.linksWithRole('images');
|
|
10
8
|
}
|
|
11
|
-
|
|
12
|
-
Publication.prototype.getImages = function(): Links | undefined {
|
|
13
|
-
return this.linksWithRole('images');
|
|
14
|
-
};
|
|
@@ -5,6 +5,7 @@ import { IllegalStateError, Iterator } from "../Iterator.ts";
|
|
|
5
5
|
import { Attribute, AttributeKeys, AudioElement, Body, ContentElement, Footnote, Heading, ImageElement, TextElement, TextQuote, TextRole, TextSegment, VideoElement } from "../element/index.ts";
|
|
6
6
|
import { appendNormalizedWhitespace, elementLanguage, isBlank, isInlineTag, srcRelativeToHref, trimUnicodeSpace, trimUnicodeSpaceEnd, trimUnicodeSpaceStart, trimmingTextLocator } from "./helpers.ts";
|
|
7
7
|
import { getCssSelector } from "css-selector-generator";
|
|
8
|
+
import { getCssSelector as locatorCssSelector } from "../../../html/Locations.ts";
|
|
8
9
|
|
|
9
10
|
interface ElementWithDelta {
|
|
10
11
|
element: ContentElement;
|
|
@@ -93,10 +94,11 @@ export class HTMLResourceContentIterator extends Iterator {
|
|
|
93
94
|
private async parseElements(): Promise<ParsedElementRecipes> {
|
|
94
95
|
const raw = await this.resource.readAsString();
|
|
95
96
|
const doc = (new DOMParser()).parseFromString(raw!, this.locator.type as DOMParserSupportedType);
|
|
97
|
+
const startSel = locatorCssSelector(this.locator.locations);
|
|
96
98
|
this.parser = new ContentParser(
|
|
97
99
|
doc,
|
|
98
100
|
this.locator,
|
|
99
|
-
|
|
101
|
+
startSel ? doc.querySelector(startSel) as Element : null,
|
|
100
102
|
this.beforeMaxLength
|
|
101
103
|
);
|
|
102
104
|
|
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import { Link, Links } from "../../Link.ts";
|
|
2
2
|
import { Locator } from "../../Locator.ts";
|
|
3
|
+
import { getHtmlId, getTime } from "../../html/Locations.ts";
|
|
4
|
+
import { Profile } from "../../Profiles.ts";
|
|
5
|
+
import { formatNptTime, isNptStartOfResource, parseNptTime } from "../../../util/npt.ts";
|
|
3
6
|
import { TimelineItem } from "./TimelineItem.ts";
|
|
4
|
-
import { isNptStartOfResource, parseNptTime } from "../../../util/npt.ts";
|
|
5
7
|
|
|
6
|
-
interface PublicationLike {
|
|
8
|
+
export interface PublicationLike {
|
|
7
9
|
toc?: Links;
|
|
8
10
|
readingOrder: Links;
|
|
11
|
+
metadata?: { conformsTo?: Profile[] };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A TOC entry, mirroring `publication.toc`'s authored hierarchy and
|
|
16
|
+
* contextualized with display-ready progression. When a publication has no
|
|
17
|
+
* `toc` at all, falls back to one flat entry per reading-order item.
|
|
18
|
+
*
|
|
19
|
+
* Exactly one of `position`/`timestamp` is populated, depending on the
|
|
20
|
+
* publication's profile.
|
|
21
|
+
*/
|
|
22
|
+
export interface ContextualizedTocEntry {
|
|
23
|
+
link: Link;
|
|
24
|
+
/** Display-ready label for non-audio profiles: a Positions List position for EPUB, a page number for PDF (e.g. "42"). */
|
|
25
|
+
position?: string;
|
|
26
|
+
/** Display-ready formatted time for audiobooks (e.g. "27:27"). */
|
|
27
|
+
timestamp?: string;
|
|
28
|
+
children?: ContextualizedTocEntry[];
|
|
9
29
|
}
|
|
10
30
|
|
|
11
31
|
/**
|
|
@@ -17,32 +37,61 @@ interface PublicationLike {
|
|
|
17
37
|
* 2. Populate flat children — all TOC fragment entries that reference the
|
|
18
38
|
* resource, collected depth-first in TOC declaration order.
|
|
19
39
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
40
|
+
* `contextualizedToc` returns the authored TOC hierarchy (see
|
|
41
|
+
* `ContextualizedTocEntry`), each entry contextualized with progression.
|
|
42
|
+
* `tocEntryFor(item)` maps a `TimelineItem` (e.g. from
|
|
43
|
+
* `locate()`) back to its entry in that hierarchy. TOC entries whose href
|
|
44
|
+
* does not match any reading order item are ignored.
|
|
23
45
|
*
|
|
24
|
-
* The `depth` build option limits how many levels deep into the TOC tree
|
|
25
|
-
* title resolution
|
|
26
|
-
* entries; level 2 = their children; etc.
|
|
46
|
+
* The `depth` build option limits how many levels deep into the TOC tree
|
|
47
|
+
* title resolution, child collection, and `contextualizedToc` traversal may
|
|
48
|
+
* look. Level 1 = top-level TOC entries; level 2 = their children; etc.
|
|
49
|
+
* `undefined` means no limit.
|
|
27
50
|
*/
|
|
28
51
|
export class Timeline {
|
|
29
52
|
private readonly _allItems: TimelineItem[];
|
|
30
53
|
private readonly linkMap: Map<TimelineItem, Link>;
|
|
54
|
+
private readonly _conformsTo: readonly Profile[];
|
|
55
|
+
private readonly tocLinks: Link[];
|
|
31
56
|
private _depth: number | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Depth used for `contextualizedToc` traversal. Tracks `_depth`, but is
|
|
59
|
+
* seeded independently from `Timeline.build()`'s `depth` option: unlike
|
|
60
|
+
* `_depth`, which only ever triggers `items`/`flat` re-trimming via the
|
|
61
|
+
* runtime `depth` setter, the TOC tree is walked fresh from `tocLinks`
|
|
62
|
+
* every time, so there's no equivalent "already baked in, don't reapply"
|
|
63
|
+
* hazard to avoid.
|
|
64
|
+
*/
|
|
65
|
+
private _tocDepth: number | undefined;
|
|
32
66
|
private _items: TimelineItem[] | undefined;
|
|
33
67
|
private _flat: TimelineItem[] | undefined;
|
|
68
|
+
private _toc: ContextualizedTocEntry[] | undefined;
|
|
69
|
+
private _linkToItem: Map<Link, TimelineItem> | undefined;
|
|
34
70
|
/** Populated when depth is set; maps cloned items from trimToDepth back to their Links. */
|
|
35
71
|
private _trimmedLinkMap: Map<TimelineItem, Link> = new Map();
|
|
36
72
|
|
|
37
|
-
constructor(
|
|
73
|
+
constructor(
|
|
74
|
+
items: TimelineItem[],
|
|
75
|
+
linkMap: Map<TimelineItem, Link>,
|
|
76
|
+
conformsTo: readonly Profile[] = [],
|
|
77
|
+
tocLinks: Link[] = [],
|
|
78
|
+
tocDepth: number | undefined = undefined,
|
|
79
|
+
) {
|
|
38
80
|
this._allItems = items;
|
|
39
81
|
this.linkMap = linkMap;
|
|
82
|
+
this._conformsTo = conformsTo;
|
|
83
|
+
this.tocLinks = tocLinks;
|
|
84
|
+
this._tocDepth = tocDepth;
|
|
40
85
|
}
|
|
41
86
|
|
|
42
|
-
static build(
|
|
87
|
+
static build(
|
|
88
|
+
publication: PublicationLike,
|
|
89
|
+
options: { depth?: number } = {},
|
|
90
|
+
): Timeline {
|
|
43
91
|
const tocLinks = publication.toc?.items ?? [];
|
|
44
92
|
const roLinks = publication.readingOrder.items;
|
|
45
93
|
const { depth } = options;
|
|
94
|
+
const conformsTo = publication.metadata?.conformsTo ?? [];
|
|
46
95
|
const linkMap = new Map<TimelineItem, Link>();
|
|
47
96
|
const items: TimelineItem[] = [];
|
|
48
97
|
|
|
@@ -52,8 +101,7 @@ export class Timeline {
|
|
|
52
101
|
|
|
53
102
|
const title =
|
|
54
103
|
ro.title ??
|
|
55
|
-
Timeline.findTitleInToc(tocLinks, bare, depth)
|
|
56
|
-
`Resource ${i + 1}`;
|
|
104
|
+
Timeline.findTitleInToc(tocLinks, bare, depth);
|
|
57
105
|
|
|
58
106
|
const tocChildren = Timeline.collectChildrenFromToc(tocLinks, bare, depth, 1, linkMap);
|
|
59
107
|
|
|
@@ -67,7 +115,25 @@ export class Timeline {
|
|
|
67
115
|
items.push(item);
|
|
68
116
|
}
|
|
69
117
|
|
|
70
|
-
return new Timeline(items, linkMap);
|
|
118
|
+
return new Timeline(items, linkMap, conformsTo, tocLinks, depth);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Augments all items in the timeline by applying the mapper's returned patch.
|
|
123
|
+
* The mapper receives the item and its original manifest Link, and returns
|
|
124
|
+
* a partial TimelineItem — any fields it sets will overwrite the item's
|
|
125
|
+
* current value. Use this to populate `position`, `scroll`, `role`, or any
|
|
126
|
+
* future TimelineItem fields from format-specific data.
|
|
127
|
+
*/
|
|
128
|
+
augment(mapper: (item: TimelineItem, link: Link) => Partial<TimelineItem>): void {
|
|
129
|
+
for (const item of this.flatAll) {
|
|
130
|
+
const link = this.linkFor(item);
|
|
131
|
+
if (!link) continue;
|
|
132
|
+
const patch = mapper(item, link);
|
|
133
|
+
if (patch.position !== undefined) item.position = patch.position;
|
|
134
|
+
if (patch.scroll !== undefined) item.scroll = patch.scroll;
|
|
135
|
+
if (patch.role !== undefined) item.role = patch.role;
|
|
136
|
+
}
|
|
71
137
|
}
|
|
72
138
|
|
|
73
139
|
/**
|
|
@@ -82,8 +148,10 @@ export class Timeline {
|
|
|
82
148
|
set depth(value: number | undefined) {
|
|
83
149
|
if (this._depth === value) return;
|
|
84
150
|
this._depth = value;
|
|
151
|
+
this._tocDepth = value;
|
|
85
152
|
this._items = undefined;
|
|
86
153
|
this._flat = undefined;
|
|
154
|
+
this._toc = undefined;
|
|
87
155
|
}
|
|
88
156
|
|
|
89
157
|
/** Top-level timeline items. Cached; invalidated when `depth` changes. */
|
|
@@ -101,26 +169,63 @@ export class Timeline {
|
|
|
101
169
|
|
|
102
170
|
locate(locator: Locator): TimelineItem | undefined {
|
|
103
171
|
const href = locator.href.split("#")[0];
|
|
104
|
-
const time = locator.locations
|
|
105
|
-
|
|
106
|
-
|
|
172
|
+
const time = getTime(locator.locations);
|
|
173
|
+
const htmlId = getHtmlId(locator.locations);
|
|
174
|
+
const progression = locator.locations.progression;
|
|
107
175
|
|
|
176
|
+
// Audio: best-match on t= start time.
|
|
108
177
|
if (time !== undefined) {
|
|
109
178
|
let bestTime = -Infinity;
|
|
179
|
+
let match: TimelineItem | undefined;
|
|
110
180
|
for (const item of this.flat) {
|
|
181
|
+
if (!this.itemMatchesHref(item, href)) continue;
|
|
111
182
|
const t = this.itemStartTime(item, href);
|
|
112
183
|
if (t !== undefined && t <= time && t > bestTime) {
|
|
113
184
|
bestTime = t;
|
|
114
185
|
match = item;
|
|
115
186
|
}
|
|
116
187
|
}
|
|
188
|
+
if (match) return match;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// EPUB: match on HTML ID fragment.
|
|
192
|
+
if (htmlId) {
|
|
193
|
+
const match = this.flat.find(item => {
|
|
194
|
+
if (!this.itemMatchesHref(item, href)) return false;
|
|
195
|
+
return item.references.some(ref => ref.split("#")[1] === htmlId);
|
|
196
|
+
});
|
|
197
|
+
if (match) return match;
|
|
117
198
|
}
|
|
118
199
|
|
|
119
|
-
|
|
120
|
-
|
|
200
|
+
// EPUB: best-match on scroll progression.
|
|
201
|
+
if (progression !== undefined) {
|
|
202
|
+
let bestScroll = -Infinity;
|
|
203
|
+
let match: TimelineItem | undefined;
|
|
204
|
+
for (const item of this.flat) {
|
|
205
|
+
if (!this.itemMatchesHref(item, href)) continue;
|
|
206
|
+
const s = this.itemScrollPosition(item);
|
|
207
|
+
if (s !== undefined && s <= progression && s > bestScroll) {
|
|
208
|
+
bestScroll = s;
|
|
209
|
+
match = item;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (match) return match;
|
|
213
|
+
|
|
214
|
+
// No scroll data resolved anywhere in this resource yet: guess a
|
|
215
|
+
// child by evenly dividing the fraction across its children,
|
|
216
|
+
// rather than naming the whole resource.
|
|
217
|
+
const container = this.items.find(i => this.itemMatchesHref(i, href));
|
|
218
|
+
if (container?.children?.length && !container.children.some(c => c.scroll !== undefined)) {
|
|
219
|
+
const index = Math.min(
|
|
220
|
+
Math.floor(progression * container.children.length),
|
|
221
|
+
container.children.length - 1,
|
|
222
|
+
);
|
|
223
|
+
return container.children[index];
|
|
224
|
+
}
|
|
121
225
|
}
|
|
122
226
|
|
|
123
|
-
|
|
227
|
+
// Fallback: bare href match.
|
|
228
|
+
return this.flat.find(item => this.itemMatchesHref(item, href));
|
|
124
229
|
}
|
|
125
230
|
|
|
126
231
|
adjacentTo(item: TimelineItem): { previous: TimelineItem | undefined; next: TimelineItem | undefined } {
|
|
@@ -133,35 +238,11 @@ export class Timeline {
|
|
|
133
238
|
|
|
134
239
|
segmentsForHref(href: string): TimelineItem[] {
|
|
135
240
|
const bare = href.split("#")[0];
|
|
136
|
-
const item = this.items.find(i => this.
|
|
241
|
+
const item = this.items.find(i => this.itemMatchesHref(i, bare));
|
|
137
242
|
if (!item) return [];
|
|
138
243
|
return item.children?.length ? item.children : [item];
|
|
139
244
|
}
|
|
140
245
|
|
|
141
|
-
itemAtProgression(href: string, progression: number, duration?: number): TimelineItem | undefined {
|
|
142
|
-
const bare = href.split("#")[0];
|
|
143
|
-
const item = this.items.find(i => this.bareHrefFromItem(i) === bare);
|
|
144
|
-
if (!item) return undefined;
|
|
145
|
-
if (!item.children?.length) return item;
|
|
146
|
-
|
|
147
|
-
if (duration !== undefined) {
|
|
148
|
-
const time = progression * duration;
|
|
149
|
-
let match: TimelineItem = item;
|
|
150
|
-
let bestTime = -Infinity;
|
|
151
|
-
for (const child of item.children) {
|
|
152
|
-
const t = this.timeFromItem(child);
|
|
153
|
-
if (t !== undefined && t <= time && t > bestTime) {
|
|
154
|
-
bestTime = t;
|
|
155
|
-
match = child;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return match;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const index = Math.min(Math.floor(progression * item.children.length), item.children.length - 1);
|
|
162
|
-
return item.children[index];
|
|
163
|
-
}
|
|
164
|
-
|
|
165
246
|
ancestors(item: TimelineItem): TimelineItem[] {
|
|
166
247
|
return this.ancestorPath(this.items, item) ?? [];
|
|
167
248
|
}
|
|
@@ -170,11 +251,47 @@ export class Timeline {
|
|
|
170
251
|
return this.linkMap.get(item) ?? this._trimmedLinkMap.get(item);
|
|
171
252
|
}
|
|
172
253
|
|
|
254
|
+
/**
|
|
255
|
+
* The authored TOC, contextualized with display-ready progression. Mirrors
|
|
256
|
+
* `publication.toc`'s authored hierarchy (respecting `depth`), falling
|
|
257
|
+
* back to one flat entry per reading-order item when there's no toc at
|
|
258
|
+
* all. Cached; invalidated when `depth` changes.
|
|
259
|
+
*/
|
|
260
|
+
get contextualizedToc(): ContextualizedTocEntry[] {
|
|
261
|
+
if (!this._toc) {
|
|
262
|
+
this._toc = this.tocLinks.length > 0
|
|
263
|
+
? this.buildTocEntries(this.tocLinks, this._tocDepth, 1)
|
|
264
|
+
: this._allItems.map(item => this.entryFor(this.linkFor(item)!, item));
|
|
265
|
+
}
|
|
266
|
+
return this._toc;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Maps a `TimelineItem` (typically from `locate()`) to its `ContextualizedTocEntry`:
|
|
271
|
+
* a direct match first, falling back to the nearest preceding toc entry
|
|
272
|
+
* for that resource when there's no exact match (e.g. a mid-resource
|
|
273
|
+
* audio position between chapter markers).
|
|
274
|
+
*/
|
|
275
|
+
tocEntryFor(current: TimelineItem): ContextualizedTocEntry | undefined {
|
|
276
|
+
const link = this.linkFor(current);
|
|
277
|
+
if (!link) return undefined;
|
|
278
|
+
|
|
279
|
+
const direct = this.findTocEntryByLink(this.contextualizedToc, link);
|
|
280
|
+
if (direct) return direct;
|
|
281
|
+
|
|
282
|
+
return this.nearestTocEntryForResource(link.href, current);
|
|
283
|
+
}
|
|
284
|
+
|
|
173
285
|
private get flat(): TimelineItem[] {
|
|
174
286
|
if (!this._flat) this._flat = this.flattenItems(this.items);
|
|
175
287
|
return this._flat;
|
|
176
288
|
}
|
|
177
289
|
|
|
290
|
+
/** All items flattened from _allItems, depth-independent — used by augment(). */
|
|
291
|
+
private get flatAll(): TimelineItem[] {
|
|
292
|
+
return this.flattenItems(this._allItems);
|
|
293
|
+
}
|
|
294
|
+
|
|
178
295
|
// -------------------------------------------------------------------------
|
|
179
296
|
// TOC title resolution
|
|
180
297
|
// -------------------------------------------------------------------------
|
|
@@ -215,8 +332,17 @@ export class Timeline {
|
|
|
215
332
|
const result: TimelineItem[] = [];
|
|
216
333
|
|
|
217
334
|
for (const link of tocLinks) {
|
|
218
|
-
|
|
219
|
-
|
|
335
|
+
const linkBare = Timeline.bareHref(link.href);
|
|
336
|
+
// Fragment-only hrefs (e.g. "#t=60" in single-track audio) have no bare href,
|
|
337
|
+
// so they are associated with the current resource.
|
|
338
|
+
const matchesBare = linkBare === bare || linkBare === '';
|
|
339
|
+
if (matchesBare && link.title && !Timeline.isStartOfResource(link.href)) {
|
|
340
|
+
// Prepend the resource href when the link uses a fragment-only reference.
|
|
341
|
+
const ref = linkBare === '' ? bare + link.href : link.href;
|
|
342
|
+
const child: TimelineItem = {
|
|
343
|
+
title: link.title,
|
|
344
|
+
references: [ref],
|
|
345
|
+
};
|
|
220
346
|
linkMap.set(child, link);
|
|
221
347
|
result.push(child);
|
|
222
348
|
}
|
|
@@ -250,7 +376,8 @@ export class Timeline {
|
|
|
250
376
|
const fragments: Link[] = [];
|
|
251
377
|
|
|
252
378
|
for (const link of tocLinks) {
|
|
253
|
-
|
|
379
|
+
const linkBare = Timeline.bareHref(link.href);
|
|
380
|
+
if ((linkBare === bare || linkBare === '') && link.title) {
|
|
254
381
|
if (Timeline.isStartOfResource(link.href)) {
|
|
255
382
|
atStart.push(link);
|
|
256
383
|
} else {
|
|
@@ -282,6 +409,88 @@ export class Timeline {
|
|
|
282
409
|
return match !== null && isNptStartOfResource(match[1]);
|
|
283
410
|
}
|
|
284
411
|
|
|
412
|
+
// -------------------------------------------------------------------------
|
|
413
|
+
// Contextualized TOC
|
|
414
|
+
// -------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
private get linkToItem(): Map<Link, TimelineItem> {
|
|
417
|
+
if (!this._linkToItem) {
|
|
418
|
+
this._linkToItem = new Map();
|
|
419
|
+
for (const [item, link] of this.linkMap) this._linkToItem.set(link, item);
|
|
420
|
+
}
|
|
421
|
+
return this._linkToItem;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private buildTocEntries(links: Link[], maxDepth: number | undefined, currentDepth: number): ContextualizedTocEntry[] {
|
|
425
|
+
if (maxDepth !== undefined && currentDepth > maxDepth) return [];
|
|
426
|
+
return links.map(link => {
|
|
427
|
+
const item = this.linkToItem.get(link) ?? this.resourceStartItem(link.href);
|
|
428
|
+
const kids = link.children?.items?.length
|
|
429
|
+
? this.buildTocEntries(link.children.items, maxDepth, currentDepth + 1)
|
|
430
|
+
: [];
|
|
431
|
+
return { ...this.entryFor(link, item), children: kids.length > 0 ? kids : undefined };
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** All toc Links flattened, respecting the same depth limit as `buildTocEntries`. */
|
|
436
|
+
private tocLinksFlat(links: Link[], maxDepth: number | undefined, currentDepth: number): Link[] {
|
|
437
|
+
if (maxDepth !== undefined && currentDepth > maxDepth) return [];
|
|
438
|
+
const result: Link[] = [];
|
|
439
|
+
for (const link of links) {
|
|
440
|
+
result.push(link);
|
|
441
|
+
if (link.children?.items?.length) {
|
|
442
|
+
result.push(...this.tocLinksFlat(link.children.items, maxDepth, currentDepth + 1));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return result;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private entryFor(link: Link, item: TimelineItem | undefined): ContextualizedTocEntry {
|
|
449
|
+
const isAudio = this._conformsTo.includes(Profile.AUDIOBOOK);
|
|
450
|
+
return {
|
|
451
|
+
link,
|
|
452
|
+
position: !isAudio && item?.position !== undefined ? String(item.position) : undefined,
|
|
453
|
+
timestamp: isAudio && item?.position !== undefined ? formatNptTime(item.position) : undefined,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private resourceStartItem(href: string): TimelineItem | undefined {
|
|
458
|
+
const bare = Timeline.bareHref(href);
|
|
459
|
+
return this._allItems.find(i => this.itemMatchesHref(i, bare));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private findTocEntryByLink(entries: ContextualizedTocEntry[], link: Link): ContextualizedTocEntry | undefined {
|
|
463
|
+
for (const entry of entries) {
|
|
464
|
+
if (entry.link === link) return entry;
|
|
465
|
+
if (entry.children) {
|
|
466
|
+
const found = this.findTocEntryByLink(entry.children, link);
|
|
467
|
+
if (found) return found;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private nearestTocEntryForResource(href: string, current: TimelineItem): ContextualizedTocEntry | undefined {
|
|
474
|
+
const bare = Timeline.bareHref(href);
|
|
475
|
+
// Re-walk with raw items (not the display-formatted TocEntry tree) so we can compare
|
|
476
|
+
// current.position/current.scroll directly against each candidate's source TimelineItem.
|
|
477
|
+
const candidates = this.tocLinksFlat(this.tocLinks, this._tocDepth, 1)
|
|
478
|
+
.filter(link => Timeline.bareHref(link.href) === bare)
|
|
479
|
+
.map(link => ({ link, item: this.linkToItem.get(link) ?? this.resourceStartItem(link.href) }));
|
|
480
|
+
if (candidates.length === 0) return undefined;
|
|
481
|
+
|
|
482
|
+
const isAudio = this._conformsTo.includes(Profile.AUDIOBOOK);
|
|
483
|
+
const currentValue = isAudio ? (current.position ?? 0) : (current.scroll ?? 0);
|
|
484
|
+
let best: { link: Link; item: TimelineItem | undefined } | undefined;
|
|
485
|
+
let bestValue = -Infinity;
|
|
486
|
+
for (const c of candidates) {
|
|
487
|
+
const v = isAudio ? (c.item?.position ?? 0) : (c.item?.scroll ?? 0);
|
|
488
|
+
if (v <= currentValue && v > bestValue) { bestValue = v; best = c; }
|
|
489
|
+
}
|
|
490
|
+
const chosen = best ?? candidates[0];
|
|
491
|
+
return this.findTocEntryByLink(this.contextualizedToc, chosen.link);
|
|
492
|
+
}
|
|
493
|
+
|
|
285
494
|
// -------------------------------------------------------------------------
|
|
286
495
|
// Shared utilities
|
|
287
496
|
// -------------------------------------------------------------------------
|
|
@@ -319,7 +528,8 @@ export class Timeline {
|
|
|
319
528
|
const hashIndex = ref.indexOf("#");
|
|
320
529
|
const refHref = hashIndex >= 0 ? ref.slice(0, hashIndex) : ref;
|
|
321
530
|
const refFragment = hashIndex >= 0 ? ref.slice(hashIndex + 1) : undefined;
|
|
322
|
-
|
|
531
|
+
// Fragment-only reference (e.g. "#t=60") has no resource of its own — use the queried href.
|
|
532
|
+
const effectiveHref = refHref || href;
|
|
323
533
|
if (effectiveHref !== href) continue;
|
|
324
534
|
if (!refFragment) return undefined;
|
|
325
535
|
const match = refFragment.match(/(?:^|&)t=([^&]+)/);
|
|
@@ -328,17 +538,12 @@ export class Timeline {
|
|
|
328
538
|
return undefined;
|
|
329
539
|
}
|
|
330
540
|
|
|
331
|
-
private
|
|
332
|
-
return
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
if (!ref) return undefined;
|
|
338
|
-
const fragment = ref.split("#")[1];
|
|
339
|
-
if (!fragment) return undefined;
|
|
340
|
-
const match = fragment.match(/(?:^|&)t=([^&]+)/);
|
|
341
|
-
return match ? parseNptTime(match[1]) : undefined;
|
|
541
|
+
private itemMatchesHref(item: TimelineItem, href: string): boolean {
|
|
542
|
+
return item.references.some(ref => {
|
|
543
|
+
const bare = ref.split("#")[0];
|
|
544
|
+
// Fragment-only reference (single-track audio) matches the current resource.
|
|
545
|
+
return bare === href || bare === '';
|
|
546
|
+
});
|
|
342
547
|
}
|
|
343
548
|
|
|
344
549
|
private ancestorPath(items: TimelineItem[], target: TimelineItem): TimelineItem[] | null {
|
|
@@ -352,6 +557,10 @@ export class Timeline {
|
|
|
352
557
|
return null;
|
|
353
558
|
}
|
|
354
559
|
|
|
560
|
+
private itemScrollPosition(item: TimelineItem): number | undefined {
|
|
561
|
+
return item.scroll;
|
|
562
|
+
}
|
|
563
|
+
|
|
355
564
|
private static bareHref(href: string): string {
|
|
356
565
|
return href.split("#")[0];
|
|
357
566
|
}
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* show previous/next as chapter titles, group search results by chapter, etc.
|
|
7
7
|
*/
|
|
8
8
|
export interface TimelineItem {
|
|
9
|
-
/** Display title of this entry. */
|
|
10
|
-
title
|
|
9
|
+
/** Display title of this entry, when one could be derived. */
|
|
10
|
+
title?: string;
|
|
11
11
|
/**
|
|
12
12
|
* References as hrefs with optional fragments.
|
|
13
13
|
* e.g. ["track1.mp3#t=60"] for audio, ["chapter1.html"] for EPUB, ["#page=6"] for PDF.
|
|
@@ -15,7 +15,7 @@ export interface TimelineItem {
|
|
|
15
15
|
references: string[];
|
|
16
16
|
/** Structural roles of this entry, e.g. ["chapter"], ["part"]. */
|
|
17
17
|
role?: string[];
|
|
18
|
-
/**
|
|
18
|
+
/** Raw position: book-global seconds for audio, a Positions List position for EPUB, a page number for PDF. */
|
|
19
19
|
position?: number;
|
|
20
20
|
/** Scroll progression within the resource (0 to 1), for entries that start mid-way in a resource. */
|
|
21
21
|
scroll?: number;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { parseNptTime } from "../../../util/npt.ts";
|
|
2
|
+
import { Timeline, PublicationLike } from "./Timeline.ts";
|
|
3
|
+
|
|
4
|
+
export function buildAudioTimeline(pub: PublicationLike, opts?: { depth?: number }): Timeline {
|
|
5
|
+
const t = Timeline.build(pub, opts);
|
|
6
|
+
const ro = pub.readingOrder.items;
|
|
7
|
+
const canLabel = ro.length <= 1 || ro.every(l => l.duration !== undefined);
|
|
8
|
+
if (!canLabel) return t;
|
|
9
|
+
|
|
10
|
+
let offset = 0;
|
|
11
|
+
const offsets = new Map(ro.map(l => {
|
|
12
|
+
const o = offset;
|
|
13
|
+
offset += l.duration ?? 0;
|
|
14
|
+
return [l.href, o];
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
t.augment((_item, link) => {
|
|
18
|
+
const href = link.href;
|
|
19
|
+
const hashIndex = href.indexOf('#');
|
|
20
|
+
const bare = hashIndex >= 0 ? href.slice(0, hashIndex) : href;
|
|
21
|
+
const fragment = hashIndex >= 0 ? href.slice(hashIndex + 1) : undefined;
|
|
22
|
+
const trackOffset = offsets.get(bare) ?? 0;
|
|
23
|
+
const tMatch = fragment?.match(/(?:^|&)t=([^&]+)/);
|
|
24
|
+
const seconds = tMatch ? parseNptTime(tMatch[1]) : undefined;
|
|
25
|
+
return {
|
|
26
|
+
position: trackOffset + (seconds ?? 0),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return t;
|
|
31
|
+
}
|
|
@@ -1,2 +1,14 @@
|
|
|
1
|
+
import { Profile } from "../../Profiles.ts";
|
|
2
|
+
import { PublicationLike, Timeline } from "./Timeline.ts";
|
|
3
|
+
import { buildAudioTimeline } from "./audio.ts";
|
|
4
|
+
import { buildHtmlTimeline } from "./html.ts";
|
|
5
|
+
|
|
1
6
|
export * from './Timeline.ts';
|
|
2
7
|
export * from './TimelineItem.ts';
|
|
8
|
+
export { buildHtmlTimeline } from './html.ts';
|
|
9
|
+
export { buildAudioTimeline } from './audio.ts';
|
|
10
|
+
|
|
11
|
+
export function buildTimeline(pub: PublicationLike, options?: { depth?: number }): Timeline {
|
|
12
|
+
const isAudio = pub.metadata?.conformsTo?.includes(Profile.AUDIOBOOK) ?? false;
|
|
13
|
+
return isAudio ? buildAudioTimeline(pub, options) : buildHtmlTimeline(pub, options);
|
|
14
|
+
}
|
package/src/util/npt.ts
CHANGED
|
@@ -49,3 +49,22 @@ export function isNptStartOfResource(raw: string): boolean {
|
|
|
49
49
|
const t = parseNptTime(raw);
|
|
50
50
|
return t !== undefined && t === 0;
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Formats a duration in seconds as a human-readable string suitable for
|
|
55
|
+
* display in a Table of Contents or progress indicator.
|
|
56
|
+
*
|
|
57
|
+
* Outputs "H:MM:SS" when hours > 0, otherwise "M:SS".
|
|
58
|
+
* Minutes and seconds are always zero-padded to two digits.
|
|
59
|
+
*
|
|
60
|
+
* Examples: 0 → "0:00", 90 → "1:30", 3661 → "1:01:01"
|
|
61
|
+
*/
|
|
62
|
+
export function formatNptTime(seconds: number): string {
|
|
63
|
+
const totalSeconds = Math.floor(seconds);
|
|
64
|
+
const h = Math.floor(totalSeconds / 3600);
|
|
65
|
+
const m = Math.floor((totalSeconds % 3600) / 60);
|
|
66
|
+
const s = totalSeconds % 60;
|
|
67
|
+
const mm = String(m).padStart(2, "0");
|
|
68
|
+
const ss = String(s).padStart(2, "0");
|
|
69
|
+
return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`;
|
|
70
|
+
}
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
+
import { Properties } from '../Properties.ts';
|
|
1
2
|
import { Encryption } from './Encryption.ts';
|
|
2
|
-
declare
|
|
3
|
-
interface Properties {
|
|
4
|
-
/**
|
|
5
|
-
* Indicates that a resource is encrypted/obfuscated and provides relevant information for
|
|
6
|
-
* decryption.
|
|
7
|
-
*/
|
|
8
|
-
encryption: Encryption | undefined;
|
|
9
|
-
}
|
|
10
|
-
}
|
|
3
|
+
export declare function getEncryption(properties: Properties): Encryption | undefined;
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
getMediaOverlay(): MediaOverlay | undefined;
|
|
5
|
-
}
|
|
6
|
-
}
|
|
1
|
+
import { Metadata } from '../Metadata.ts';
|
|
2
|
+
import { MediaOverlay } from './MediaOverlay.ts';
|
|
3
|
+
export declare function getMediaOverlay(metadata: Metadata): MediaOverlay | undefined;
|
|
@@ -1,10 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Identifies content contained in the linked resource, that cannot be strictly identified using a
|
|
5
|
-
* media type.
|
|
6
|
-
*/
|
|
7
|
-
getContains(): Set<string> | undefined;
|
|
8
|
-
}
|
|
9
|
-
}
|
|
10
|
-
export {};
|
|
1
|
+
import { Properties } from '../Properties.ts';
|
|
2
|
+
export declare function getContains(properties: Properties): Set<string>;
|