Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions navigator/CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.7.2] – 2026-07-16

### Fixed

- `Timeline.tocEntryFor()` (via `@readium/shared`) now falls back to the nearest preceding resource's toc entry when a resource has no toc entry of its own — the case where one toc entry spans several consecutive reading-order resources ([#242](https://github.com/readium/ts-toolkit/pull/242))

## [2.7.1] – 2026-07-16

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion navigator/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@readium/navigator",
"version": "2.7.1",
"version": "2.7.2",
"type": "module",
"description": "Next generation SDK for publications in Web Apps",
"author": "readium",
Expand Down
6 changes: 6 additions & 0 deletions shared/CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.3.1] – 2026-07-16

### Fixed

- `Timeline.tocEntryFor()` now falls back to the nearest preceding resource's toc entry when a resource has no toc entry of its own — the case where one toc entry spans several consecutive reading-order resources ([#242](https://github.com/readium/ts-toolkit/pull/242))

## [2.3.0] – 2026-07-16

### Added
Expand Down
2 changes: 1 addition & 1 deletion shared/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@readium/shared",
"version": "2.3.0",
"version": "2.3.1",
"type": "module",
"description": "Shared models to be used across other Readium projects and implementations in Typescript",
"author": "readium",
Expand Down
47 changes: 42 additions & 5 deletions shared/src/publication/services/timeline/Timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,10 +267,12 @@ export class Timeline {
}

