fix(reader): remember last read position for markdown files (#4871)

Markdown sections were created with `cfi: ''`, but foliate-js builds a
location CFI as `section.cfi ?? CFI.fake.fromIndex(index)`. Nullish
coalescing does not fall back for an empty string, so every saved position
collapsed to a section-less CFI that resolves to no section. Reopening a
`.md` book then fell back to the start even though the library still showed
the correct read percentage.

Set each section's `cfi` to `CFI.fake.fromIndex(index)`, the same fake spine
CFI foliate synthesizes for single-file formats that omit it (e.g. fb2), so
positions round-trip across reopens.

Fixes #4862

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-01 11:16:30 +09:00
committed by GitHub
parent 01bc015985
commit 3ac1a1a45b
2 changed files with 23 additions and 1 deletions
@@ -1,6 +1,8 @@
import { describe, it, expect, vi } from 'vitest';
import { makeMarkdownBook } from '@/utils/md';
import type { BookDoc } from '@/libs/document';
import { CFI } from '@/libs/document';
import { getIndexFromCfi } from '@/utils/cfi';
// makeMarkdownBook returns a foliate book with a few methods (resolveHref,
// isExternal, destroy) and a per-section load() that the BookDoc type does not
@@ -80,6 +82,20 @@ describe('makeMarkdownBook', () => {
}
});
it('gives each section a CFI base that round-trips to its index (resume position)', async () => {
// foliate-js view.getCFI does `section.cfi ?? CFI.fake.fromIndex(index)`,
// so an empty-string cfi defeats the fallback and the generated location
// CFI loses its spine step. Reopening then resolves to no section and the
// reader falls back to the start. Verify each section carries a base CFI
// that survives the getCFI -> resolveCFI round-trip.
const book = await make('# One\n\na\n\n# Two\n\nb\n\n# Three\n\nc\n');
book.sections.forEach((section, index) => {
const baseCFI = section.cfi ?? CFI.fake.fromIndex(index);
const locationCFI = CFI.joinIndir(baseCFI, '/4/2:3');
expect(getIndexFromCfi(locationCFI)).toBe(index);
});
});
it('produces XHTML that parses without errors despite void tags', async () => {
const book = await make('# H\n\nline one \nline two\n\n---\n\n![alt](https://e.com/i.png)\n');
const doc = await book.sections[0]!.createDocument();
+7 -1
View File
@@ -1,6 +1,7 @@
import { marked } from 'marked';
import type { BookDoc, SectionItem } from '@/libs/document';
import { CFI } from '@/libs/document';
import { sanitizeHtml } from './sanitize';
// Render a standalone Markdown (.md) file into an in-memory foliate-js book at
@@ -158,7 +159,12 @@ export async function makeMarkdownBook(file: File): Promise<BookDoc> {
const urls: (string | undefined)[] = new Array(xhtml.length).fill(undefined);
const sections: MarkdownSection[] = xhtml.map((str, index) => ({
id: String(index),
cfi: '',
// A per-section spine CFI base. foliate-js builds location CFIs as
// `section.cfi ?? CFI.fake.fromIndex(index)`; an empty string defeats that
// `??` fallback, so every saved position would collapse to a section-less
// CFI and reopening would resume from the start. Set the same fake spine
// CFI foliate would synthesize so positions round-trip across reopens.
cfi: CFI.fake.fromIndex(index),
size: new TextEncoder().encode(str).length,
linear: 'yes',
load: () => {