mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Fix PDF viewer crash on older macOS WebKit (#730)
This commit is contained in:
1 parent
7890281db3
commit
b4417add33
6 files changed
+352
-7
No files matched your search
@@ -5,13 +5,11 @@ import type { PDFDocumentProxy } from "pdfjs-dist";
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { Document, Page, pdfjs } from "react-pdf";
|
import { Document, Page, pdfjs } from "react-pdf";
|
||||||
import { useContainerSize } from "@yaakapp-internal/ui";
|
import { useContainerSize } from "@yaakapp-internal/ui";
|
||||||
|
import pdfWorkerUrl from "./pdfWorker?worker&url";
|
||||||
|
|
||||||
// Document can start loading during render, so configure its worker synchronously.
|
// Document can start loading during render, so configure its worker synchronously.
|
||||||
// Vite bundles the installed PDF.js worker as a local asset, including offline builds.
|
// Vite bundles the installed PDF.js worker as a local asset, including offline builds.
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
pdfjs.GlobalWorkerOptions.workerSrc = new URL(pdfWorkerUrl, import.meta.url).toString();
|
||||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
|
||||||
import.meta.url,
|
|
||||||
).toString();
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** A URL for the body the host already stored. */
|
/** A URL for the body the host already stored. */
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// The worker has its own globals, so it needs the polyfills before PDF.js evaluates
|
||||||
|
import "../../polyfills";
|
||||||
|
import "pdfjs-dist/build/pdf.worker.min.mjs";
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
<div id="cm-portal" class="cm-portal"></div>
|
<div id="cm-portal" class="cm-portal"></div>
|
||||||
<div id="react-portal"></div>
|
<div id="react-portal"></div>
|
||||||
<div id="radix-portal" class="cm-portal"></div>
|
<div id="radix-portal" class="cm-portal"></div>
|
||||||
|
<script type="module" src="/polyfills.ts"></script>
|
||||||
<script type="module" src="/theme.ts"></script>
|
<script type="module" src="/theme.ts"></script>
|
||||||
<script type="module" src="/font-size.ts"></script>
|
<script type="module" src="/font-size.ts"></script>
|
||||||
<script type="module" src="/font.ts"></script>
|
<script type="module" src="/font.ts"></script>
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, expect, test, vi } from "vite-plus/test";
|
||||||
|
import { getOrInsert, getOrInsertComputed, readableStreamValues, sumPrecise } from "./polyfills";
|
||||||
|
|
||||||
|
describe("getOrInsert", () => {
|
||||||
|
test("returns the existing value without replacing it", () => {
|
||||||
|
const map = new Map([["a", 1]]);
|
||||||
|
expect(getOrInsert.call(map, "a", 2)).toBe(1);
|
||||||
|
expect(map.get("a")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inserts and returns the value for a missing key", () => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
expect(getOrInsert.call(map, "a", 2)).toBe(2);
|
||||||
|
expect(map.get("a")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("works on a WeakMap", () => {
|
||||||
|
const key = {};
|
||||||
|
const map = new WeakMap<object, number>();
|
||||||
|
expect(getOrInsert.call(map, key, 1)).toBe(1);
|
||||||
|
expect(getOrInsert.call(map, key, 2)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getOrInsertComputed", () => {
|
||||||
|
test("returns the existing value without calling the callback", () => {
|
||||||
|
const map = new Map([["a", 1]]);
|
||||||
|
const callback = vi.fn(() => 2);
|
||||||
|
expect(getOrInsertComputed.call(map, "a", callback)).toBe(1);
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calls the callback with the key and inserts its result", () => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
expect(getOrInsertComputed.call(map, "a", (key) => `${String(key)}!`)).toBe("a!");
|
||||||
|
expect(map.get("a")).toBe("a!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stores what the callback returns even if the callback set the key", () => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
const result = getOrInsertComputed.call(map, "a", () => {
|
||||||
|
map.set("a", 1);
|
||||||
|
return 2;
|
||||||
|
});
|
||||||
|
expect(result).toBe(2);
|
||||||
|
expect(map.get("a")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canonicalizes -0 to +0", () => {
|
||||||
|
const map = new Map<number, number>();
|
||||||
|
getOrInsertComputed.call(map, -0, (key) => {
|
||||||
|
expect(Object.is(key, 0)).toBe(true);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
expect(Object.is([...map.keys()][0], 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when the callback is not a function, even for an existing key", () => {
|
||||||
|
const map = new Map([["a", 1]]);
|
||||||
|
// @ts-expect-error testing a non-callable callback
|
||||||
|
expect(() => getOrInsertComputed.call(map, "a", 1)).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is installed on Map and WeakMap", () => {
|
||||||
|
const map = new Map<string, number[]>();
|
||||||
|
map.getOrInsertComputed("a", () => []).push(1);
|
||||||
|
map.getOrInsertComputed("a", () => []).push(2);
|
||||||
|
expect(map.get("a")).toEqual([1, 2]);
|
||||||
|
|
||||||
|
const key = {};
|
||||||
|
const weakMap = new WeakMap<object, number>();
|
||||||
|
expect(weakMap.getOrInsertComputed(key, () => 1)).toBe(1);
|
||||||
|
expect(weakMap.getOrInsert(key, 2)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sumPrecise", () => {
|
||||||
|
test("sums without intermediate rounding", () => {
|
||||||
|
expect(sumPrecise([1e20, 0.1, -1e20])).toBe(0.1);
|
||||||
|
expect(sumPrecise([0.1, 0.2, 0.3])).toBe(0.6);
|
||||||
|
expect(sumPrecise(Array.from({ length: 10 }, () => 0.1))).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rounds half to even on the final step", () => {
|
||||||
|
expect(sumPrecise([1, 1e-16, 1e-16])).toBe(1.0000000000000002);
|
||||||
|
expect(sumPrecise([1, 2 ** -53])).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles zeroes, infinities and NaN like the spec", () => {
|
||||||
|
expect(Object.is(sumPrecise([]), -0)).toBe(true);
|
||||||
|
expect(Object.is(sumPrecise([-0, -0]), -0)).toBe(true);
|
||||||
|
expect(Object.is(sumPrecise([-0, 0]), 0)).toBe(true);
|
||||||
|
expect(Object.is(sumPrecise([1, -1]), 0)).toBe(true);
|
||||||
|
expect(sumPrecise([1, Infinity])).toBe(Infinity);
|
||||||
|
expect(sumPrecise([1, -Infinity])).toBe(-Infinity);
|
||||||
|
expect(sumPrecise([Infinity, -Infinity])).toBeNaN();
|
||||||
|
expect(sumPrecise([1, NaN])).toBeNaN();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts any iterable and rejects non-numbers", () => {
|
||||||
|
expect(sumPrecise(new Set([1, 2, 3]))).toBe(6);
|
||||||
|
// @ts-expect-error testing a non-number item
|
||||||
|
expect(() => sumPrecise([1, "2"])).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is installed on Math", () => {
|
||||||
|
expect(Math.sumPrecise([1, 2, 3])).toBe(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readableStreamValues", () => {
|
||||||
|
const values = readableStreamValues<number>;
|
||||||
|
|
||||||
|
function streamOf(chunks: number[], onCancel?: () => void) {
|
||||||
|
return new ReadableStream<number>({
|
||||||
|
start(controller) {
|
||||||
|
for (const chunk of chunks) controller.enqueue(chunk);
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
cancel: onCancel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("yields every chunk and releases the lock", async () => {
|
||||||
|
const stream = streamOf([1, 2, 3]);
|
||||||
|
const seen: number[] = [];
|
||||||
|
for await (const chunk of values.call(stream)) seen.push(chunk);
|
||||||
|
expect(seen).toEqual([1, 2, 3]);
|
||||||
|
expect(stream.locked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancels the stream when the loop exits early", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
const stream = new ReadableStream<number>({
|
||||||
|
pull(controller) {
|
||||||
|
controller.enqueue(1);
|
||||||
|
},
|
||||||
|
cancel: onCancel,
|
||||||
|
});
|
||||||
|
for await (const _ of values.call(stream)) break;
|
||||||
|
expect(onCancel).toHaveBeenCalled();
|
||||||
|
expect(stream.locked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves the stream open with preventCancel", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
const stream = streamOf([1, 2], onCancel);
|
||||||
|
for await (const _ of values.call(stream, { preventCancel: true })) break;
|
||||||
|
expect(onCancel).not.toHaveBeenCalled();
|
||||||
|
expect(stream.locked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("releases the lock and rethrows when cancelling rejects", async () => {
|
||||||
|
const stream = new ReadableStream<number>({
|
||||||
|
pull(controller) {
|
||||||
|
controller.enqueue(1);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
throw new Error("cancel failed");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(async () => {
|
||||||
|
for await (const _ of values.call(stream)) break;
|
||||||
|
}).rejects.toThrow("cancel failed");
|
||||||
|
expect(stream.locked).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Built-ins that PDF.js's modern build calls unconditionally but the system WebKit on
|
||||||
|
* older macOS lacks: the upsert methods and `Math.sumPrecise` arrived in Safari 26.2,
|
||||||
|
* and async iteration of `ReadableStream` in Safari 27. Without them the PDF viewer
|
||||||
|
* throws as soon as it renders. Loaded first by `index.html`, and by `pdfWorker.ts`
|
||||||
|
* because the worker has its own globals.
|
||||||
|
*
|
||||||
|
* Each is defined only when missing, so newer engines keep their native versions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type KeyedCollection<K, V> = {
|
||||||
|
has(key: K): boolean;
|
||||||
|
get(key: K): V | undefined;
|
||||||
|
set(key: K, value: V): unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** https://tc39.es/proposal-upsert/#sec-map.prototype.getOrInsert */
|
||||||
|
export function getOrInsert<K, V>(this: KeyedCollection<K, V>, key: K, value: V): V {
|
||||||
|
if (this.has(key)) return this.get(key) as V;
|
||||||
|
this.set(key, value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** https://tc39.es/proposal-upsert/#sec-map.prototype.getOrInsertComputed */
|
||||||
|
export function getOrInsertComputed<K, V>(
|
||||||
|
this: KeyedCollection<K, V>,
|
||||||
|
key: K,
|
||||||
|
callbackfn: (key: K) => V,
|
||||||
|
): V {
|
||||||
|
if (typeof callbackfn !== "function") {
|
||||||
|
throw new TypeError("getOrInsertComputed callback is not a function");
|
||||||
|
}
|
||||||
|
if (this.has(key)) return this.get(key) as V;
|
||||||
|
// Map keys are canonicalized, so -0 is stored and passed on as +0
|
||||||
|
const k = (Object.is(key, -0) ? 0 : key) as K;
|
||||||
|
const value = callbackfn(k);
|
||||||
|
// The spec sets after the callback even if the callback inserted the key itself
|
||||||
|
this.set(k, value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://tc39.es/proposal-math-sum/
|
||||||
|
*
|
||||||
|
* Shewchuk's exact summation with round-half-even on the final step (the algorithm
|
||||||
|
* behind Python's `math.fsum`). A finite input set whose running sum overflows
|
||||||
|
* partway through is not handled exactly, which PDF.js's byte lengths and widths
|
||||||
|
* never come near.
|
||||||
|
*/
|
||||||
|
export function sumPrecise(items: Iterable<number>): number {
|
||||||
|
const partials: number[] = [];
|
||||||
|
let allNegativeZero = true;
|
||||||
|
let sawNaN = false;
|
||||||
|
let sawPositiveInfinity = false;
|
||||||
|
let sawNegativeInfinity = false;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (typeof item !== "number") {
|
||||||
|
throw new TypeError("Math.sumPrecise requires an iterable of numbers");
|
||||||
|
}
|
||||||
|
if (!Object.is(item, -0)) allNegativeZero = false;
|
||||||
|
if (Number.isNaN(item)) {
|
||||||
|
sawNaN = true;
|
||||||
|
} else if (item === Infinity) {
|
||||||
|
sawPositiveInfinity = true;
|
||||||
|
} else if (item === -Infinity) {
|
||||||
|
sawNegativeInfinity = true;
|
||||||
|
} else {
|
||||||
|
let x = item;
|
||||||
|
let i = 0;
|
||||||
|
for (let y of partials) {
|
||||||
|
if (Math.abs(x) < Math.abs(y)) [x, y] = [y, x];
|
||||||
|
const hi = x + y;
|
||||||
|
const lo = y - (hi - x);
|
||||||
|
if (lo !== 0) partials[i++] = lo;
|
||||||
|
x = hi;
|
||||||
|
}
|
||||||
|
partials.length = i;
|
||||||
|
partials.push(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sawNaN || (sawPositiveInfinity && sawNegativeInfinity)) return NaN;
|
||||||
|
if (sawPositiveInfinity) return Infinity;
|
||||||
|
if (sawNegativeInfinity) return -Infinity;
|
||||||
|
if (allNegativeZero) return -0;
|
||||||
|
|
||||||
|
let n = partials.length;
|
||||||
|
let hi = 0;
|
||||||
|
let lo = 0;
|
||||||
|
if (n > 0) {
|
||||||
|
hi = partials[--n] as number;
|
||||||
|
while (n > 0) {
|
||||||
|
const x = hi;
|
||||||
|
const y = partials[--n] as number;
|
||||||
|
hi = x + y;
|
||||||
|
lo = y - (hi - x);
|
||||||
|
if (lo !== 0) break;
|
||||||
|
}
|
||||||
|
const next = partials[n - 1];
|
||||||
|
if (n > 0 && next != null && ((lo < 0 && next < 0) || (lo > 0 && next > 0))) {
|
||||||
|
const y = lo * 2;
|
||||||
|
const x = hi + y;
|
||||||
|
if (y === x - hi) hi = x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hi === 0 ? 0 : hi;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** https://streams.spec.whatwg.org/#rs-asynciterator */
|
||||||
|
export async function* readableStreamValues<R>(
|
||||||
|
this: ReadableStream<R>,
|
||||||
|
{ preventCancel = false }: { preventCancel?: boolean } = {},
|
||||||
|
): AsyncGenerator<R, undefined, undefined> {
|
||||||
|
const reader = this.getReader();
|
||||||
|
let finished = false;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
finished = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield value;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
finished = true;
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
// Leaving the loop early (break, return, throw in the body) cancels the stream. The
|
||||||
|
// lock is released even if cancelling rejects, which then propagates as in the spec.
|
||||||
|
try {
|
||||||
|
if (!finished && !preventCancel) await reader.cancel();
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function define(target: object, key: PropertyKey, value: unknown) {
|
||||||
|
if (key in target) return;
|
||||||
|
Object.defineProperty(target, key, { value, writable: true, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
define(Map.prototype, "getOrInsert", getOrInsert);
|
||||||
|
define(Map.prototype, "getOrInsertComputed", getOrInsertComputed);
|
||||||
|
define(WeakMap.prototype, "getOrInsert", getOrInsert);
|
||||||
|
define(WeakMap.prototype, "getOrInsertComputed", getOrInsertComputed);
|
||||||
|
define(Math, "sumPrecise", sumPrecise);
|
||||||
|
if (typeof ReadableStream !== "undefined") {
|
||||||
|
define(ReadableStream.prototype, "values", readableStreamValues);
|
||||||
|
define(
|
||||||
|
ReadableStream.prototype,
|
||||||
|
Symbol.asyncIterator,
|
||||||
|
Reflect.get(ReadableStream.prototype, "values"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeScript's lib does not describe these yet
|
||||||
|
declare global {
|
||||||
|
interface Map<K, V> {
|
||||||
|
getOrInsert(key: K, value: V): V;
|
||||||
|
getOrInsertComputed(key: K, callbackfn: (key: K) => V): V;
|
||||||
|
}
|
||||||
|
interface WeakMap<K extends WeakKey, V> {
|
||||||
|
getOrInsert(key: K, value: V): V;
|
||||||
|
getOrInsertComputed(key: K, callbackfn: (key: K) => V): V;
|
||||||
|
}
|
||||||
|
interface Math {
|
||||||
|
sumPrecise(items: Iterable<number>): number;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,9 +93,13 @@ test.each(["http://tauri.localhost", "tauri://localhost"])(
|
|||||||
const pdfjsDir = path.dirname(reactPdfRequire.resolve("pdfjs-dist/package.json"));
|
const pdfjsDir = path.dirname(reactPdfRequire.resolve("pdfjs-dist/package.json"));
|
||||||
const installed = JSON.parse(await readFile(path.join(pdfjsDir, "package.json"), "utf8"));
|
const installed = JSON.parse(await readFile(path.join(pdfjsDir, "package.json"), "utf8"));
|
||||||
expect(version).toBe(installed.version);
|
expect(version).toBe(installed.version);
|
||||||
expect(Buffer.from(worker.source)).toEqual(
|
// The worker is bundled with the polyfills older WebKit needs, ahead of PDF.js itself.
|
||||||
await readFile(path.join(pdfjsDir, "build/pdf.worker.min.mjs")),
|
const source = Buffer.from(worker.source).toString("utf8");
|
||||||
);
|
const escapedVersion = installed.version.replaceAll(".", "\\.");
|
||||||
|
expect(source).toMatch(new RegExp(`["'\`]${escapedVersion}["'\`]`));
|
||||||
|
const polyfillAt = source.indexOf("getOrInsertComputed callback is not a function");
|
||||||
|
expect(polyfillAt).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(polyfillAt).toBeLessThan(source.indexOf("pdfjs_internal_editor_"));
|
||||||
},
|
},
|
||||||
30_000,
|
30_000,
|
||||||
);
|
);
|
||||||
Reference in new issue
Block a user