@readium/shared 2.2.3 → 2.3.1

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 (163) hide show
  1. package/LICENSE +28 -0
  2. package/README.MD +8 -7
  3. package/dist/_virtual/preload-helper.js +1 -0
  4. package/dist/fetcher/Fetcher.js +1 -0
  5. package/dist/fetcher/HttpFetcher.js +1 -0
  6. package/dist/fetcher/Resource.js +1 -0
  7. package/dist/index.js +1 -4993
  8. package/dist/locales/publication-metadata/ar.json.js +1 -0
  9. package/dist/locales/publication-metadata/da.json.js +1 -0
  10. package/dist/locales/publication-metadata/el.json.js +1 -0
  11. package/dist/locales/publication-metadata/en.json.js +1 -0
  12. package/dist/locales/publication-metadata/es.json.js +1 -0
  13. package/dist/locales/publication-metadata/et.json.js +1 -0
  14. package/dist/locales/publication-metadata/fi.json.js +1 -0
  15. package/dist/locales/publication-metadata/fr.json.js +1 -0
  16. package/dist/locales/publication-metadata/it.json.js +1 -0
  17. package/dist/locales/publication-metadata/pl.json.js +1 -0
  18. package/dist/locales/publication-metadata/pt_PT.json.js +1 -0
  19. package/dist/locales/publication-metadata/sv.json.js +1 -0
  20. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/constants.js +1 -0
  21. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/index.js +1 -0
  22. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/selector-attribute.js +1 -0
  23. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/selector-class.js +1 -0
  24. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/selector-fallback.js +1 -0
  25. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/selector-id.js +1 -0
  26. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/selector-nth-child.js +1 -0
  27. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/selector-nth-of-type.js +1 -0
  28. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/selector-tag.js +1 -0
  29. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/types.js +1 -0
  30. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-cartesian.js +1 -0
  31. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-data.js +1 -0
  32. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-dom.js +1 -0
  33. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-element-data.js +1 -0
  34. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-iselement.js +1 -0
  35. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-messages.js +1 -0
  36. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-options.js +1 -0
  37. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-powerset.js +1 -0
  38. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-selectors.js +1 -0
  39. package/dist/node_modules/.pnpm/css-selector-generator@3.8.0/node_modules/css-selector-generator/esm/utilities-typescript.js +1 -0
  40. package/dist/opds/Acquisition.js +1 -0
  41. package/dist/opds/Availability.js +1 -0
  42. package/dist/opds/Copies.js +1 -0
  43. package/dist/opds/Holds.js +1 -0
  44. package/dist/opds/Price.js +1 -0
  45. package/dist/publication/AltIdentifier.js +1 -0
  46. package/dist/publication/BelongsTo.js +1 -0
  47. package/dist/publication/Contributor.js +1 -0
  48. package/dist/publication/GuidedNavigation.js +1 -0
  49. package/dist/publication/Layout.js +1 -0
  50. package/dist/publication/Link.js +1 -0
  51. package/dist/publication/LocalizedString.js +1 -0
  52. package/dist/publication/Locator.js +1 -0
  53. package/dist/publication/LocatorCollection.js +1 -0
  54. package/dist/publication/Manifest.js +1 -0
  55. package/dist/publication/Metadata.js +1 -0
  56. package/dist/publication/Profiles.js +1 -0
  57. package/dist/publication/Properties.js +1 -0
  58. package/dist/publication/Publication.js +1 -0
  59. package/dist/publication/PublicationCollection.js +1 -0
  60. package/dist/publication/ReadingProgression.js +1 -0
  61. package/dist/publication/Subject.js +1 -0
  62. package/dist/publication/TDM.js +1 -0
  63. package/dist/publication/accessibility/Accessibility.js +1 -0
  64. package/dist/publication/accessibility/AccessibilityMetadataDisplayGuide.js +1 -0
  65. package/dist/publication/accessibility/Localization.js +1 -0
  66. package/dist/publication/accessibility/SupportedLanguages.js +1 -0
  67. package/dist/publication/encryption/Encryption.js +1 -0
  68. package/dist/publication/encryption/Properties.js +1 -0
  69. package/dist/publication/encryption/index.js +1 -0
  70. package/dist/publication/epub/MediaOverlay.js +1 -0
  71. package/dist/publication/epub/Metadata.js +1 -0
  72. package/dist/publication/epub/Properties.js +1 -0
  73. package/dist/publication/epub/Publication.js +1 -0
  74. package/dist/publication/epub/index.js +1 -0
  75. package/dist/publication/html/DomRange.js +1 -0
  76. package/dist/publication/html/DomRangePoint.js +1 -0
  77. package/dist/publication/html/Locations.js +1 -0
  78. package/dist/publication/html/index.js +1 -0
  79. package/dist/publication/opds/Properties.js +1 -0
  80. package/dist/publication/opds/Publication.js +1 -0
  81. package/dist/publication/opds/index.js +1 -0
  82. package/dist/publication/services/content/Content.js +2 -0
  83. package/dist/publication/services/content/ContentTokenizer.js +1 -0
  84. package/dist/publication/services/content/Iterator.js +1 -0
  85. package/dist/publication/services/content/element/attributes.js +1 -0
  86. package/dist/publication/services/content/element/element.js +1 -0
  87. package/dist/publication/services/content/element/text_role.js +1 -0
  88. package/dist/publication/services/content/iterators/HTMLResourceContentIterator.js +1 -0
  89. package/dist/publication/services/content/iterators/PDFTextContentIterator.js +1 -0
  90. package/dist/publication/services/content/iterators/PublicationContentIterator.js +1 -0
  91. package/dist/publication/services/content/iterators/helpers.js +1 -0
  92. package/dist/publication/services/timeline/Timeline.js +1 -0
  93. package/dist/publication/services/timeline/audio.js +1 -0
  94. package/dist/publication/services/timeline/html.js +1 -0
  95. package/dist/publication/services/timeline/index.js +1 -0
  96. package/dist/util/JSONParse.js +1 -0
  97. package/dist/util/URITemplate.js +1 -0
  98. package/dist/util/mediatype/MediaType.js +1 -0
  99. package/dist/util/npt.js +1 -0
  100. package/dist/util/tokenizer/TextTokenizer.js +1 -0
  101. package/dist/util/tokenizer/tokenize-english/abbreviations.js +1 -0
  102. package/dist/util/tokenizer/tokenize-english/index.js +2 -0
  103. package/dist/util/tokenizer/tokenize-english/utils.js +1 -0
  104. package/dist/util/tokenizer/tokenize-text/index.js +1 -0
  105. package/dist/util/tokenizer/tokenize-text/tokens.js +1 -0
  106. package/package.json +34 -27
  107. package/src/locales/publication-metadata/ar.json +402 -0
  108. package/src/locales/publication-metadata/brh.json +1 -0
  109. package/src/locales/publication-metadata/da.json +310 -0
  110. package/src/locales/publication-metadata/el.json +310 -0
  111. package/src/locales/publication-metadata/en.json +310 -0
  112. package/src/locales/publication-metadata/es.json +333 -0
  113. package/src/locales/publication-metadata/et.json +310 -0
  114. package/src/locales/publication-metadata/fi.json +310 -0
  115. package/src/locales/publication-metadata/fr.json +333 -0
  116. package/src/locales/publication-metadata/he.json +9 -0
  117. package/src/locales/publication-metadata/it.json +333 -0
  118. package/src/locales/publication-metadata/lt.json +8 -0
  119. package/src/locales/publication-metadata/pl.json +333 -0
  120. package/src/locales/publication-metadata/pt_PT.json +329 -0
  121. package/src/locales/publication-metadata/sv.json +310 -0
  122. package/src/locales/publication-metadata/tr.json +51 -0
  123. package/src/locales/publication-metadata/uk.json +1 -0
  124. package/src/publication/Link.ts +2 -6
  125. package/src/publication/Locator.ts +21 -3
  126. package/src/publication/Manifest.ts +5 -7
  127. package/src/publication/Publication.ts +2 -1
  128. package/src/publication/accessibility/Localization.ts +12 -14
  129. package/src/publication/accessibility/SupportedLanguages.ts +1 -1
  130. package/src/publication/encryption/Properties.ts +3 -15
  131. package/src/publication/epub/Metadata.ts +6 -12
  132. package/src/publication/epub/Properties.ts +3 -13
  133. package/src/publication/epub/Publication.ts +18 -39
  134. package/src/publication/html/Locations.ts +54 -101
  135. package/src/publication/opds/Properties.ts +21 -66
  136. package/src/publication/opds/Publication.ts +3 -9
  137. package/src/publication/services/content/iterators/HTMLResourceContentIterator.ts +3 -1
  138. package/src/publication/services/timeline/Timeline.ts +305 -59
  139. package/src/publication/services/timeline/TimelineItem.ts +3 -3
  140. package/src/publication/services/timeline/audio.ts +31 -0
  141. package/src/publication/services/timeline/html.ts +5 -0
  142. package/src/publication/services/timeline/index.ts +12 -0
  143. package/src/util/npt.ts +19 -0
  144. package/types/src/publication/encryption/Properties.d.ts +2 -9
  145. package/types/src/publication/epub/Metadata.d.ts +3 -6
  146. package/types/src/publication/epub/Properties.d.ts +2 -10
  147. package/types/src/publication/epub/Publication.d.ts +7 -18
  148. package/types/src/publication/html/Locations.d.ts +15 -44
  149. package/types/src/publication/opds/Properties.d.ts +8 -36
  150. package/types/src/publication/opds/Publication.d.ts +2 -5
  151. package/types/src/publication/services/timeline/Timeline.d.ts +91 -12
  152. package/types/src/publication/services/timeline/TimelineItem.d.ts +3 -3
  153. package/types/src/publication/services/timeline/audio.d.ts +4 -0
  154. package/types/src/publication/services/timeline/html.d.ts +4 -0
  155. package/types/src/publication/services/timeline/index.d.ts +6 -0
  156. package/types/src/util/npt.d.ts +10 -0
  157. package/dist/ar-DyHX_uy2.js +0 -7
  158. package/dist/da-Dct0PS3E.js +0 -7
  159. package/dist/fr-C5HEel98.js +0 -7
  160. package/dist/index.umd.cjs +0 -3
  161. package/dist/it-DFOBoXGy.js +0 -7
  162. package/dist/pt_PT-Di3sVjze.js +0 -7
  163. 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 [Publication]
