Skip to content

Commit

Permalink
Add support for importing KOReader & Calibre EPUB annotations
Browse files Browse the repository at this point in the history
  • Loading branch information
AbeJellinek authored and dstillman committed Nov 8, 2024
1 parent c1a538a commit d600ec5
Show file tree
Hide file tree
Showing 9 changed files with 367 additions and 5 deletions.
27 changes: 27 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"classnames": "^2.3.1",
"darkreader": "^4.9.83",
"epubjs": "file:epubjs/epub.js",
"luaparse": "^0.3.1",
"postcss-selector-parser": "^6.0.13",
"prop-types": "^15.8.1",
"queue": "^6.0.2",
Expand All @@ -37,6 +38,7 @@
"@babel/preset-typescript": "^7.24.7",
"@babel/runtime": "^7.18.9",
"@svgr/webpack": "^8.1.0",
"@types/luaparse": "^0.2.12",
"@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.49.0",
Expand Down
16 changes: 16 additions & 0 deletions src/common/reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,22 @@ class Reader {
return this._annotationManager.mergeAnnotations(ids);
}

/**
* @param {BufferSource} metadata
*/
importAnnotationsFromKOReaderMetadata(metadata) {
this._ensureType('epub');
this._primaryView.importAnnotationsFromKOReaderMetadata(metadata);
}

/**
* @param {string} metadata
*/
importAnnotationsFromCalibreMetadata(metadata) {
this._ensureType('epub');
this._primaryView.importAnnotationsFromCalibreMetadata(metadata);
}