/**
* Maps a `TimelineItem` (typically from `locate()`) to its `ContextualizedTocEntry`:
* a direct match first, falling back to the nearest preceding toc entry
* for that resource when there's no exact match (e.g. a mid-resource
* audio position between chapter markers).
* Maps a `TimelineItem` (typically from `locate()`) to its `ContextualizedTocEntry`,
* trying three fallbacks in order:
* 1. A direct match on `current`'s own link.
* 2. The nearest preceding toc entry within the same resource.
* 3. When no toc entry references the resource at all, the nearest
* preceding resource's toc entry.
*/
tocEntryFor(current: TimelineItem): ContextualizedTocEntry | undefined {
const link = this.linkFor(current);
Expand All @@ -279,7 +281,10 @@ export class Timeline {
const direct = this.findTocEntryByLink(this.contextualizedToc, link);
if (direct) return direct;

return this.nearestTocEntryForResource(link.href, current);
const nearest = this.nearestTocEntryForResource(link.href, current);
if (nearest) return nearest;

return this.previousResolvedTocEntry(link);
}

private get flat(): TimelineItem[] {
Expand Down Expand Up @@ -470,6 +475,11 @@ export class Timeline {
return undefined;
}

/**
* Tier-2 fallback for `tocEntryFor`: among the toc entries that reference
* `href`'s own resource (e.g. chapter markers within one audio file),
* return the nearest one at or before `current`'s position/scroll.
*/
private nearestTocEntryForResource(href: string, current: TimelineItem): ContextualizedTocEntry | undefined {
const bare = Timeline.bareHref(href);
// Re-walk with raw items (not the display-formatted TocEntry tree) so we can compare
Expand All @@ -491,6 +501,33 @@ export class Timeline {
return this.findTocEntryByLink(this.contextualizedToc, chosen.link);
}

/**
* Tier-3 fallback for `tocEntryFor`: when no toc entry references
* `href`'s resource at all, walk backward through the reading order and
* return the nearest preceding resource's toc entry.
*/
private previousResolvedTocEntry(link: Link): ContextualizedTocEntry | undefined {
const index = this._allItems.findIndex(item => this.linkFor(item) === link);
if (index === -1) return undefined;

for (let i = index - 1; i >= 0; i--) {
const precedingLink = this.linkFor(this._allItems[i]);
if (!precedingLink) continue;
const precedingBare = Timeline.bareHref(precedingLink.href);
const { atStart, fragments } = Timeline.collectTocCandidates(this.tocLinks, precedingBare, this._tocDepth, 1);
// Exclude fragment-only entries (bareHref "") — collectTocCandidates
// treats them as matching any bare href for single-track audio, but
// here they must belong to this specific preceding resource.
const ownAtStart = atStart.filter(l => Timeline.bareHref(l.href) === precedingBare);
const ownFragments = fragments.filter(l => Timeline.bareHref(l.href) === precedingBare);
const chosen = ownAtStart[0] ?? (ownFragments.length > 0 ? ownFragments[ownFragments.length - 1] : undefined);
if (!chosen) continue;
const entry = this.findTocEntryByLink(this.contextualizedToc, chosen);
if (entry) return entry;
}
return undefined;
}

// -------------------------------------------------------------------------
// Shared utilities
// -------------------------------------------------------------------------
Expand Down
89 changes: 85 additions & 4 deletions shared/test/Timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,7 @@ describe('Timeline – contextualizedToc', () => {
// ---------------------------------------------------------------------------

describe('Timeline – tocEntryFor', () => {
it('10.1 direct match: a TOC-derived child item resolves to its own toc entry', () => {
it('10.1 tier-1 direct match: a TOC-derived child item resolves to its own toc entry', () => {
const t = build(
ro({ href: 'chapter1.html', title: 'Chapter 1' }),
toc({ href: 'chapter1.html#intro', title: 'Introduction' }),
Expand All @@ -786,7 +786,7 @@ describe('Timeline – tocEntryFor', () => {
expect(t.tocEntryFor(child)?.link.title).toBe('Introduction');
});

it('10.2 fallback match: a plain reading-order item resolves to the nearest preceding toc entry by scroll', () => {
it('10.2 tier-2 fallback: a plain reading-order item resolves to the nearest preceding toc entry by scroll', () => {
const t = build(
ro({ href: 'chapter1.html', title: 'Chapter 1' }),
toc(
Expand All @@ -802,7 +802,88 @@ describe('Timeline – tocEntryFor', () => {
expect(t.tocEntryFor(t.items[0])?.link.title).toBe('Section 2');
});

it('10.3 no match: an item unknown to the timeline resolves to undefined', () => {
it('10.3 tier-3 fallback: a resource with no TOC entries of its own resolves to the preceding resource\'s toc entry', () => {
// One TOC entry spans three consecutive RO resources — only the first is referenced.
const t = build(
ro(
{ href: 'chapter1.html' },
{ href: 'chapter1-2.html' },
{ href: 'chapter1-3.html' },
),
toc({ href: 'chapter1.html', title: 'Chapter One' }),
);
expect(t.tocEntryFor(t.items[1])?.link.title).toBe('Chapter One');
});

it('10.4 tier-3 fallback: walks back past multiple untitled resources to find the nearest preceding entry', () => {
const t = build(
ro(
{ href: 'chapter1.html' },
{ href: 'chapter1-2.html' },
{ href: 'chapter1-3.html' },
),
toc({ href: 'chapter1.html', title: 'Chapter One' }),
);
// chapter1-3.html has no toc entry of its own, and neither does chapter1-2.html
// immediately preceding it — must skip past it to chapter1.html's entry.
expect(t.tocEntryFor(t.items[2])?.link.title).toBe('Chapter One');
});
Comment thread
JayPanoz marked this conversation as resolved.

it('10.5 tier-3 fallback: preceding resource with only ambiguous fragment entries still resolves, to the last one', () => {
// chapter1.html has no start-of-resource toc entry, and two fragment entries —
// ambiguous for title resolution (undefined title), but tier-3 must still
// pick one deterministically rather than skipping past it to nothing.
const t = build(
ro(
{ href: 'chapter1.html' },
{ href: 'chapter1-2.html' },
),
toc(
{ href: 'chapter1.html#s1', title: 'Section 1' },
{ href: 'chapter1.html#s2', title: 'Section 2' },
),
);
expect(t.tocEntryFor(t.items[1])?.link.title).toBe('Section 2');
});

it('10.6 tier-3 fallback: excludes fragment-only toc entries when resolving the preceding resource', () => {
// "#t=30" is fragment-only (single-track audio convention) and loosely
// matches any resource's own title resolution in build(), but it does
// not belong to track1.mp3 specifically — must not be picked as its entry.
const t = build(
ro(
{ href: 'track1.mp3' },
{ href: 'track2.mp3' },
),
toc({ href: '#t=30', title: 'Marker' }),
);
expect(t.tocEntryFor(t.items[1])).toBeUndefined();
});

it('10.7 tier-3 fallback: no preceding entry exists → undefined', () => {
const t = build(
ro({ href: 'chapter1.html' }, { href: 'chapter2.html', title: 'Chapter 2' }),
toc({ href: 'chapter2.html', title: 'Chapter Two' }),
);
expect(t.tocEntryFor(t.items[0])).toBeUndefined();
});

it('10.8 tier-3 fallback: does not misresolve when the reading order repeats the same href', () => {
// Two RO entries share an href; the second (untitled, no toc entries of
// its own) must resolve against its own preceding neighbor by identity,
// not the first occurrence of a matching href.
const t = build(
ro(
{ href: 'shared.html', title: 'First' },
{ href: 'other.html' },
{ href: 'shared.html' },
),
toc({ href: 'other.html', title: 'Other' }),
);
expect(t.tocEntryFor(t.items[2])?.link.title).toBe('Other');
});

it('10.9 no match: an item unknown to the timeline resolves to undefined', () => {
const t = build(
ro({ href: 'chapter1.html', title: 'Chapter 1' }),
toc({ href: 'chapter1.html#intro', title: 'Introduction' }),
Expand All @@ -811,7 +892,7 @@ describe('Timeline – tocEntryFor', () => {
expect(t.tocEntryFor(untracked)).toBeUndefined();
});

it('10.4 no manifest toc: a reading-order item direct-matches its own fallback toc entry', () => {
it('10.10 no manifest toc: a reading-order item direct-matches its own fallback toc entry', () => {
const t = build(ro({ href: 'chapter1.html', title: 'Chapter 1' }));
expect(t.tocEntryFor(t.items[0])?.link.href).toBe('chapter1.html');
});
Expand Down
Loading