4
+ // OPDS extensions for Publication.
5
5
 
6
- declare module '../Publication' {
7
- export interface Publication {
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
- this.locator.locations.getCssSelector() ? doc.querySelector(this.locator.locations.getCssSelector()!) as Element : null,
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
- * No TOC hierarchy is reconstructed; that requires role context and is not yet
21
- * implemented. TOC entries whose href does not match any reading order item
22
- * are ignored.
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 both
25
- * title resolution and child collection may look. Level 1 = top-level TOC
26
- * entries; level 2 = their children; etc. `undefined` means no limit.
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(items: TimelineItem[], linkMap: Map<TimelineItem, Link>) {
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(publication: PublicationLike, options: { depth?: number } = {}): Timeline {
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?.time();
105
-
106
- let match: TimelineItem | undefined;
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;
117
189
  }
118
190
 
119
- if (!match) {
120
- match = this.flat.find(item => this.bareHrefFromItem(item) === href);
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;
198
+ }
199
+
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
- return match;
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.bareHrefFromItem(i) === bare);
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,52 @@ 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
+ * trying three fallbacks in order:
272
+ * 1. A direct match on `current`'s own link.
273
+ * 2. The nearest preceding toc entry within the same resource.
274
+ * 3. When no toc entry references the resource at all, the nearest
275
+ * preceding resource's toc entry.
276
+ */
277
+ tocEntryFor(current: TimelineItem): ContextualizedTocEntry | undefined {
278
+ const link = this.linkFor(current);
279
+ if (!link) return undefined;
280
+
281
+ const direct = this.findTocEntryByLink(this.contextualizedToc, link);
282
+ if (direct) return direct;
283
+
284
+ const nearest = this.nearestTocEntryForResource(link.href, current);
285
+ if (nearest) return nearest;
286
+
287
+ return this.previousResolvedTocEntry(link);
288
+ }
289
+
173
290
  private get flat(): TimelineItem[] {
174
291
  if (!this._flat) this._flat = this.flattenItems(this.items);
175
292
  return this._flat;
176
293
  }
177
294
 
295
+ /** All items flattened from _allItems, depth-independent — used by augment(). */
296
+ private get flatAll(): TimelineItem[] {
297
+ return this.flattenItems(this._allItems);
298
+ }
299
+
178
300
  // -------------------------------------------------------------------------
179
301
  // TOC title resolution
180
302
  // -------------------------------------------------------------------------
@@ -215,8 +337,17 @@ export class Timeline {
215
337
  const result: TimelineItem[] = [];
216
338
 
217
339
  for (const link of tocLinks) {
218
- if (Timeline.bareHref(link.href) === bare && link.title && !Timeline.isStartOfResource(link.href)) {
219
- const child: TimelineItem = { title: link.title, references: [link.href] };
340
+ const linkBare = Timeline.bareHref(link.href);
341
+ // Fragment-only hrefs (e.g. "#t=60" in single-track audio) have no bare href,
342
+ // so they are associated with the current resource.
343
+ const matchesBare = linkBare === bare || linkBare === '';
344
+ if (matchesBare && link.title && !Timeline.isStartOfResource(link.href)) {
345
+ // Prepend the resource href when the link uses a fragment-only reference.
346
+ const ref = linkBare === '' ? bare + link.href : link.href;
347
+ const child: TimelineItem = {
348
+ title: link.title,
349
+ references: [ref],
350
+ };
220
351
  linkMap.set(child, link);
221
352
  result.push(child);
222
353
  }
@@ -250,7 +381,8 @@ export class Timeline {
250
381
  const fragments: Link[] = [];
251
382
 
252
383
  for (const link of tocLinks) {
253
- if (Timeline.bareHref(link.href) === bare && link.title) {
384
+ const linkBare = Timeline.bareHref(link.href);
385
+ if ((linkBare === bare || linkBare === '') && link.title) {
254
386
  if (Timeline.isStartOfResource(link.href)) {
255
387
  atStart.push(link);
256
388
  } else {
@@ -282,6 +414,120 @@ export class Timeline {
282
414
  return match !== null && isNptStartOfResource(match[1]);
283
415
  }
284
416
 
417
+ // -------------------------------------------------------------------------
418
+ // Contextualized TOC
419
+ // -------------------------------------------------------------------------
420
+
421
+ private get linkToItem(): Map<Link, TimelineItem> {
422
+ if (!this._linkToItem) {
423
+ this._linkToItem = new Map();
424
+ for (const [item, link] of this.linkMap) this._linkToItem.set(link, item);
425
+ }
426
+ return this._linkToItem;
427
+ }
428
+
429
+ private buildTocEntries(links: Link[], maxDepth: number | undefined, currentDepth: number): ContextualizedTocEntry[] {
430
+ if (maxDepth !== undefined && currentDepth > maxDepth) return [];
431
+ return links.map(link => {
432
+ const item = this.linkToItem.get(link) ?? this.resourceStartItem(link.href);
433
+ const kids = link.children?.items?.length
434
+ ? this.buildTocEntries(link.children.items, maxDepth, currentDepth + 1)
435
+ : [];
436
+ return { ...this.entryFor(link, item), children: kids.length > 0 ? kids : undefined };
437
+ });
438
+ }
439
+
440
+ /** All toc Links flattened, respecting the same depth limit as `buildTocEntries`. */
441
+ private tocLinksFlat(links: Link[], maxDepth: number | undefined, currentDepth: number): Link[] {
442
+ if (maxDepth !== undefined && currentDepth > maxDepth) return [];
443
+ const result: Link[] = [];
444
+ for (const link of links) {
445
+ result.push(link);
446
+ if (link.children?.items?.length) {
447
+ result.push(...this.tocLinksFlat(link.children.items, maxDepth, currentDepth + 1));
448
+ }
449
+ }
450
+ return result;
451
+ }
452
+
453
+ private entryFor(link: Link, item: TimelineItem | undefined): ContextualizedTocEntry {
454
+ const isAudio = this._conformsTo.includes(Profile.AUDIOBOOK);
455
+ return {
456
+ link,
457
+ position: !isAudio && item?.position !== undefined ? String(item.position) : undefined,
458
+ timestamp: isAudio && item?.position !== undefined ? formatNptTime(item.position) : undefined,
459
+ };
460
+ }
461
+
462
+ private resourceStartItem(href: string): TimelineItem | undefined {
463
+ const bare = Timeline.bareHref(href);
464
+ return this._allItems.find(i => this.itemMatchesHref(i, bare));
465
+ }
466
+
467
+ private findTocEntryByLink(entries: ContextualizedTocEntry[], link: Link): ContextualizedTocEntry | undefined {
468
+ for (const entry of entries) {
469
+ if (entry.link === link) return entry;
470
+ if (entry.children) {
471
+ const found = this.findTocEntryByLink(entry.children, link);
472
+ if (found) return found;
473
+ }
474
+ }
475
+ return undefined;
476
+ }
477
+
478
+ /**
479
+ * Tier-2 fallback for `tocEntryFor`: among the toc entries that reference
480
+ * `href`'s own resource (e.g. chapter markers within one audio file),
481
+ * return the nearest one at or before `current`'s position/scroll.
482
+ */
483
+ private nearestTocEntryForResource(href: string, current: TimelineItem): ContextualizedTocEntry | undefined {
484
+ const bare = Timeline.bareHref(href);
485
+ // Re-walk with raw items (not the display-formatted TocEntry tree) so we can compare
486
+ // current.position/current.scroll directly against each candidate's source TimelineItem.
487
+ const candidates = this.tocLinksFlat(this.tocLinks, this._tocDepth, 1)
488
+ .filter(link => Timeline.bareHref(link.href) === bare)
489
+ .map(link => ({ link, item: this.linkToItem.get(link) ?? this.resourceStartItem(link.href) }));
490
+ if (candidates.length === 0) return undefined;
491
+
492
+ const isAudio = this._conformsTo.includes(Profile.AUDIOBOOK);
493
+ const currentValue = isAudio ? (current.position ?? 0) : (current.scroll ?? 0);
494
+ let best: { link: Link; item: TimelineItem | undefined } | undefined;
495
+ let bestValue = -Infinity;
496
+ for (const c of candidates) {
497
+ const v = isAudio ? (c.item?.position ?? 0) : (c.item?.scroll ?? 0);
498
+ if (v <= currentValue && v > bestValue) { bestValue = v; best = c; }
499
+ }
500
+ const chosen = best ?? candidates[0];
501
+ return this.findTocEntryByLink(this.contextualizedToc, chosen.link);
502
+ }
503
+
504
+ /**
505
+ * Tier-3 fallback for `tocEntryFor`: when no toc entry references
506
+ * `href`'s resource at all, walk backward through the reading order and
507
+ * return the nearest preceding resource's toc entry.
508
+ */
509
+ private previousResolvedTocEntry(link: Link): ContextualizedTocEntry | undefined {
510
+ const index = this._allItems.findIndex(item => this.linkFor(item) === link);
511
+ if (index === -1) return undefined;
512
+
513
+ for (let i = index - 1; i >= 0; i--) {
514
+ const precedingLink = this.linkFor(this._allItems[i]);
515
+ if (!precedingLink) continue;
516
+ const precedingBare = Timeline.bareHref(precedingLink.href);
517
+ const { atStart, fragments } = Timeline.collectTocCandidates(this.tocLinks, precedingBare, this._tocDepth, 1);
518
+ // Exclude fragment-only entries (bareHref "") — collectTocCandidates
519
+ // treats them as matching any bare href for single-track audio, but
520
+ // here they must belong to this specific preceding resource.
521
+ const ownAtStart = atStart.filter(l => Timeline.bareHref(l.href) === precedingBare);
522
+ const ownFragments = fragments.filter(l => Timeline.bareHref(l.href) === precedingBare);
523
+ const chosen = ownAtStart[0] ?? (ownFragments.length > 0 ? ownFragments[ownFragments.length - 1] : undefined);
524
+ if (!chosen) continue;
525
+ const entry = this.findTocEntryByLink(this.contextualizedToc, chosen);
526
+ if (entry) return entry;
527
+ }
528
+ return undefined;
529
+ }
530
+
285
531
  // -------------------------------------------------------------------------
286
532
  // Shared utilities
287
533
  // -------------------------------------------------------------------------
@@ -319,7 +565,8 @@ export class Timeline {
319
565
  const hashIndex = ref.indexOf("#");
320
566
  const refHref = hashIndex >= 0 ? ref.slice(0, hashIndex) : ref;
321
567
  const refFragment = hashIndex >= 0 ? ref.slice(hashIndex + 1) : undefined;
322
- const effectiveHref = refHref || this.bareHrefFromItem(item);
568
+ // Fragment-only reference (e.g. "#t=60") has no resource of its own — use the queried href.
569
+ const effectiveHref = refHref || href;
323
570
  if (effectiveHref !== href) continue;
324
571
  if (!refFragment) return undefined;
325
572
  const match = refFragment.match(/(?:^|&)t=([^&]+)/);
@@ -328,17 +575,12 @@ export class Timeline {
328
575
  return undefined;
329
576
  }
330
577
 
331
- private bareHrefFromItem(item: TimelineItem): string {
332
- return (item.references[0] ?? "").split("#")[0];
333
- }
334
-
335
- private timeFromItem(item: TimelineItem): number | undefined {
336
- const ref = item.references[0];
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;
578
+ private itemMatchesHref(item: TimelineItem, href: string): boolean {
579
+ return item.references.some(ref => {
580
+ const bare = ref.split("#")[0];
581
+ // Fragment-only reference (single-track audio) matches the current resource.
582
+ return bare === href || bare === '';
583
+ });
342
584
  }
343
585
 
344
586
  private ancestorPath(items: TimelineItem[], target: TimelineItem): TimelineItem[] | null {
@@ -352,6 +594,10 @@ export class Timeline {
352
594
  return null;
353
595
  }
354
596
 
597
+ private itemScrollPosition(item: TimelineItem): number | undefined {
598
+ return item.scroll;
599
+ }
600
+
355
601
  private static bareHref(href: string): string {
356
602
  return href.split("#")[0];
357
603
  }
@@ -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: string;
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
- /** Position number in the reading order context. */
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
+ }
@@ -0,0 +1,5 @@
1
+ import { Timeline, PublicationLike } from "./Timeline.ts";
2
+
3
+ export function buildHtmlTimeline(pub: PublicationLike, opts?: { depth?: number }): Timeline {
4
+ return Timeline.build(pub, opts);
5
+ }
@@ -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 module '../Properties' {
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 { MediaOverlay } from "./MediaOverlay.ts";
2
- declare module '../Metadata' {
3
- interface Metadata {
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;