From 37e1f4f1be2c7371b2824c9b6079c67b83ab0dda Mon Sep 17 00:00:00 2001
From: Pierre Leroux
Date: Wed, 16 Sep 2026 14:41:22 +0200
Subject: [PATCH 1/3] Export EPUB annotations as detached packages
---
src/common/extension.ts | 4 +-
.../annotation/annotationModel.type.ts | 31 ++++++--
src/common/readium/annotation/converter.ts | 11 +--
src/common/readium/annotation/htmlTemplate.ts | 2 +-
src/main/redux/sagas/note.ts | 25 ++++---
src/main/w3c/annotations/read.ts | 41 +++++++++++
.../readiumAnnotation/detachedPackage.ts | 30 ++++++++
.../redux/sagas/readiumAnnotation/export.ts | 12 ++--
src/utils/mimeTypes.ts | 3 +-
.../readium/annotation/converter.test.ts | 58 +++++++++++++++
test/main/w3c/annotations/read.test.ts | 70 +++++++++++++++++++
11 files changed, 258 insertions(+), 29 deletions(-)
create mode 100644 src/main/w3c/annotations/read.ts
create mode 100644 src/renderer/common/redux/sagas/readiumAnnotation/detachedPackage.ts
create mode 100644 test/main/w3c/annotations/read.test.ts
diff --git a/src/common/extension.ts b/src/common/extension.ts
index e4be5227ed..e1a3df2ce5 100644
--- a/src/common/extension.ts
+++ b/src/common/extension.ts
@@ -8,7 +8,9 @@
import { ObjectValues } from "readium-desktop/utils/object-keys-values";
export const EXT_THORIUM = ".thorium";
-export const EXT_ANNOTATIONS = ".annotation";
+export const EXT_ANNOTATIONS = ".annotations";
+export const EXT_ANNOTATIONS_LEGACY = ".annotation";
+export const ANNOTATIONS_JSON_FILENAME = "annotations.json";
// cf src/utils/mimeTypes.ts
export const acceptedExtensionObject = {
diff --git a/src/common/readium/annotation/annotationModel.type.ts b/src/common/readium/annotation/annotationModel.type.ts
index 0d5e901d7c..b669000324 100644
--- a/src/common/readium/annotation/annotationModel.type.ts
+++ b/src/common/readium/annotation/annotationModel.type.ts
@@ -8,8 +8,13 @@
import Ajv from "ajv";
import addFormats from "ajv-formats";
+export const EPUB_ANNOTATION_CONTEXT = "https://www.w3.org/ns/epub-anno.jsonld" as const;
+export const LEGACY_ANNOTATION_CONTEXT = "http://www.w3.org/ns/anno.jsonld" as const;
+
+export type TAnnotationContext = typeof EPUB_ANNOTATION_CONTEXT | typeof LEGACY_ANNOTATION_CONTEXT;
+
export interface IReadiumAnnotationSet {
- "@context": "http://www.w3.org/ns/anno.jsonld";
+ "@context": TAnnotationContext;
id: string;
type: "AnnotationSet";
generator?: Generator;
@@ -20,7 +25,7 @@ export interface IReadiumAnnotationSet {
}
export interface IReadiumAnnotation {
- "@context": "http://www.w3.org/ns/anno.jsonld";
+ "@context"?: TAnnotationContext;
id: string;
created: string;
modified?: string;
@@ -34,6 +39,7 @@ export interface IReadiumAnnotation {
type: string;
value: string;
tag?: string;
+ tags?: string[];
highlight?: "solid" | "underline" | "strikethrough" | "outline" | "bookmark";
color?: string;
textDirection?: "ltr" | "rtl";
@@ -198,7 +204,7 @@ export const readiumAnnotationSetSchema = {
"properties": {
"@context": {
"type": "string",
- "const": "http://www.w3.org/ns/anno.jsonld",
+ "enum": [EPUB_ANNOTATION_CONTEXT, LEGACY_ANNOTATION_CONTEXT],
},
"id": {
"type": "string",
@@ -290,7 +296,7 @@ export const readiumAnnotationSetSchema = {
"properties": {
"@context": {
"type": "string",
- "const": "http://www.w3.org/ns/anno.jsonld",
+ "enum": [EPUB_ANNOTATION_CONTEXT, LEGACY_ANNOTATION_CONTEXT],
},
"id": {
"type": "string",
@@ -343,6 +349,13 @@ export const readiumAnnotationSetSchema = {
"type": "string",
"nullable": true,
},
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ },
+ "nullable": true,
+ },
"highlight": {
"type": "string",
"enum": ["solid", "underline", "strikethrough", "outline", "bookmark"],
@@ -418,7 +431,7 @@ export const readiumAnnotationSetSchema = {
"required": ["source", "selector"],
},
},
- "required": ["@context", "id", "created", "type", "target"],
+ "required": ["id", "created", "type", "target"],
},
"Selector": {
"type": "object",
@@ -494,3 +507,11 @@ export function isIReadiumAnnotationSet(data: any): data is IReadiumAnnotationSe
return valid;
}
+export function normalizeReadiumAnnotationTags(body: IReadiumAnnotation["body"] | undefined): string[] {
+ const tags = body?.tags || [];
+ const legacyTag = body?.tag;
+
+ return [...tags, ...(legacyTag ? [legacyTag] : [])]
+ .filter((tag, index, allTags) => allTags.indexOf(tag) === index);
+}
+
diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts
index 1e3d4f4d39..62fb6baa3b 100644
--- a/src/common/readium/annotation/converter.ts
+++ b/src/common/readium/annotation/converter.ts
@@ -7,7 +7,7 @@
import debug_ from "debug";
-import { ICssSelector, IReadiumAnnotation, IReadiumAnnotationSet, isCFIFragmentSelector, isCssSelector, isEPUBCFISelector, isLegacyCfiSelector, isProgressionSelector, isTextPositionSelector, isTextQuoteSelector, ITextPositionSelector, ITextQuoteSelector } from "./annotationModel.type";
+import { EPUB_ANNOTATION_CONTEXT, ICssSelector, IReadiumAnnotation, IReadiumAnnotationSet, isCFIFragmentSelector, isCssSelector, isEPUBCFISelector, isLegacyCfiSelector, isProgressionSelector, isTextPositionSelector, isTextQuoteSelector, ITextPositionSelector, ITextQuoteSelector } from "./annotationModel.type";
import { uuidv4 } from "readium-desktop/utils/uuid";
import { _APP_NAME, _APP_VERSION } from "readium-desktop/preprocessor-directives";
import { PublicationView } from "readium-desktop/common/views/publication";
@@ -269,8 +269,9 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn
export function convertAnnotationStateToReadiumAnnotation(note: INoteState): IReadiumAnnotation | undefined {
const { uuid, color, locatorExtended, tags, drawType, textualValue, creator, created, modified, readiumAnnotation } = note;
- const highlight = (drawType === EDrawType.solid_background ? "solid" : EDrawType[drawType]) as IReadiumAnnotation["body"]["highlight"];
const isABookmark = drawType === EDrawType.bookmark;
+ const highlight = isABookmark ? undefined :
+ (drawType === EDrawType.solid_background ? "solid" : EDrawType[drawType]) as IReadiumAnnotation["body"]["highlight"];
// PDF annotations currently store their target in `note.pdfAnnotation`.
// Do not serialize them as Readium annotations until there is an explicit
@@ -285,7 +286,7 @@ export function convertAnnotationStateToReadiumAnnotation(note: INoteState): IRe
}
return {
- "@context": "http://www.w3.org/ns/anno.jsonld",
+ "@context": EPUB_ANNOTATION_CONTEXT,
id: uuid ? "urn:uuid:" + uuid : "",
created: new Date(created).toISOString(),
modified: modified ? new Date(modified).toISOString() : undefined,
@@ -295,7 +296,7 @@ export function convertAnnotationStateToReadiumAnnotation(note: INoteState): IRe
value: textualValue || "",
format: "text/plain",
color: noteColorCodeToColorSet[rgbToHex(color)] || NOTE_DEFAULT_COLOR,
- tag: (tags || [])[0] || "",
+ ...(tags?.length ? { tags: [...tags] } : {}),
highlight,
// textDirection: "ltr",
// language: "fr",
@@ -324,7 +325,7 @@ export function convertAnnotationStateArrayToReadiumAnnotationSet(locale: keyof
// const iLcp = !!publicationView.lcp;
return {
- "@context": "http://www.w3.org/ns/anno.jsonld",
+ "@context": EPUB_ANNOTATION_CONTEXT,
id: "urn:uuid:" + uuidv4(),
type: "AnnotationSet",
generator: {
diff --git a/src/common/readium/annotation/htmlTemplate.ts b/src/common/readium/annotation/htmlTemplate.ts
index e0291b266c..b1780c3961 100644
--- a/src/common/readium/annotation/htmlTemplate.ts
+++ b/src/common/readium/annotation/htmlTemplate.ts
@@ -79,7 +79,7 @@ export const noteExportHtmlMustacheTemplate = `
{{/body.value}}
- {{#body.tag}} | Tag: {{body.tag}}{{/body.tag}}
+ {{#body.tags}} | Tag: {{.}}{{/body.tags}}
{{#body.color}} | Color: {{body.color}}{{/body.color}}
{{#body.highlight}} | Highlight type: {{body.highlight}}{{/body.highlight}};
{{#body.textDirection}}TextDirection: | {{body.textDirection}}{{/body.textDirection}}
diff --git a/src/main/redux/sagas/note.ts b/src/main/redux/sagas/note.ts
index 4a8634a9e4..3926414098 100644
--- a/src/main/redux/sagas/note.ts
+++ b/src/main/redux/sagas/note.ts
@@ -7,7 +7,6 @@
import debug_ from "debug";
import { dialog } from "electron";
-import * as fs from "node:fs";
import { buildPublicationUserAnalyticsParams, publicationAnalyticsEvents } from "readium-desktop/common/analytics/publication";
import { ToastType } from "readium-desktop/common/models/toast";
import { annotationActions, readerActions, toastActions } from "readium-desktop/common/redux/actions";
@@ -17,7 +16,7 @@ import { SagaGenerator } from "typed-redux-saga";
import { call as callTyped, put as putTyped, take as takeTyped, delay as delayTyped, all as allTyped } from "typed-redux-saga/macro";
import { hexToRgb } from "readium-desktop/common/rgb";
import { isNil } from "readium-desktop/utils/nil";
-import { __READIUM_ANNOTATION_AJV_ERRORS, isCFIFragmentSelector, isCssSelector, isEPUBCFISelector, isFragmentSelector, isIReadiumAnnotationSet, isLegacyCfiSelector, isTextPositionSelector, isTextQuoteSelector } from "readium-desktop/common/readium/annotation/annotationModel.type";
+import { __READIUM_ANNOTATION_AJV_ERRORS, isCFIFragmentSelector, isCssSelector, isEPUBCFISelector, isFragmentSelector, isIReadiumAnnotationSet, isLegacyCfiSelector, isTextPositionSelector, isTextQuoteSelector, normalizeReadiumAnnotationTags } from "readium-desktop/common/readium/annotation/annotationModel.type";
import path from "node:path";
import { getPublication } from "./api/publication/getPublication";
import { Publication as R2Publication } from "@r2-shared-js/models/publication";
@@ -30,10 +29,11 @@ import { EDrawType, INoteState, NOTE_DEFAULT_COLOR, noteColorCodeToColorSet, not
import { takeSpawnLeading } from "readium-desktop/common/redux/sagas/takeSpawnLeading";
import { sqliteTableNoteDelete, sqliteTableNoteDeleteWherePubId, sqliteTableNoteInsert, sqliteTableNoteUpdate, sqliteTableSelectAllNotesWherePubId } from "readium-desktop/main/db/sqlite/note";
import { publicationActions as publicationActionsFromMainAction } from "../actions";
-import { EXT_ANNOTATIONS } from "readium-desktop/common/extension";
+import { EXT_ANNOTATIONS, EXT_ANNOTATIONS_LEGACY } from "readium-desktop/common/extension";
import { resolveReadiumAnnotationSourceHref } from "readium-desktop/common/readium/annotation/sourceHref";
import { spawnPublicationAnalyticsEvent } from "./analyticsPublication";
import { TAnalyticsEventParams } from "src/common/api/interface/analyticsApi.interface";
+import { readAnnotationSetFile } from "readium-desktop/main/w3c/annotations/read";
// Logger
const filename_ = "readium-desktop:main:saga:annotationsImporter";
@@ -116,11 +116,14 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct
try {
debug("Open ShowOpenDialog and ask to user the filePath");
- const res = yield* callTyped(() => dialog.showOpenDialog(win, { filters: [{ extensions: [EXT_ANNOTATIONS.substring(1)], name: __("reader.marks.annotationsReadium") + " [" + EXT_ANNOTATIONS + "]" }], properties: ["openFile"] }));
-
- if (!res.canceled) {
- filePath = res.filePaths[0] || "";
+ const res = yield* callTyped(() => dialog.showOpenDialog(win, { filters: [{ extensions: [EXT_ANNOTATIONS.substring(1), EXT_ANNOTATIONS_LEGACY.substring(1)], name: __("reader.marks.annotationsReadium") + ` [${EXT_ANNOTATIONS}, ${EXT_ANNOTATIONS_LEGACY}]` }], properties: ["openFile"] }));
+ if (res.canceled) {
+ return;
+ }
+ filePath = res.filePaths[0] || "";
+ if (!filePath) {
+ return;
}
} catch (e) {
debug("Error!!! to open a file, exit", e);
@@ -129,12 +132,12 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct
}
debug("FilePath=", filePath);
- const fileName = path.basename(filePath).slice(0, -1 * EXT_ANNOTATIONS.length);
+ const fileName = path.basename(filePath, path.extname(filePath));
try {
// read filePath
- const dataString = yield* callTyped(() => fs.promises.readFile(filePath, { encoding: "utf8" }));
+ const dataString = yield* callTyped(() => readAnnotationSetFile(filePath));
const readiumAnnotationFormat = JSON.parse(dataString);
debug("filePath size=", dataString.length);
debug("filePath serialized and ready to pass the type checker");
@@ -225,6 +228,7 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct
// loop on each annotation to check conflicts and import it
for (const incommingAnnotation of annotationsIncommingArray) {
const creator = incommingAnnotation.creator;
+ const importedTags = normalizeReadiumAnnotationTags(incommingAnnotation.body);
const uuid = incommingAnnotation.id.split("urn:uuid:")[1] || uuidv4(); // TODO : may not be an uuid format and maybe we should hash the uuid to get a unique identifier based on the original uuid
@@ -267,8 +271,7 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct
noteColorSetToColorCode[noteColorCodeToColorSet[incommingAnnotation.body?.color] || NOTE_DEFAULT_COLOR],
),
drawType: EDrawType[(isNil(incommingAnnotation.body?.highlight) || incommingAnnotation.body?.highlight === "solid") ? "solid_background" : incommingAnnotation.body.highlight] || EDrawType.solid_background,
- // TODO need to ask to user if the incomming tag is kept or the fileName is used
- tags: [fileName], // incommingAnnotation.body?.tag ? [incommingAnnotation.body?.tag] : [],
+ tags: importedTags.length ? importedTags : [fileName],
modified: incommingAnnotation.modified ? tryCatchSync(() => new Date(incommingAnnotation.modified).getTime(), fileName) : undefined,
created: tryCatchSync(() => new Date(incommingAnnotation.created).getTime(), fileName) || currentTimestamp,
creator: creator?.id ? {
diff --git a/src/main/w3c/annotations/read.ts b/src/main/w3c/annotations/read.ts
new file mode 100644
index 0000000000..5d140bde1f
--- /dev/null
+++ b/src/main/w3c/annotations/read.ts
@@ -0,0 +1,41 @@
+// ==LICENSE-BEGIN==
+// Copyright 2017 European Digital Reading Lab. All rights reserved.
+// Licensed to the Readium Foundation under one or more contributor license agreements.
+// Use of this source code is governed by a BSD-style license
+// that can be found in the LICENSE file exposed on Github (readium) in the project repository.
+// ==LICENSE-END==
+
+import * as fs from "node:fs";
+import path from "node:path";
+import StreamZip from "node-stream-zip";
+
+import { ANNOTATIONS_JSON_FILENAME, EXT_ANNOTATIONS, EXT_ANNOTATIONS_LEGACY } from "readium-desktop/common/extension";
+
+async function readDetachedAnnotationPackage(filePath: string): Promise {
+ const zip = new StreamZip.async({ file: filePath });
+
+ try {
+ const entry = await zip.entry(ANNOTATIONS_JSON_FILENAME);
+ if (!entry || entry.isDirectory) {
+ throw new Error(`The annotation package does not contain a root-level ${ANNOTATIONS_JSON_FILENAME} file.`);
+ }
+
+ const data = await zip.entryData(entry);
+ return data.toString("utf8");
+ } finally {
+ await zip.close().catch((): void => undefined);
+ }
+}
+
+export async function readAnnotationSetFile(filePath: string): Promise {
+ const extension = path.extname(filePath).toLowerCase();
+
+ if (extension === EXT_ANNOTATIONS_LEGACY) {
+ return fs.promises.readFile(filePath, { encoding: "utf8" });
+ }
+ if (extension === EXT_ANNOTATIONS) {
+ return readDetachedAnnotationPackage(filePath);
+ }
+
+ throw new Error(`Unsupported annotation file extension: ${extension}`);
+}
diff --git a/src/renderer/common/redux/sagas/readiumAnnotation/detachedPackage.ts b/src/renderer/common/redux/sagas/readiumAnnotation/detachedPackage.ts
new file mode 100644
index 0000000000..8da9a10058
--- /dev/null
+++ b/src/renderer/common/redux/sagas/readiumAnnotation/detachedPackage.ts
@@ -0,0 +1,30 @@
+// ==LICENSE-BEGIN==
+// Copyright 2017 European Digital Reading Lab. All rights reserved.
+// Licensed to the Readium Foundation under one or more contributor license agreements.
+// Use of this source code is governed by a BSD-style license
+// that can be found in the LICENSE file exposed on Github (readium) in the project repository.
+// ==LICENSE-END==
+
+import { ZipFile } from "yazl";
+
+import { ANNOTATIONS_JSON_FILENAME } from "readium-desktop/common/extension";
+
+export function createDetachedAnnotationPackage(serializedAnnotationSet: string): Promise {
+ return new Promise((resolve, reject) => {
+ const zipFile = new ZipFile();
+ const chunks: Buffer[] = [];
+
+ zipFile.outputStream.on("data", (chunk: Buffer) => chunks.push(chunk));
+ zipFile.outputStream.once("error", reject);
+ zipFile.outputStream.once("end", () => {
+ const archive = Buffer.concat(chunks);
+ resolve(archive.buffer.slice(
+ archive.byteOffset,
+ archive.byteOffset + archive.byteLength,
+ ) as ArrayBuffer);
+ });
+
+ zipFile.addBuffer(Buffer.from(serializedAnnotationSet, "utf8"), ANNOTATIONS_JSON_FILENAME);
+ zipFile.end();
+ });
+}
diff --git a/src/renderer/common/redux/sagas/readiumAnnotation/export.ts b/src/renderer/common/redux/sagas/readiumAnnotation/export.ts
index 8e62735449..c8737903ba 100644
--- a/src/renderer/common/redux/sagas/readiumAnnotation/export.ts
+++ b/src/renderer/common/redux/sagas/readiumAnnotation/export.ts
@@ -33,6 +33,7 @@ import { sanitizeForFilename } from "readium-desktop/common/safe-filename";
import { EXT_ANNOTATIONS } from "readium-desktop/common/extension";
import { mimeTypes } from "readium-desktop/utils/mimeTypes";
import { logEvent } from "readium-desktop/renderer/common/analytics";
+import { createDetachedAnnotationPackage } from "./detachedPackage";
// Logger
const debug = debug_("readium-desktop:renderer:common:redux:sagas:readiumAnnotation:export");
@@ -70,9 +71,9 @@ ${Buffer.from(JsonStringifySortedKeys(readiumAnnotation, 2)).toString("base64")}
-->
`;
};
-const downloadAnnotationFile = (data: string, filenameWithExtension: string, extension: typeof EXT_ANNOTATIONS | ".html") => {
+const downloadAnnotationFile = (data: string | ArrayBuffer, filenameWithExtension: string, extension: typeof EXT_ANNOTATIONS | ".html") => {
- const blob = new Blob([data], { type: extension === EXT_ANNOTATIONS ? mimeTypes.annotation : "text/html" });
+ const blob = new Blob([data], { type: extension === EXT_ANNOTATIONS ? mimeTypes.annotations : "text/html" });
const jsonObjectUrl = URL.createObjectURL(blob);
const anchorEl = document.createElement("a");
anchorEl.href = jsonObjectUrl;
@@ -97,13 +98,14 @@ export function* exportAnnotationSet(notes: INoteState[], publicationView: Publi
const htmlMustacheTemplateContent = overrideHTMLTemplate ? htmlContent : noteExportHtmlMustacheTemplate || noteExportHtmlMustacheTemplate;
const extension = fileType === "annotation" ? EXT_ANNOTATIONS : ".html";
- const stringData = extension === EXT_ANNOTATIONS ?
- JsonStringifySortedKeys(readiumAnnotationSet, 2) :
+ const serializedAnnotationSet = JsonStringifySortedKeys(readiumAnnotationSet, 2);
+ const fileData = extension === EXT_ANNOTATIONS ?
+ yield* callTyped(() => createDetachedAnnotationPackage(serializedAnnotationSet)) :
yield* callTyped(() => convertReadiumAnnotationSetToHtml(readiumAnnotationSet, __htmlMustacheViewConverterFn, htmlMustacheTemplateContent));
const filenameWithExtension = sanitizeForFilename(annoSetTitle + extension);
- downloadAnnotationFile(stringData, filenameWithExtension, extension);
+ downloadAnnotationFile(fileData, filenameWithExtension, extension);
yield* spawnTyped(function*() {
yield* callTyped(
logEvent,
diff --git a/src/utils/mimeTypes.ts b/src/utils/mimeTypes.ts
index 8bdda2e069..a31366fad0 100644
--- a/src/utils/mimeTypes.ts
+++ b/src/utils/mimeTypes.ts
@@ -1177,7 +1177,8 @@ export const mimeTypes = {
"lcpdivina": "application/divina+lcp",
"lcpdf": "application/pdf+lcp",
[ADOBE_ADEPT_XML]: "application/vnd.adobe.adept+xml",
- "annotation": "application/rd-annotations+json", // also see acceptedExtensionObject.annotations EXT_ANNOTATIONS
+ "annotation": "application/rd-annotations+json", // legacy raw annotation set
+ "annotations": "application/zip;profile=\"https://www.w3.org/TR/epub-anno-10/\"", // detached W3C annotation package
"thorium": "application/vnd.edrlab.thorium+zip", // also see acceptedExtensionObject.customizationProfilePackage EXT_THORIUM
};
diff --git a/test/common/readium/annotation/converter.test.ts b/test/common/readium/annotation/converter.test.ts
index 25b06c9345..57cde079de 100644
--- a/test/common/readium/annotation/converter.test.ts
+++ b/test/common/readium/annotation/converter.test.ts
@@ -4,6 +4,12 @@ import {
convertAnnotationStateArrayToReadiumAnnotationSet,
convertAnnotationStateToReadiumAnnotation,
} from "readium-desktop/common/readium/annotation/converter";
+import {
+ EPUB_ANNOTATION_CONTEXT,
+ isIReadiumAnnotationSet,
+ LEGACY_ANNOTATION_CONTEXT,
+ normalizeReadiumAnnotationTags,
+} from "readium-desktop/common/readium/annotation/annotationModel.type";
import type { IEPUBCFISelector, ITextQuoteSelector } from "readium-desktop/common/readium/annotation/annotationModel.type";
import { EDrawType, INoteState } from "readium-desktop/common/redux/states/renderer/note";
import { PublicationView } from "readium-desktop/common/views/publication";
@@ -131,3 +137,55 @@ test("Readium annotation export preserves EPUB CFI selector vocabulary", () => {
expect(annotation?.target.selector).toContainEqual(epubCfiSelector);
});
+
+test("EPUB annotation export uses the W3C context and preserves all tags", () => {
+ const annotation = convertAnnotationStateToReadiumAnnotation(createNote({
+ tags: ["review", "important"],
+ }));
+
+ expect(annotation?.["@context"]).toBe(EPUB_ANNOTATION_CONTEXT);
+ expect(annotation?.body.tags).toEqual(["review", "important"]);
+ expect(annotation?.body).not.toHaveProperty("tag");
+});
+
+test("EPUB annotation export omits tags when the note has none", () => {
+ const annotation = convertAnnotationStateToReadiumAnnotation(createNote({ tags: [] }));
+
+ expect(annotation?.body).not.toHaveProperty("tags");
+});
+
+test("EPUB annotation sets validate with the W3C context", () => {
+ const annotationSet = convertAnnotationStateArrayToReadiumAnnotationSet(
+ "en",
+ [createNote()],
+ publicationView,
+ "Export",
+ );
+
+ expect(annotationSet["@context"]).toBe(EPUB_ANNOTATION_CONTEXT);
+ expect(isIReadiumAnnotationSet(annotationSet)).toBe(true);
+});
+
+test("legacy annotation sets remain valid for import", () => {
+ const annotationSet = convertAnnotationStateArrayToReadiumAnnotationSet(
+ "en",
+ [createNote()],
+ publicationView,
+ "Legacy export",
+ );
+ annotationSet["@context"] = LEGACY_ANNOTATION_CONTEXT;
+ annotationSet.items[0]["@context"] = LEGACY_ANNOTATION_CONTEXT;
+ annotationSet.items[0].body.tag = annotationSet.items[0].body.tags?.[0];
+ delete annotationSet.items[0].body.tags;
+
+ expect(isIReadiumAnnotationSet(annotationSet)).toBe(true);
+});
+
+test("annotation import merges current and legacy tags without duplicates", () => {
+ expect(normalizeReadiumAnnotationTags({
+ type: "TextualBody",
+ value: "Note",
+ tags: ["review", "important"],
+ tag: "review",
+ })).toEqual(["review", "important"]);
+});
diff --git a/test/main/w3c/annotations/read.test.ts b/test/main/w3c/annotations/read.test.ts
new file mode 100644
index 0000000000..576f38ae58
--- /dev/null
+++ b/test/main/w3c/annotations/read.test.ts
@@ -0,0 +1,70 @@
+import { afterEach, expect, test } from "@jest/globals";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import path from "node:path";
+import { ZipFile } from "yazl";
+
+import { readAnnotationSetFile } from "readium-desktop/main/w3c/annotations/read";
+import { createDetachedAnnotationPackage } from "readium-desktop/renderer/common/redux/sagas/readiumAnnotation/detachedPackage";
+
+const tempDirectories: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(tempDirectories.splice(0).map((directory) =>
+ fs.promises.rm(directory, { force: true, recursive: true }),
+ ));
+});
+
+async function createTempDirectory(): Promise {
+ const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "thorium-annotations-"));
+ tempDirectories.push(directory);
+ return directory;
+}
+
+function createZipWithEntry(entryName: string, contents: string): Promise {
+ return new Promise((resolve, reject) => {
+ const zipFile = new ZipFile();
+ const chunks: Buffer[] = [];
+
+ zipFile.outputStream.on("data", (chunk: Buffer) => chunks.push(chunk));
+ zipFile.outputStream.once("error", reject);
+ zipFile.outputStream.once("end", () => resolve(Buffer.concat(chunks)));
+ zipFile.addBuffer(Buffer.from(contents, "utf8"), entryName);
+ zipFile.end();
+ });
+}
+
+test("detached annotation packages contain a root-level annotations.json", async () => {
+ const serializedAnnotationSet = JSON.stringify({ type: "AnnotationSet", items: [] });
+ const archive = await createDetachedAnnotationPackage(serializedAnnotationSet);
+ const directory = await createTempDirectory();
+ const filePath = path.join(directory, "export.annotations");
+ await fs.promises.writeFile(filePath, Buffer.from(archive));
+
+ await expect(readAnnotationSetFile(filePath)).resolves.toBe(serializedAnnotationSet);
+});
+
+test("legacy raw annotation files remain readable", async () => {
+ const serializedAnnotationSet = JSON.stringify({ type: "AnnotationSet", items: [] });
+ const directory = await createTempDirectory();
+ const filePath = path.join(directory, "legacy.annotation");
+ await fs.promises.writeFile(filePath, serializedAnnotationSet, "utf8");
+
+ await expect(readAnnotationSetFile(filePath)).resolves.toBe(serializedAnnotationSet);
+});
+
+test("detached annotation packages require annotations.json at the archive root", async () => {
+ const directory = await createTempDirectory();
+ const filePath = path.join(directory, "invalid.annotations");
+ await fs.promises.writeFile(filePath, await createZipWithEntry("nested/annotations.json", "{}"));
+
+ await expect(readAnnotationSetFile(filePath)).rejects.toThrow("root-level annotations.json");
+});
+
+test("malformed detached annotation packages are rejected", async () => {
+ const directory = await createTempDirectory();
+ const filePath = path.join(directory, "malformed.annotations");
+ await fs.promises.writeFile(filePath, "not a ZIP archive", "utf8");
+
+ await expect(readAnnotationSetFile(filePath)).rejects.toThrow();
+});
From 04214219204a9d293fe68861fe933f3872b0a574 Mon Sep 17 00:00:00 2001
From: Pierre Leroux
Date: Mon, 5 Oct 2026 17:16:07 +0200
Subject: [PATCH 2/3] Fix bookmark round-trip and HTML annotation tag
compatibility
---
src/common/readium/annotation/converter.ts | 8 +++++++
src/common/readium/annotation/htmlTemplate.ts | 8 +++++++
src/main/redux/sagas/note.ts | 6 +++---
.../redux/sagas/readiumAnnotation/export.ts | 7 ++++---
.../readium/annotation/converter.test.ts | 21 +++++++++++++++++++
5 files changed, 44 insertions(+), 6 deletions(-)
diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts
index 190a166838..ffc04f64c1 100644
--- a/src/common/readium/annotation/converter.ts
+++ b/src/common/readium/annotation/converter.ts
@@ -308,6 +308,14 @@ export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnn
// export type INoteStateWithICacheDocument = INoteState & { __cacheDocument?: ICacheDocument | undefined };
+export function readiumAnnotationDrawType(annotation: IReadiumAnnotation): EDrawType {
+ if (annotation.motivation === "bookmarking") {
+ return EDrawType.bookmark;
+ }
+ const highlight = annotation.body?.highlight;
+ return highlight && highlight !== "solid" ? EDrawType[highlight] : EDrawType.solid_background;
+}
+
export function convertAnnotationStateToReadiumAnnotation(note: INoteState): IReadiumAnnotation | undefined {
const { uuid, color, locatorExtended, tags, drawType, textualValue, creator, created, modified, readiumAnnotation } = note;
diff --git a/src/common/readium/annotation/htmlTemplate.ts b/src/common/readium/annotation/htmlTemplate.ts
index b1780c3961..1c6a742c98 100644
--- a/src/common/readium/annotation/htmlTemplate.ts
+++ b/src/common/readium/annotation/htmlTemplate.ts
@@ -1,3 +1,11 @@
+import type { IReadiumAnnotation } from "./annotationModel.type";
+
+export function annotationHtmlBody(annotation: IReadiumAnnotation): IReadiumAnnotation["body"] {
+ return {
+ ...annotation.body,
+ tag: annotation.body?.tags?.[0] || annotation.body?.tag || "",
+ };
+}
export const noteExportHtmlMustacheTemplate = `
diff --git a/src/main/redux/sagas/note.ts b/src/main/redux/sagas/note.ts
index 9e4b0a6f04..16e2a64ae7 100644
--- a/src/main/redux/sagas/note.ts
+++ b/src/main/redux/sagas/note.ts
@@ -15,7 +15,7 @@ import { error } from "readium-desktop/main/tools/error";
import { SagaGenerator } from "typed-redux-saga";
import { call as callTyped, put as putTyped, take as takeTyped, delay as delayTyped, all as allTyped } from "typed-redux-saga/macro";
import { hexToRgb } from "readium-desktop/common/rgb";
-import { isNil } from "readium-desktop/utils/nil";
+import { readiumAnnotationDrawType } from "readium-desktop/common/readium/annotation/converter";
import { __READIUM_ANNOTATION_AJV_ERRORS, isCFIFragmentSelector, isCssSelector, isEPUBCFISelector, isFragmentSelector, isIReadiumAnnotationSet, isLegacyCfiSelector, isTextPositionSelector, isTextQuoteSelector, normalizeReadiumAnnotationTags } from "readium-desktop/common/readium/annotation/annotationModel.type";
import path from "node:path";
import { getPublication } from "./api/publication/getPublication";
@@ -25,7 +25,7 @@ import { tryCatchSync } from "readium-desktop/utils/tryCatch";
import { uuidv4 } from "readium-desktop/utils/uuid";
import { takeSpawnLatest } from "readium-desktop/common/redux/sagas/takeSpawnLatest";
import { getTranslator } from "readium-desktop/common/services/translator";
-import { EDrawType, INoteState, NOTE_DEFAULT_COLOR, noteColorCodeToColorSet, noteColorSetToColorCode } from "readium-desktop/common/redux/states/renderer/note";
+import { INoteState, NOTE_DEFAULT_COLOR, noteColorCodeToColorSet, noteColorSetToColorCode } from "readium-desktop/common/redux/states/renderer/note";
import { takeSpawnLeading } from "readium-desktop/common/redux/sagas/takeSpawnLeading";
import { sqliteTableNoteDelete, sqliteTableNoteDeleteWherePubId, sqliteTableNoteInsert, sqliteTableNoteUpdate, sqliteTableSelectAllNotesWherePubId } from "readium-desktop/main/db/sqlite/note";
import { publicationActions as publicationActionsFromMainAction } from "../actions";
@@ -261,7 +261,7 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct
color: hexToRgb(noteColorSetToColorCode[incommingAnnotation.body?.color] ||
noteColorSetToColorCode[noteColorCodeToColorSet[incommingAnnotation.body?.color] || NOTE_DEFAULT_COLOR],
),
- drawType: EDrawType[(isNil(incommingAnnotation.body?.highlight) || incommingAnnotation.body?.highlight === "solid") ? "solid_background" : incommingAnnotation.body.highlight] || EDrawType.solid_background,
+ drawType: readiumAnnotationDrawType(incommingAnnotation),
tags: importedTags.length ? importedTags : [fileName],
modified: incommingAnnotation.modified ? tryCatchSync(() => new Date(incommingAnnotation.modified).getTime(), fileName) : undefined,
created: tryCatchSync(() => new Date(incommingAnnotation.created).getTime(), fileName) || currentTimestamp,
diff --git a/src/renderer/common/redux/sagas/readiumAnnotation/export.ts b/src/renderer/common/redux/sagas/readiumAnnotation/export.ts
index c8737903ba..ffe7e723d9 100644
--- a/src/renderer/common/redux/sagas/readiumAnnotation/export.ts
+++ b/src/renderer/common/redux/sagas/readiumAnnotation/export.ts
@@ -21,7 +21,7 @@ import { IReadiumAnnotation, IReadiumAnnotationSet } from "readium-desktop/commo
import Mustache from "mustache";
// esModuleInterop?
-import { noteExportHtmlMustacheTemplate } from "readium-desktop/common/readium/annotation/htmlTemplate";
+import { annotationHtmlBody, noteExportHtmlMustacheTemplate } from "readium-desktop/common/readium/annotation/htmlTemplate";
import DOMPurify from "dompurify";
import { INoteState } from "readium-desktop/common/redux/states/renderer/note";
import { PublicationView } from "readium-desktop/common/views/publication";
@@ -46,11 +46,12 @@ const __htmlMustacheViewConverterFn: (readiumAnnotation: IReadiumAnnotationSet)
};
const tmpItems = [];
for (const item of (view.items || [])) {
+ const htmlItem = { ...item, body: annotationHtmlBody(item) };
try {
- tmpItems.push({ ...item, body: { ...item.body || {}, htmlValue: DOMPurify.sanitize(await marked.parse((item.body?.value || "").replace(/^[\u200B\u200C\u200D\u200E\u200F\uFEFF]/, ""), { gfm: true })) } });
+ tmpItems.push({ ...htmlItem, body: { ...htmlItem.body, htmlValue: DOMPurify.sanitize(await marked.parse((item.body?.value || "").replace(/^[\u200B\u200C\u200D\u200E\u200F\uFEFF]/, ""), { gfm: true })) } });
} catch (_) {
- tmpItems.push(item);
+ tmpItems.push(htmlItem);
}
}
view.items = tmpItems as IReadiumAnnotation[];
diff --git a/test/common/readium/annotation/converter.test.ts b/test/common/readium/annotation/converter.test.ts
index 531970e31b..6ab8284bf4 100644
--- a/test/common/readium/annotation/converter.test.ts
+++ b/test/common/readium/annotation/converter.test.ts
@@ -1,9 +1,12 @@
import { expect, test } from "@jest/globals";
+import Mustache from "mustache";
+import { annotationHtmlBody } from "readium-desktop/common/readium/annotation/htmlTemplate";
import {
convertSelectorTargetToLocatorExtended,
convertAnnotationStateArrayToReadiumAnnotationSet,
convertAnnotationStateToReadiumAnnotation,
+ readiumAnnotationDrawType,
} from "readium-desktop/common/readium/annotation/converter";
import {
EPUB_ANNOTATION_CONTEXT,
@@ -92,6 +95,24 @@ function createNote(overrides: Partial = {}): INoteState {
};
}
+test("bookmarks retain their motivation across export, import, and re-export", () => {
+ const note = createNote({ drawType: EDrawType.bookmark, group: "bookmark" });
+ const exported = convertAnnotationStateToReadiumAnnotation(note)!;
+ expect(exported.body.highlight).toBeUndefined();
+ const importedDrawType = readiumAnnotationDrawType(exported);
+ expect(importedDrawType).toBe(EDrawType.bookmark);
+ expect(convertAnnotationStateToReadiumAnnotation({ ...note, drawType: importedDrawType })?.motivation)
+ .toBe("bookmarking");
+});
+
+test("custom HTML templates retain the legacy first-tag field without changing JSON export", () => {
+ const annotation = convertAnnotationStateToReadiumAnnotation(createNote({ tags: ["review", "important"] }))!;
+ const htmlView = { body: annotationHtmlBody(annotation) };
+ expect(Mustache.render("{{#body.tag}}Tag: {{body.tag}}{{/body.tag}}", htmlView)).toBe("Tag: review");
+ expect(Mustache.render("{{#body.tags}}{{.}};{{/body.tags}}", htmlView)).toBe("review;important;");
+ expect(annotation.body).not.toHaveProperty("tag");
+});
+
test("Readium annotation conversion skips PDF annotations", () => {
const pdfAnnotation = createNote({
uuid: "pdf-note",
From 5f4f0365ad59ee873bf20cf23c35539b4782723f Mon Sep 17 00:00:00 2001
From: Pierre Leroux
Date: Mon, 5 Oct 2026 19:08:29 +0200
Subject: [PATCH 3/3] Limit W3C annotation packages to EPUB publications
---
src/common/readium/annotation/converter.ts | 16 ++++----
src/common/readium/annotation/htmlTemplate.ts | 1 +
src/common/views/publication.ts | 1 +
src/main/converter/publication.ts | 2 +
.../redux/sagas/readiumAnnotation/export.ts | 15 ++++----
.../readium/annotation/converter.test.ts | 38 +++++++++++++++++++
6 files changed, 58 insertions(+), 15 deletions(-)
diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts
index ffc04f64c1..1ae94000a3 100644
--- a/src/common/readium/annotation/converter.ts
+++ b/src/common/readium/annotation/converter.ts
@@ -7,7 +7,7 @@
import debug_ from "debug";
-import { EPUB_ANNOTATION_CONTEXT, ICssSelector, IReadiumAnnotation, IReadiumAnnotationSet, isCFIFragmentSelector, isCssSelector, isEPUBCFISelector, isLegacyCfiSelector, isProgressionSelector, isTextPositionSelector, isTextQuoteSelector, ITextPositionSelector, ITextQuoteSelector } from "./annotationModel.type";
+import { LEGACY_ANNOTATION_CONTEXT, TAnnotationContext, EPUB_ANNOTATION_CONTEXT, ICssSelector, IReadiumAnnotation, IReadiumAnnotationSet, isCFIFragmentSelector, isCssSelector, isEPUBCFISelector, isLegacyCfiSelector, isProgressionSelector, isTextPositionSelector, isTextQuoteSelector, ITextPositionSelector, ITextQuoteSelector } from "./annotationModel.type";
import { uuidv4 } from "readium-desktop/utils/uuid";
import { _APP_NAME, _APP_VERSION } from "readium-desktop/preprocessor-directives";
import { PublicationView } from "readium-desktop/common/views/publication";
@@ -316,11 +316,11 @@ export function readiumAnnotationDrawType(annotation: IReadiumAnnotation): EDraw
return highlight && highlight !== "solid" ? EDrawType[highlight] : EDrawType.solid_background;
}
-export function convertAnnotationStateToReadiumAnnotation(note: INoteState): IReadiumAnnotation | undefined {
+export function convertAnnotationStateToReadiumAnnotation(note: INoteState, context: TAnnotationContext = EPUB_ANNOTATION_CONTEXT): IReadiumAnnotation | undefined {
const { uuid, color, locatorExtended, tags, drawType, textualValue, creator, created, modified, readiumAnnotation } = note;
const isABookmark = drawType === EDrawType.bookmark;
- const highlight = isABookmark ? undefined :
+ const highlight = isABookmark && context === EPUB_ANNOTATION_CONTEXT ? undefined :
(drawType === EDrawType.solid_background ? "solid" : EDrawType[drawType]) as IReadiumAnnotation["body"]["highlight"];
// PDF annotations currently store their target in `note.pdfAnnotation`.
@@ -343,7 +343,7 @@ export function convertAnnotationStateToReadiumAnnotation(note: INoteState): IRe
} : undefined;
return {
- "@context": EPUB_ANNOTATION_CONTEXT,
+ "@context": context,
id: uuid ? "urn:uuid:" + uuid : "",
created: new Date(created).toISOString(),
modified: modified ? new Date(modified).toISOString() : undefined,
@@ -353,7 +353,7 @@ export function convertAnnotationStateToReadiumAnnotation(note: INoteState): IRe
value: textualValue || "",
format: "text/plain",
color: noteColorCodeToColorSet[rgbToHex(color)] || NOTE_DEFAULT_COLOR,
- ...(tags?.length ? { tags: [...tags] } : {}),
+ ...(context === LEGACY_ANNOTATION_CONTEXT ? { tag: tags?.[0] || "" } : tags?.length ? { tags: [...tags] } : {}),
highlight,
// textDirection: "ltr",
// language: "fr",
@@ -373,14 +373,14 @@ export function convertAnnotationStateToReadiumAnnotation(note: INoteState): IRe
};
}
-export function convertAnnotationStateArrayToReadiumAnnotationSet(locale: keyof typeof availableLanguages, notes: INoteState[], publicationView: PublicationView, label?: string): IReadiumAnnotationSet {
+export function convertAnnotationStateArrayToReadiumAnnotationSet(locale: keyof typeof availableLanguages, notes: INoteState[], publicationView: PublicationView, label?: string, context: TAnnotationContext = publicationView.isEPUB ? EPUB_ANNOTATION_CONTEXT : LEGACY_ANNOTATION_CONTEXT): IReadiumAnnotationSet {
const currentDate = new Date();
const dateString: string = currentDate.toISOString();
// const iLcp = !!publicationView.lcp;
return {
- "@context": EPUB_ANNOTATION_CONTEXT,
+ "@context": context,
id: "urn:uuid:" + uuidv4(),
type: "AnnotationSet",
generator: {
@@ -418,7 +418,7 @@ export function convertAnnotationStateArrayToReadiumAnnotationSet(locale: keyof
"dc:date": publicationView.publishedAt || "",
},
items: notes.reduce((items, note) => {
- const readiumAnnotation = convertAnnotationStateToReadiumAnnotation(note);
+ const readiumAnnotation = convertAnnotationStateToReadiumAnnotation(note, context);
if (readiumAnnotation) {
items.push(readiumAnnotation);
}
diff --git a/src/common/readium/annotation/htmlTemplate.ts b/src/common/readium/annotation/htmlTemplate.ts
index 1c6a742c98..689021464b 100644
--- a/src/common/readium/annotation/htmlTemplate.ts
+++ b/src/common/readium/annotation/htmlTemplate.ts
@@ -4,6 +4,7 @@ export function annotationHtmlBody(annotation: IReadiumAnnotation): IReadiumAnno
return {
...annotation.body,
tag: annotation.body?.tags?.[0] || annotation.body?.tag || "",
+ tags: annotation.body?.tags || (annotation.body?.tag ? [annotation.body.tag] : []),
};
}
diff --git a/src/common/views/publication.ts b/src/common/views/publication.ts
index c52a6381dd..da927bbc79 100644
--- a/src/common/views/publication.ts
+++ b/src/common/views/publication.ts
@@ -28,6 +28,7 @@ export interface PublicationView extends Identifiable {
// Post-check that the publication storage is valid for opening
isOpenable: boolean;
+ isEPUB?: boolean;
isAudio?: boolean;
isDivina?: boolean;
isPDF?: boolean;
diff --git a/src/main/converter/publication.ts b/src/main/converter/publication.ts
index 34722ece9e..39e245b044 100644
--- a/src/main/converter/publication.ts
+++ b/src/main/converter/publication.ts
@@ -350,6 +350,8 @@ export class PublicationViewConverter {
isDivina,
isPDF,
isDaisy,
+ isEPUB: !isAudio && !isDivina && !isPDF && !isDaisy &&
+ !!document.files?.some((file) => file.ext.toLowerCase() === "epub"),
isFixedLayoutPublication,
lastReadTimeStamp,
readingFinished,
diff --git a/src/renderer/common/redux/sagas/readiumAnnotation/export.ts b/src/renderer/common/redux/sagas/readiumAnnotation/export.ts
index ffe7e723d9..9fadd0f219 100644
--- a/src/renderer/common/redux/sagas/readiumAnnotation/export.ts
+++ b/src/renderer/common/redux/sagas/readiumAnnotation/export.ts
@@ -13,7 +13,7 @@ import {
publicationAnalyticsEvents,
} from "readium-desktop/common/analytics/publication";
import { convertAnnotationStateArrayToReadiumAnnotationSet } from "readium-desktop/common/readium/annotation/converter";
-import { IReadiumAnnotation, IReadiumAnnotationSet } from "readium-desktop/common/readium/annotation/annotationModel.type";
+import { IReadiumAnnotation, IReadiumAnnotationSet, LEGACY_ANNOTATION_CONTEXT } from "readium-desktop/common/readium/annotation/annotationModel.type";
// https://github.com/janl/mustache.js/issues/797
// export 'render' (imported as 'Mustache') was not found in 'mustache' (possible exports: default)
@@ -30,7 +30,7 @@ import { marked } from "readium-desktop/renderer/common/marked/marked";
import { JsonStringifySortedKeys } from "readium-desktop/common/utils/json";
import { sanitizeForFilename } from "readium-desktop/common/safe-filename";
-import { EXT_ANNOTATIONS } from "readium-desktop/common/extension";
+import { EXT_ANNOTATIONS, EXT_ANNOTATIONS_LEGACY } from "readium-desktop/common/extension";
import { mimeTypes } from "readium-desktop/utils/mimeTypes";
import { logEvent } from "readium-desktop/renderer/common/analytics";
import { createDetachedAnnotationPackage } from "./detachedPackage";
@@ -72,9 +72,9 @@ ${Buffer.from(JsonStringifySortedKeys(readiumAnnotation, 2)).toString("base64")}
-->
`;
};
-const downloadAnnotationFile = (data: string | ArrayBuffer, filenameWithExtension: string, extension: typeof EXT_ANNOTATIONS | ".html") => {
+const downloadAnnotationFile = (data: string | ArrayBuffer, filenameWithExtension: string, extension: typeof EXT_ANNOTATIONS | typeof EXT_ANNOTATIONS_LEGACY | ".html") => {
- const blob = new Blob([data], { type: extension === EXT_ANNOTATIONS ? mimeTypes.annotations : "text/html" });
+ const blob = new Blob([data], { type: extension === EXT_ANNOTATIONS ? mimeTypes.annotations : extension === EXT_ANNOTATIONS_LEGACY ? mimeTypes.annotation : "text/html" });
const jsonObjectUrl = URL.createObjectURL(blob);
const anchorEl = document.createElement("a");
anchorEl.href = jsonObjectUrl;
@@ -91,18 +91,19 @@ export function* exportAnnotationSet(notes: INoteState[], publicationView: Publi
debug("fileType:", fileType);
const locale = yield* selectTyped((state: ICommonRootState) => state.i18n.locale);
- const readiumAnnotationSet = yield* callTyped(() => convertAnnotationStateArrayToReadiumAnnotationSet(locale, notes, publicationView, annoSetTitle));
+ const readiumAnnotationSet = yield* callTyped(() => convertAnnotationStateArrayToReadiumAnnotationSet(locale, notes, publicationView, annoSetTitle, fileType === "html" ? LEGACY_ANNOTATION_CONTEXT : undefined));
debug("readiumAnnotationSet generated, prepare to download it");
const {htmlContent, overrideHTMLTemplate} = (yield* selectTyped((state: ICommonRootState) => state.noteExport));
const htmlMustacheTemplateContent = overrideHTMLTemplate ? htmlContent : noteExportHtmlMustacheTemplate || noteExportHtmlMustacheTemplate;
- const extension = fileType === "annotation" ? EXT_ANNOTATIONS : ".html";
+ const extension = fileType === "annotation" ? (publicationView.isEPUB ? EXT_ANNOTATIONS : EXT_ANNOTATIONS_LEGACY) : ".html";
const serializedAnnotationSet = JsonStringifySortedKeys(readiumAnnotationSet, 2);
const fileData = extension === EXT_ANNOTATIONS ?
yield* callTyped(() => createDetachedAnnotationPackage(serializedAnnotationSet)) :
- yield* callTyped(() => convertReadiumAnnotationSetToHtml(readiumAnnotationSet, __htmlMustacheViewConverterFn, htmlMustacheTemplateContent));
+ extension === EXT_ANNOTATIONS_LEGACY ? serializedAnnotationSet :
+ yield* callTyped(() => convertReadiumAnnotationSetToHtml(readiumAnnotationSet, __htmlMustacheViewConverterFn, htmlMustacheTemplateContent));
const filenameWithExtension = sanitizeForFilename(annoSetTitle + extension);
diff --git a/test/common/readium/annotation/converter.test.ts b/test/common/readium/annotation/converter.test.ts
index 6ab8284bf4..b505d49e65 100644
--- a/test/common/readium/annotation/converter.test.ts
+++ b/test/common/readium/annotation/converter.test.ts
@@ -28,6 +28,7 @@ import { PublicationView } from "readium-desktop/common/views/publication";
const publicationView = {
identifier: "pub-1",
isOpenable: true,
+ isEPUB: true,
readingFinished: false,
documentTitle: "Test publication",
publicationTitle: "Test publication",
@@ -113,6 +114,43 @@ test("custom HTML templates retain the legacy first-tag field without changing J
expect(annotation.body).not.toHaveProperty("tag");
});
+test.each([false, true])("EPUB annotation sets use the W3C context for fixed layout=%s", (isFixedLayoutPublication) => {
+ const annotationSet = convertAnnotationStateArrayToReadiumAnnotationSet("en", [createNote()], {
+ ...publicationView,
+ isFixedLayoutPublication,
+ });
+ expect(annotationSet["@context"]).toBe(EPUB_ANNOTATION_CONTEXT);
+ expect(annotationSet.items[0]["@context"]).toBe(EPUB_ANNOTATION_CONTEXT);
+ expect(annotationSet.items[0].body.tags).toEqual(["tag"]);
+});
+
+test.each([
+ { isAudio: true },
+ { isPDF: true },
+ { isDivina: true },
+ { isDaisy: true },
+ {},
+])("non-EPUB annotation sets retain the legacy JSON model: %j", (format) => {
+ const annotationSet = convertAnnotationStateArrayToReadiumAnnotationSet("en", [createNote()], {
+ ...publicationView,
+ isEPUB: false,
+ ...format,
+ });
+ expect(annotationSet["@context"]).toBe(LEGACY_ANNOTATION_CONTEXT);
+ expect(annotationSet.items[0]["@context"]).toBe(LEGACY_ANNOTATION_CONTEXT);
+ expect(annotationSet.items[0].body.tag).toBe("tag");
+ expect(annotationSet.items[0].body).not.toHaveProperty("tags");
+ expect(isIReadiumAnnotationSet(annotationSet)).toBe(true);
+});
+
+test("HTML export can retain the legacy model for EPUB", () => {
+ const annotationSet = convertAnnotationStateArrayToReadiumAnnotationSet("en", [createNote()], publicationView, "HTML", LEGACY_ANNOTATION_CONTEXT);
+ expect(annotationSet["@context"]).toBe(LEGACY_ANNOTATION_CONTEXT);
+ expect(Mustache.render("{{#body.tags}}Tag: {{.}}{{/body.tags}}", {
+ body: annotationHtmlBody(annotationSet.items[0]),
+ })).toBe("Tag: tag");
+});
+
test("Readium annotation conversion skips PDF annotations", () => {
const pdfAnnotation = createNote({
uuid: "pdf-note",