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",