From 93a0caee413400112949853e498a5a09b0c2f88d Mon Sep 17 00:00:00 2001 From: chrox Date: Wed, 30 Oct 2024 14:57:34 +0100 Subject: [PATCH 01/10] Several patches to support older version of browser 1. Avoid the use of Lookbehind regex which is not supported on WebKit version below 614.3.7.1.5; 2. Polyfill CSSStyleSheet constructor which is not supported on WebKit version below 615.1.26.11.22; 3. Avoid the use of module top-level await which is not supported on WebKit version below 613.3.9.1.16; 4. Use legacy build of pdfjs instead of the bundled pdfjs in the vendor directory: * Web Workers loading scripts in pdfjsPath now from the base / that's the public directory of a Next.js project; * Use @pdfjs instead of hard coding dist files path of pdfjs so that we can alias @pdfjs to the public directory; --- epub.js | 2 +- fixed-layout.js | 2 ++ package-lock.json | 9 +++++++++ package.json | 5 ++++- pdf.js | 21 +++++++++++++-------- 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/epub.js b/epub.js index 378ffd8..5452012 100644 --- a/epub.js +++ b/epub.js @@ -859,7 +859,7 @@ class Loader { const h = window?.innerHeight ?? 600 return replacedImports // unprefix as most of the props are (only) supported unprefixed - .replace(/(?<=[{\s;])-epub-/gi, '') + .replace(/([{\s;])-epub-/gi, '$1') // replace vw and vh as they cause problems with layout .replace(/(\d*\.?\d+)vw/gi, (_, d) => parseFloat(d) * w / 100 + 'px') .replace(/(\d*\.?\d+)vh/gi, (_, d) => parseFloat(d) * h / 100 + 'px') diff --git a/fixed-layout.js b/fixed-layout.js index b2adeba..b648a0d 100644 --- a/fixed-layout.js +++ b/fixed-layout.js @@ -1,3 +1,5 @@ +import 'construct-style-sheets-polyfill' + const parseViewport = str => str ?.split(/[,;\s]/) // NOTE: technically, only the comma is valid ?.filter(x => x) diff --git a/package-lock.json b/package-lock.json index 2341a20..a4fc3c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "foliate-js", "version": "0.0.0", "license": "MIT", + "dependencies": { + "construct-style-sheets-polyfill": "^3.1.0" + }, "devDependencies": { "@eslint/js": "^9.9.1", "@rollup/plugin-node-resolve": "^15.2.3", @@ -596,6 +599,12 @@ "license": "ISC", "optional": true }, + "node_modules/construct-style-sheets-polyfill": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/construct-style-sheets-polyfill/-/construct-style-sheets-polyfill-3.1.0.tgz", + "integrity": "sha512-HBLKP0chz8BAY6rBdzda11c3wAZeCZ+kIG4weVC2NM3AXzxx09nhe8t0SQNdloAvg5GLuHwq/0SPOOSPvtCcKw==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", diff --git a/package.json b/package.json index e457e98..2ca62bf 100644 --- a/package.json +++ b/package.json @@ -43,5 +43,8 @@ "dictd", "stardict", "opds" - ] + ], + "dependencies": { + "construct-style-sheets-polyfill": "^3.1.0" + } } diff --git a/pdf.js b/pdf.js index 5abf583..32d0fa4 100644 --- a/pdf.js +++ b/pdf.js @@ -1,16 +1,13 @@ -const pdfjsPath = path => new URL(`vendor/pdfjs/${path}`, import.meta.url).toString() +const pdfjsPath = path => `/vendor/pdfjs/${path}` -import './vendor/pdfjs/pdf.mjs' +import '@pdfjs/pdf.mjs' const pdfjsLib = globalThis.pdfjsLib -pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath('pdf.worker.mjs') +pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath('pdf.worker.min.mjs') const fetchText = async url => await (await fetch(url)).text() -// https://github.com/mozilla/pdf.js/blob/642b9a5ae67ef642b9a8808fd9efd447e8c350e2/web/text_layer_builder.css -const textLayerBuilderCSS = await fetchText(pdfjsPath('text_layer_builder.css')) - -// https://github.com/mozilla/pdf.js/blob/642b9a5ae67ef642b9a8808fd9efd447e8c350e2/web/annotation_layer_builder.css -const annotationLayerBuilderCSS = await fetchText(pdfjsPath('annotation_layer_builder.css')) +let textLayerBuilderCSS = null +let annotationLayerBuilderCSS = null const render = async (page, doc, zoom) => { const scale = zoom * devicePixelRatio @@ -77,6 +74,14 @@ const renderPage = async (page, getImageBlob) => { await page.render({ canvasContext, viewport }).promise return new Promise(resolve => canvas.toBlob(resolve)) } + // https://github.com/mozilla/pdf.js/blob/642b9a5ae67ef642b9a8808fd9efd447e8c350e2/web/text_layer_builder.css + if (textLayerBuilderCSS == null) { + textLayerBuilderCSS = await fetchText(pdfjsPath('text_layer_builder.css')) + } + // https://github.com/mozilla/pdf.js/blob/642b9a5ae67ef642b9a8808fd9efd447e8c350e2/web/annotation_layer_builder.css + if (annotationLayerBuilderCSS == null) { + annotationLayerBuilderCSS = await fetchText(pdfjsPath('annotation_layer_builder.css')) + } const src = URL.createObjectURL(new Blob([` From 8997224d73c4ef4927de5ba287c68e167e46a79f Mon Sep 17 00:00:00 2001 From: chrox Date: Sat, 28 Dec 2024 23:20:45 +0100 Subject: [PATCH 02/10] Dismiss iframe background since it's replaced with a root background also scale background size --- paginator.js | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/paginator.js b/paginator.js index 0dd424b..e35fecc 100644 --- a/paginator.js +++ b/paginator.js @@ -250,7 +250,9 @@ class View { // it needs to be visible for Firefox to get computed style this.#iframe.style.display = 'block' const { vertical, rtl } = getDirection(doc) - const background = getBackground(doc) + this.docBackground = getBackground(doc) + doc.body.style.background = 'none' + const background = this.docBackground this.#iframe.style.display = 'none' this.#vertical = vertical @@ -610,7 +612,7 @@ export class Paginator extends HTMLElement { this.#mediaQueryListener = () => { if (!this.#view) return - this.#background.style.background = getBackground(this.#view.document) + this.#replaceBackground(this.#view.docBackground, this.columnCount) } this.#mediaQuery.addEventListener('change', this.#mediaQueryListener) } @@ -648,15 +650,31 @@ export class Paginator extends HTMLElement { this.#container.append(this.#view.element) return this.#view } + #replaceBackground(background, columnCount) { + const doc = this.#view?.document + const htmlStyle = doc.defaultView.getComputedStyle(doc.documentElement) + const themeBgColor = htmlStyle.getPropertyValue('--theme-bg-color') + if (background && themeBgColor) { + const parsedBackground = background.split(/\s(?=(?:url|rgb|hsl|#[0-9a-fA-F]{3,6}))/) + parsedBackground[0] = themeBgColor + background = parsedBackground.join(' ') + } + this.#background.innerHTML = '' + this.#background.style.display = 'grid' + this.#background.style.gridTemplateColumns = `repeat(${columnCount}, 1fr)` + for (let i = 0; i < columnCount; i++) { + const column = document.createElement('div') + column.style.background = background + column.style.width = '100%' + column.style.height = '100%' + this.#background.appendChild(column) + } + } #beforeRender({ vertical, rtl, background }) { this.#vertical = vertical this.#rtl = rtl this.#top.classList.toggle('vertical', vertical) - // set background to `doc` background - // this is needed because the iframe does not fill the whole element - this.#background.style.background = background - const { width, height } = this.#container.getBoundingClientRect() const size = vertical ? height : width @@ -705,6 +723,11 @@ export class Paginator extends HTMLElement { const columnWidth = (size / divisor) - gap this.setAttribute('dir', rtl ? 'rtl' : 'ltr') + // set background to `doc` background + // this is needed because the iframe does not fill the whole element + this.columnCount = divisor + this.#replaceBackground(background, this.columnCount) + const marginalDivisor = vertical ? Math.min(2, Math.ceil(width / maxInlineSize)) : divisor @@ -1082,8 +1105,9 @@ export class Paginator extends HTMLElement { } else $style.textContent = styles // NOTE: needs `requestAnimationFrame` in Chromium - requestAnimationFrame(() => - this.#background.style.background = getBackground(this.#view.document)) + requestAnimationFrame(() => { + this.#replaceBackground(this.#view.docBackground, this.columnCount) + }) // needed because the resize observer doesn't work in Firefox this.#view?.document?.fonts?.ready?.then(() => this.#view.expand()) From fcc449c08909bdaa008cd03e114a846d49e7c118 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sun, 29 Dec 2024 22:11:58 +0100 Subject: [PATCH 03/10] Support specific cases where the cover is not included in the meta or guide sections --- epub.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/epub.js b/epub.js index 5452012..339942c 100644 --- a/epub.js +++ b/epub.js @@ -671,6 +671,8 @@ class Resources { ?.getAttribute('content')) ?? this.getItemByHref(this.guide ?.find(ref => ref.type.includes('cover'))?.href) + ?? this.manifest.find(item => item.href.includes('cover') + && item.mediaType.startsWith('image')) this.cfis = CFI.fromElements($$itemref) } From 3bdbd3d015145a1adc455caf3b8c0452eea90da2 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Tue, 7 Jan 2025 21:09:04 +0100 Subject: [PATCH 04/10] Init view TTS with text segmentation granularity in order to support more TTS backends --- text-walker.js | 2 +- tts.js | 10 +++++----- view.js | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/text-walker.js b/text-walker.js index 3e2c44e..62518aa 100644 --- a/text-walker.js +++ b/text-walker.js @@ -32,7 +32,7 @@ export const textWalker = function* (x, func) { const walker = document.createTreeWalker(root, filter, { acceptNode }) const walk = x.commonAncestorContainer ? walkRange : walkDocument const nodes = walk(x, walker) - const strs = nodes.map(node => node.nodeValue) + const strs = nodes.map(node => node.nodeValue ?? '') const makeRange = (startIndex, startOffset, endIndex, endOffset) => { const range = document.createRange() range.setStart(nodes[startIndex], startOffset) diff --git a/tts.js b/tts.js index 0089ed3..9a695af 100644 --- a/tts.js +++ b/tts.js @@ -25,7 +25,7 @@ const getSegmenter = (lang = 'en', granularity = 'word') => { const segmenter = new Intl.Segmenter(lang, { granularity }) const granularityIsWord = granularity === 'word' return function* (strs, makeRange) { - const str = strs.join('') + const str = strs.join('').replace(/\r\n/g, ' ').replace(/\r/g, ' ').replace(/\n/g, ' ') let name = 0 let strIndex = -1 let sum = 0 @@ -34,10 +34,10 @@ const getSegmenter = (lang = 'en', granularity = 'word') => { while (sum <= index) sum += strs[++strIndex].length const startIndex = strIndex const startOffset = index - (sum - strs[strIndex].length) - const end = index + segment.length + const end = index + segment.length - 1 if (end < str.length) while (sum <= end) sum += strs[++strIndex].length const endIndex = strIndex - const endOffset = end - (sum - strs[strIndex].length) + const endOffset = end - (sum - strs[strIndex].length) + 1 yield [(name++).toString(), makeRange(startIndex, startOffset, endIndex, endOffset)] } @@ -207,11 +207,11 @@ export class TTS { #ranges #lastMark #serializer = new XMLSerializer() - constructor(doc, textWalker, highlight) { + constructor(doc, textWalker, highlight, granularity) { this.doc = doc this.highlight = highlight this.#list = new ListIterator(getBlocks(doc), range => { - const { entries, ssml } = getFragmentWithMarks(range, textWalker) + const { entries, ssml } = getFragmentWithMarks(range, textWalker, granularity) this.#ranges = new Map(entries) return [ssml, range] }) diff --git a/view.js b/view.js index 17566ac..55c818f 100644 --- a/view.js +++ b/view.js @@ -577,12 +577,12 @@ export class View extends HTMLElement { for (const item of list) this.deleteAnnotation(item) this.#searchResults.clear() } - async initTTS() { + async initTTS(granularity = 'word') { const doc = this.renderer.getContents()[0].doc if (this.tts && this.tts.doc === doc) return const { TTS } = await import('./tts.js') this.tts = new TTS(doc, textWalker, range => - this.renderer.scrollToAnchor(range, true)) + this.renderer.scrollToAnchor(range, true), granularity) } startMediaOverlay() { const { index } = this.renderer.getContents()[0] From b9347d80e8df7ba91e599899289f55f1ec2f5cdc Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 11 Jan 2025 17:58:02 +0100 Subject: [PATCH 05/10] Make sync pre/next possible in TTS --- paginator.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/paginator.js b/paginator.js index e35fecc..babe551 100644 --- a/paginator.js +++ b/paginator.js @@ -1065,11 +1065,11 @@ export class Paginator extends HTMLElement { if (shouldGo || !this.hasAttribute('animated')) await wait(100) this.#locked = false } - prev(distance) { - return this.#turnPage(-1, distance) + async prev(distance) { + return await this.#turnPage(-1, distance) } - next(distance) { - return this.#turnPage(1, distance) + async next(distance) { + return await this.#turnPage(1, distance) } prevSection() { return this.goTo({ index: this.#adjacentIndex(-1) }) From 45d676baab0c49a90d470707b622e031f53f3073 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 24 Jan 2025 12:48:36 +0100 Subject: [PATCH 06/10] Fix scrolling when selecting text in iOS browsers --- paginator.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/paginator.js b/paginator.js index babe551..f809a21 100644 --- a/paginator.js +++ b/paginator.js @@ -833,6 +833,11 @@ export class Paginator extends HTMLElement { if (this.#touchScrolled) e.preventDefault() return } + const doc = this.#view?.document + const selection = doc?.getSelection() + if (selection && selection.rangeCount > 0 && !selection.isCollapsed) { + return + } e.preventDefault() const touch = e.changedTouches[0] const x = touch.screenX, y = touch.screenY From fe8a7593cb3414536aeabd96c2d505415769b01a Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 25 Jan 2025 20:04:30 +0100 Subject: [PATCH 07/10] Introduced stricter angle thresholds to differentiate between horizontal and vertical swipes --- paginator.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/paginator.js b/paginator.js index f809a21..8b8d208 100644 --- a/paginator.js +++ b/paginator.js @@ -846,10 +846,21 @@ export class Paginator extends HTMLElement { state.x = x state.y = y state.t = e.timeStamp - state.vx = dx / dt - state.vy = dy / dt - this.#touchScrolled = true - this.scrollBy(dx, dy) + + const HORIZONTAL_THRESHOLD = 30 + const VERTICAL_THRESHOLD = 30 + const angle = Math.abs(Math.atan2(dy, dx) * (180 / Math.PI)) + if (angle < HORIZONTAL_THRESHOLD || angle > 180 - HORIZONTAL_THRESHOLD) { + state.vx = dx / dt + state.vy = dy / dt + this.#touchScrolled = true + this.scrollBy(dx, 0) + } else if (angle > 90 - VERTICAL_THRESHOLD && angle < 90 + VERTICAL_THRESHOLD) { + state.vx = dx / dt + state.vy = dy / dt + this.#touchScrolled = true + this.scrollBy(0, dy) + } } #onTouchEnd() { this.#touchScrolled = false From 076be887e367748fb876b8c419d96c729abc6a35 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 1 Feb 2025 14:31:28 +0100 Subject: [PATCH 08/10] Use more robust way to determine cover image for epub2 --- epub.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/epub.js b/epub.js index 339942c..72915ff 100644 --- a/epub.js +++ b/epub.js @@ -669,10 +669,10 @@ class Resources { ?? this.getItemByID($$$(opf, 'meta') .find(filterAttribute('name', 'cover')) ?.getAttribute('content')) - ?? this.getItemByHref(this.guide - ?.find(ref => ref.type.includes('cover'))?.href) ?? this.manifest.find(item => item.href.includes('cover') && item.mediaType.startsWith('image')) + ?? this.getItemByHref(this.guide + ?.find(ref => ref.type.includes('cover'))?.href) this.cfis = CFI.fromElements($$itemref) } From de5c61bc2cc9867526a65540857a8b3c81868267 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Tue, 4 Feb 2025 19:23:54 +0100 Subject: [PATCH 09/10] Compatibility of getClientRects in overlays considering zoom --- overlayer.js | 31 ++++++++++++++++++++++++++++--- view.js | 2 +- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/overlayer.js b/overlayer.js index 6fd03ab..b2546b7 100644 --- a/overlayer.js +++ b/overlayer.js @@ -4,7 +4,9 @@ const createSVGElement = tag => export class Overlayer { #svg = createSVGElement('svg') #map = new Map() - constructor() { + #doc = null + constructor(doc) { + this.#doc = doc Object.assign(this.#svg.style, { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', @@ -14,10 +16,25 @@ export class Overlayer { get element() { return this.#svg } + get #zoom() { + // Safari does not zoom the client rects, while Chrome, Edge and Firefox does + if (/^((?!chrome|android).)*AppleWebKit/i.test(navigator.userAgent) && !window.chrome) { + return window.getComputedStyle(this.#doc.body).zoom || 1.0 + } + return 1.0 + } add(key, range, draw, options) { if (this.#map.has(key)) this.remove(key) if (typeof range === 'function') range = range(this.#svg.getRootNode()) - const rects = range.getClientRects() + const zoom = this.#zoom + const rects = Array.from(range.getClientRects()).map(rect => ({ + left: rect.left * zoom, + top: rect.top * zoom, + right: rect.right * zoom, + bottom: rect.bottom * zoom, + width: rect.width * zoom, + height: rect.height * zoom, + })) const element = draw(rects, options) this.#svg.append(element) this.#map.set(key, { range, draw, options, element, rects }) @@ -31,7 +48,15 @@ export class Overlayer { for (const obj of this.#map.values()) { const { range, draw, options, element } = obj this.#svg.removeChild(element) - const rects = range.getClientRects() + const zoom = this.#zoom + const rects = Array.from(range.getClientRects()).map(rect => ({ + left: rect.left * zoom, + top: rect.top * zoom, + right: rect.right * zoom, + bottom: rect.bottom * zoom, + width: rect.width * zoom, + height: rect.height * zoom, + })) const el = draw(rects, options) this.#svg.append(el) obj.element = el diff --git a/view.js b/view.js index 55c818f..9a1168d 100644 --- a/view.js +++ b/view.js @@ -402,7 +402,7 @@ export class View extends HTMLElement { .find(x => x.index === index && x.overlayer) } #createOverlayer({ doc, index }) { - const overlayer = new Overlayer() + const overlayer = new Overlayer(doc) doc.addEventListener('click', e => { const [value, range] = overlayer.hitTest(e) if (value && !value.startsWith(SEARCH_PREFIX)) { From 10541e5e4013f8dc5532a5a75ab097d7a7bfe475 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Tue, 4 Feb 2025 22:07:00 +0100 Subject: [PATCH 10/10] Split range by paragraphs to avoid over-highlighting on some blank space --- overlayer.js | 61 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/overlayer.js b/overlayer.js index b2546b7..b2cf452 100644 --- a/overlayer.js +++ b/overlayer.js @@ -23,18 +23,43 @@ export class Overlayer { } return 1.0 } + #splitRangeByParagraph(range) { + const ancestor = range.commonAncestorContainer + const paragraphs = Array.from(ancestor.querySelectorAll?.('p') || []) + if (paragraphs.length === 0) return [range] + + const splitRanges = [] + paragraphs.forEach((p) => { + const pRange = document.createRange() + if (range.intersectsNode(p)) { + pRange.selectNodeContents(p) + if (pRange.compareBoundaryPoints(Range.START_TO_START, range) < 0) { + pRange.setStart(range.startContainer, range.startOffset) + } + if (pRange.compareBoundaryPoints(Range.END_TO_END, range) > 0) { + pRange.setEnd(range.endContainer, range.endOffset) + } + splitRanges.push(pRange) + } + }) + return splitRanges + } add(key, range, draw, options) { if (this.#map.has(key)) this.remove(key) if (typeof range === 'function') range = range(this.#svg.getRootNode()) const zoom = this.#zoom - const rects = Array.from(range.getClientRects()).map(rect => ({ - left: rect.left * zoom, - top: rect.top * zoom, - right: rect.right * zoom, - bottom: rect.bottom * zoom, - width: rect.width * zoom, - height: rect.height * zoom, - })) + let rects = [] + this.#splitRangeByParagraph(range).forEach((pRange) => { + const pRects = Array.from(pRange.getClientRects()).map(rect => ({ + left: rect.left * zoom, + top: rect.top * zoom, + right: rect.right * zoom, + bottom: rect.bottom * zoom, + width: rect.width * zoom, + height: rect.height * zoom, + })) + rects = rects.concat(pRects) + }) const element = draw(rects, options) this.#svg.append(element) this.#map.set(key, { range, draw, options, element, rects }) @@ -49,14 +74,18 @@ export class Overlayer { const { range, draw, options, element } = obj this.#svg.removeChild(element) const zoom = this.#zoom - const rects = Array.from(range.getClientRects()).map(rect => ({ - left: rect.left * zoom, - top: rect.top * zoom, - right: rect.right * zoom, - bottom: rect.bottom * zoom, - width: rect.width * zoom, - height: rect.height * zoom, - })) + let rects = [] + this.#splitRangeByParagraph(range).forEach((pRange) => { + const pRects = Array.from(pRange.getClientRects()).map(rect => ({ + left: rect.left * zoom, + top: rect.top * zoom, + right: rect.right * zoom, + bottom: rect.bottom * zoom, + width: rect.width * zoom, + height: rect.height * zoom, + })) + rects = rects.concat(pRects) + }) const el = draw(rects, options) this.#svg.append(el) obj.element = el