/**
* Trigger copying inside the currently focused iframe or the main window
*/
Expand Down
2 changes: 1 addition & 1 deletion src/dom/common/lib/range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export class PersistentRange {

endOffset: number;

constructor(range: AbstractRange) {
constructor(range: Omit<AbstractRange, 'collapsed'>) {
this.startContainer = range.startContainer;
this.startOffset = range.startOffset;
this.endContainer = range.endContainer;
Expand Down
97 changes: 96 additions & 1 deletion src/dom/epub/epub-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ import {
ScrolledFlow
} from "./flow";
import { DEFAULT_EPUB_APPEARANCE, RTL_SCRIPTS } from "./defines";
import { parseAnnotationsFromKOReaderMetadata, koReaderAnnotationToRange } from "./lib/koreader";
import { ANNOTATION_COLORS } from "../../common/defines";
import { calibreAnnotationToRange, parseAnnotationsFromCalibreMetadata } from "./lib/calibre";

class EPUBView extends DOMView<EPUBViewState, EPUBViewData> {
protected _find: EPUBFindProcessor | null = null;
Expand Down Expand Up @@ -351,7 +354,7 @@ class EPUBView extends DOMView<EPUBViewState, EPUBViewData> {
return {
type: 'FragmentSelector',
conformsTo: FragmentSelectorConformsTo.EPUB3,
value: cfi.toString()
value: cfi.toString(true)
};
}

Expand Down Expand Up @@ -472,6 +475,98 @@ class EPUBView extends DOMView<EPUBViewState, EPUBViewData> {
};
}

private _upsertAnnotation(annotation: NewAnnotation<WADMAnnotation>) {
let existingAnnotation = this._annotations.find(
existingAnnotation => existingAnnotation.text === annotation!.text
&& existingAnnotation.sortIndex === annotation!.sortIndex
);
if (existingAnnotation) {
this._options.onUpdateAnnotations([{
...existingAnnotation,
comment: annotation.comment,
}]);
}
else {
this._options.onAddAnnotation(annotation);
}
}

importAnnotationsFromKOReaderMetadata(metadata: BufferSource) {
for (let koReaderAnnotation of parseAnnotationsFromKOReaderMetadata(metadata)) {
let range = koReaderAnnotationToRange(koReaderAnnotation, this._sectionRenderers);
if (!range) {
console.warn('Unable to resolve annotation', koReaderAnnotation);
continue;
}

let annotation = this._getAnnotationFromRange(
range,
'highlight',
ANNOTATION_COLORS[0][1] // Yellow
);
if (!annotation) {
console.warn('Unable to resolve range', koReaderAnnotation);
continue;
}
annotation.comment = koReaderAnnotation.note;

this._upsertAnnotation(annotation);
}
}

importAnnotationsFromCalibreMetadata(metadata: string) {
for (let calibreAnnotation of parseAnnotationsFromCalibreMetadata(metadata)) {
let range = calibreAnnotationToRange(calibreAnnotation, this._sectionRenderers);
if (!range) {
console.warn('Unable to resolve annotation', calibreAnnotation);
continue;
}

let type: 'highlight' | 'underline' = 'highlight';
let color = ANNOTATION_COLORS[0][1]; // Default to yellow
switch (calibreAnnotation.style?.kind) {
case 'color':
switch (calibreAnnotation.style.which) {
case 'green':
color = ANNOTATION_COLORS[2][1];
break;
case 'blue':
color = ANNOTATION_COLORS[3][1];
break;
case 'purple':
color = ANNOTATION_COLORS[4][1];
break;
case 'pink':
color = ANNOTATION_COLORS[5][1];
break;
case 'yellow':
default:
break;
}
break;
case 'decoration':
switch (calibreAnnotation.style.which) {
case 'strikeout':
color = ANNOTATION_COLORS[1][1]; // Red highlight as a stand-in
break;
case 'wavy':
type = 'underline';
break;
}
break;
}

let annotation = this._getAnnotationFromRange(range, type, color);
if (!annotation) {
console.warn('Unable to resolve range', calibreAnnotation);
continue;
}
annotation.comment = calibreAnnotation.notes || '';

this._upsertAnnotation(annotation);
}
}

// ***
// Event handlers
// ***
Expand Down
71 changes: 71 additions & 0 deletions src/dom/epub/lib/calibre.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { EpubCFI } from "epubjs";
import SectionRenderer from "../section-renderer";
import { lengthenCFI } from "../cfi";

export function calibreAnnotationToRange(annotation: CalibreAnnotation, sectionRenderers: SectionRenderer[]): Range | null {
let sectionRenderer = sectionRenderers[annotation.spine_index];
if (!sectionRenderer) {
return null;
}

// Calibre CFIs are basically valid EPUB CFIs, but they're missing the step
// indirection part, and they have an extra leading /2 (first element child)
// selector because they're relative to the root of the document instead of
// its root element.
// Some simple cleanup should make them parse correctly.
let startCFI = new EpubCFI(lengthenCFI(
sectionRenderer.section.cfiBase + '!'
+ annotation.start_cfi.replace(/^\/2\//, '/')));
let endCFI = new EpubCFI(lengthenCFI(
sectionRenderer.section.cfiBase + '!'
+ annotation.end_cfi.replace(/^\/2\//, '/')));
try {
let startRange = startCFI.toRange(sectionRenderer.container.ownerDocument, undefined, sectionRenderer.container);
let endRange = endCFI.toRange(sectionRenderer.container.ownerDocument, undefined, sectionRenderer.container);
startRange.setEnd(endRange.endContainer, endRange.endOffset);
return startRange;
}
catch (e) {
console.error(e);
return null;
}
}

export function parseAnnotationsFromCalibreMetadata(metadata: string) {
let doc = new DOMParser().parseFromString(metadata, 'text/xml');
let annotationMetas = doc.querySelectorAll('meta[name="calibre:annotation"][content]');
let calibreAnnotations: CalibreAnnotation[] = [];
for (let annotationMeta of annotationMetas) {
let annotation;
try {
annotation = JSON.parse(annotationMeta.getAttribute('content')!);
}
catch (e) {
console.error(e);
continue;
}
if (annotation.format !== 'EPUB'
|| 'removed' in annotation.annotation && annotation.annotation.removed
|| annotation.annotation.type !== 'highlight') {
continue;
}
calibreAnnotations.push(annotation.annotation);
}
return calibreAnnotations;
}

export type CalibreAnnotation = {
// There's more in the metadata, but these are all we need
type: 'highlight';
spine_index: number;
start_cfi: string;
end_cfi: string;
notes?: string;
style?: {
kind: 'color';
which: 'yellow' | 'green' | 'blue' | 'pink' | 'purple' | string;
} | {
kind: 'decoration';
which: 'wavy' | 'strikeout' | string;
};
};
Loading

0 comments on commit d600ec5

Please sign in to comment.