diff --git a/bun.lock b/bun.lock
index 80e8819..b7e7f31 100644
--- a/bun.lock
+++ b/bun.lock
@@ -23,6 +23,8 @@
"base64-js": "^1.5.1",
"codemirror": "^6.0.2",
"css-tree": "^3.2.1",
+ "d3-ease": "^3.0.1",
+ "d3-timer": "^3.0.1",
"defuddle": "^0.19.4",
"entities": "^8.1.0",
"jsdom": "^30.0.1",
@@ -46,6 +48,8 @@
"@types/bun": "^1.4.2",
"@types/chrome": "^0.2.7",
"@types/css-tree": "^3.2.0",
+ "@types/d3-ease": "^3.0.2",
+ "@types/d3-timer": "^3.0.2",
"@types/jsdom": "^30.0.0",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
@@ -220,6 +224,10 @@
"@types/css-tree": ["@types/css-tree@3.2.0", "", {}, "sha512-J5KXmk6BFIepOT7280FdFyNs4c5fh0Uee0otjKEvzchrRs38Ii9qminqc4ds0L19X8Zd/rT+brR9jkBthygjWw=="],
+ "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
+
+ "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
+
"@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="],
"@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="],
@@ -342,6 +350,10 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+ "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
+
+ "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
+
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
diff --git a/components/bulk-options.tsx b/components/bulk-options.tsx
index 6bf1f5d..fedb8e0 100644
--- a/components/bulk-options.tsx
+++ b/components/bulk-options.tsx
@@ -24,6 +24,7 @@ import {
type PageTab,
} from "../src/bulk";
import { getOptions, type Options } from "../src/options";
+import { reach } from "../src/progress";
import { errString, safeFilename } from "../src/utils";
// the panel unmounts the moment another tab is picked, so what's chosen, what's
@@ -126,6 +127,12 @@ function Favicon({ page }: { page: PageTab }): ReactElement {
}
}
+// a step reports when it starts and then as it goes, so between reports the
+// browser eases the ring on toward the end of the step; the long curve is
+// front-loaded, which is why a slow step still looks like it's moving
+const DRIFT = "6s cubic-bezier(0, 0.7, 0.25, 1)";
+const SETTLE = "400ms cubic-bezier(0.4, 0, 0.2, 1)";
+
/** the page's icon, ringed by how far along its export is */
function PageIcon({
page,
@@ -157,11 +164,18 @@ function PageIcon({
/>
>
)}
diff --git a/components/options-page.tsx b/components/options-page.tsx
index a60f8d5..ad3818e 100644
--- a/components/options-page.tsx
+++ b/components/options-page.tsx
@@ -168,9 +168,7 @@ async function uploadFile(deviceToken: string, file: File): Promise {
const { epub, title = file_title } = await render(
mhtml.buffer as ArrayBuffer,
opts,
- file_title,
- undefined,
- false,
+ { title: file_title, summarize: false },
);
await uploadEpub(epub, title, deviceToken, opts);
} else {
diff --git a/package.json b/package.json
index 2a94bb4..94933fe 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,8 @@
"base64-js": "^1.5.1",
"codemirror": "^6.0.2",
"css-tree": "^3.2.1",
+ "d3-ease": "^3.0.1",
+ "d3-timer": "^3.0.1",
"defuddle": "^0.19.4",
"entities": "^8.1.0",
"jsdom": "^30.0.1",
@@ -59,6 +61,8 @@
"@types/bun": "^1.4.2",
"@types/chrome": "^0.2.7",
"@types/css-tree": "^3.2.0",
+ "@types/d3-ease": "^3.0.2",
+ "@types/d3-timer": "^3.0.2",
"@types/jsdom": "^30.0.0",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
diff --git a/src/background.ts b/src/background.ts
index c5f9231..526df6a 100644
--- a/src/background.ts
+++ b/src/background.ts
@@ -4,11 +4,15 @@ import { clearProgress } from "./icon";
import type { TitleRequest } from "./messages";
import { getOptions } from "./options";
import { registerPrinter } from "./printer";
+import { steps, within } from "./progress";
import { render } from "./render";
import { getTab } from "./status";
import { uploadEpub } from "./upload";
import { safeFilename, sleep } from "./utils";
+/** the parts of a conversion, weighted by how long each tends to take */
+const stage = steps({ capture: 2, render: 6, send: 2 });
+
/**
* create and upload epub
*/
@@ -21,7 +25,7 @@ async function rePub(
// get active state for clearing the icon with navigation
const tab = getTab(tabId);
try {
- await tab.init();
+ tab.init();
const { deviceToken, outputStyle, downloadAsk, promptTitle, ...opts } =
await getOptions();
@@ -35,18 +39,19 @@ async function rePub(
return;
}
- await tab.progress(25);
+ tab.progress(within(stage.capture, 0));
const mhtml = await pageCapture(tabId);
- await tab.progress(50);
+ tab.progress(within(stage.render, 0));
- const { epub, title = "missing title" } = await render(
- mhtml,
- opts,
- initTitle,
- initAuthor,
- );
- await tab.progress(75);
+ const { epub, title = "missing title" } = await render(mhtml, opts, {
+ title: initTitle,
+ author: initAuthor,
+ onProgress: (portion) => {
+ tab.progress(within(stage.render, portion));
+ },
+ });
+ tab.progress(within(stage.send, 0));
// upload
if (outputStyle === "download") {
@@ -68,7 +73,7 @@ async function rePub(
);
}
- await tab.complete();
+ tab.complete();
} catch (ex) {
const msg = ex instanceof Error ? ex.toString() : "unknown error";
await Promise.all([
diff --git a/src/bulk.spec.ts b/src/bulk.spec.ts
index 5dc1d6d..10b6cef 100644
--- a/src/bulk.spec.ts
+++ b/src/bulk.spec.ts
@@ -7,8 +7,10 @@ import {
listable,
type PageState,
type PageTab,
+ stage,
} from "./bulk";
import { defaultOptions, type Options } from "./options";
+import { within } from "./progress";
const page: PageTab = {
tabId: 1,
@@ -139,14 +141,38 @@ test("upload a page", async () => {
expect(woke).toEqual([1]);
expect(sent).toEqual(["parsed title"]);
expect(states).toEqual([
- { kind: "running", progress: 0.1 },
- { kind: "running", progress: 0.25 },
- { kind: "running", progress: 0.5 },
- { kind: "running", progress: 0.75 },
+ { kind: "running", ...within(stage.wake, 0) },
+ { kind: "running", ...within(stage.capture, 0) },
+ { kind: "running", ...within(stage.render, 0) },
+ { kind: "running", ...within(stage.send, 0) },
{ kind: "finished" },
]);
});
+test("a render reports its own progress", async () => {
+ const opts: Options = { ...defaultOptions, deviceToken: "token" };
+ const [states, report] = collect();
+
+ await exportPage(
+ page,
+ undefined,
+ opts,
+ report,
+ fakeEnv({
+ convert: async (_, __, extra) => {
+ extra?.onProgress?.(0.5);
+ return { epub: new Uint8Array([1]), title: "parsed title" };
+ },
+ }),
+ );
+
+ // half way through the render lands half way through the render's own share
+ expect(states).toContainEqual({
+ kind: "running",
+ ...within(stage.render, 0.5),
+ });
+});
+
test("upload needs a token", async () => {
const [states, report] = collect();
await expect(
diff --git a/src/bulk.ts b/src/bulk.ts
index 65f448f..a0fc4bb 100644
--- a/src/bulk.ts
+++ b/src/bulk.ts
@@ -2,6 +2,7 @@ import { fromByteArray } from "base64-js";
import { pageCapture } from "./capture";
import { showFailed, showProgress } from "./icon";
import type { EpubOptions, Options, UploadOptions } from "./options";
+import { steps, within } from "./progress";
import { render } from "./render";
import { uploadEpub } from "./upload";
import { errString, Semaphore, safeFilename, timeout } from "./utils";
@@ -12,6 +13,9 @@ const WAKE_TIMEOUT = 30_000;
const room = new Semaphore(4);
+/** the parts of an export, weighted by how long each tends to take */
+export const stage = steps({ wake: 1, capture: 2, render: 6, send: 2 });
+
/** an open tab offered for export */
export interface PageTab {
tabId: number;
@@ -26,7 +30,7 @@ export interface PageTab {
/** how far along a page is, or what became of it */
export type PageState =
- | { kind: "running"; progress: number }
+ | { kind: "running"; progress: number; upto: number }
| { kind: "finished" }
| { kind: "failed"; error: string };
@@ -37,6 +41,7 @@ interface ExportEnv {
convert: (
mhtml: ArrayBuffer,
opts: EpubOptions,
+ extra?: { onProgress?: (portion: number) => void },
) => Promise<{ epub: Uint8Array; title?: string }>;
send: (
epub: Uint8Array,
@@ -53,9 +58,9 @@ async function markTab(tabId: number, state: PageState): Promise {
if (state.kind === "failed") {
await showFailed(tabId);
} else if (state.kind === "finished") {
- await showProgress(tabId, 1);
+ showProgress(tabId, { progress: 1, upto: 1 });
} else {
- await showProgress(tabId, state.progress);
+ showProgress(tabId, state);
}
}
@@ -210,13 +215,21 @@ export async function exportPage(
const given = name?.trim() || undefined;
await using _permit = await room.lock();
- report({ kind: "running", progress: 0.1 });
+ report({ kind: "running", ...within(stage.wake, 0) });
await wake(page.tabId);
- report({ kind: "running", progress: 0.25 });
+ report({ kind: "running", ...within(stage.capture, 0) });
const mhtml = await capture(page.tabId);
- report({ kind: "running", progress: 0.5 });
- const { epub, title = given ?? "missing title" } = await convert(mhtml, rest);
- report({ kind: "running", progress: 0.75 });
+ report({ kind: "running", ...within(stage.render, 0) });
+ const { epub, title = given ?? "missing title" } = await convert(
+ mhtml,
+ rest,
+ {
+ onProgress: (portion) => {
+ report({ kind: "running", ...within(stage.render, portion) });
+ },
+ },
+ );
+ report({ kind: "running", ...within(stage.send, 0) });
if (outputStyle === "download") {
await save(
diff --git a/src/cli.ts b/src/cli.ts
index ec53438..1087b89 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -192,7 +192,6 @@ global.Image = dom.window["Image"] as typeof Image;
rotateTables: false,
tableResolution: 1,
},
- true,
);
if (args.verbose > 0) {
diff --git a/src/icon.spec.ts b/src/icon.spec.ts
index cd6e57f..a17b22f 100644
--- a/src/icon.spec.ts
+++ b/src/icon.spec.ts
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
-import { arc } from "./icon";
+import { arc, ringAt } from "./icon";
test("arc", () => {
const top = -Math.PI / 2;
@@ -12,3 +12,36 @@ test("arc", () => {
expect(arc(-1)).toEqual([top, top]);
expect(arc(2)).toEqual([top, full]);
});
+
+test("ringAt eases to what was reported", () => {
+ const spot = { progress: 0.5, upto: 0.5 };
+
+ expect(ringAt(0.2, spot, 0)).toBe(0.2);
+ // the ease is fastest at the start, so it's over half way by half way
+ expect(ringAt(0.2, spot, 175)).toBeGreaterThan(0.35);
+ expect(ringAt(0.2, spot, 350)).toBeCloseTo(0.5, 10);
+ expect(ringAt(0.2, spot, 10_000)).toBeCloseTo(0.5, 10);
+});
+
+test("ringAt drifts on through the step", () => {
+ const spot = { progress: 0.2, upto: 0.6 };
+ const arrived = ringAt(0.2, spot, 350);
+ const drifted = ringAt(0.2, spot, 4_000);
+ const later = ringAt(0.2, spot, 60_000);
+
+ expect(arrived).toBeCloseTo(0.2, 10);
+ expect(drifted).toBeGreaterThan(arrived);
+ expect(later).toBeGreaterThan(drifted);
+ // the drift stops short, so a step that drags doesn't look finished
+ expect(later).toBeLessThan(0.6);
+});
+
+test("ringAt only moves forward", () => {
+ const spot = { progress: 0.3, upto: 0.9 };
+ let last = 0;
+ for (let elapsed = 0; elapsed < 30_000; elapsed += 40) {
+ const at = ringAt(0.1, spot, elapsed);
+ expect(at).toBeGreaterThanOrEqual(last);
+ last = at;
+ }
+});
diff --git a/src/icon.ts b/src/icon.ts
index efd7228..b86a7aa 100644
--- a/src/icon.ts
+++ b/src/icon.ts
@@ -1,3 +1,7 @@
+import { easeCubicOut } from "d3-ease";
+import { interval, type Timer } from "d3-timer";
+import { reach, type Spot } from "./progress";
+
// the toolbar icon is drawn rather than badged, so a conversion shows as a ring
// closing around the icon instead of a number beside it
const SIZE = 32;
@@ -6,6 +10,15 @@ const TRACK = "rgba(0, 0, 0, 0.15)";
const FILL = "#000000";
const FAILED = "#d62626";
+// how long the ring takes to reach a spot it was just told about
+const SWEEP = 350;
+// how long the drift past that spot takes to cover most of the step
+const DRIFT = 6000;
+// how long between frames; the ring is 32 pixels across, so this is plenty
+const FRAME = 40;
+// the smallest move worth drawing the icon again for
+const NUDGE = 1 / 200;
+
const sizes = [16, 32, 48, 128];
const paths = Object.fromEntries(
sizes.map((size) => [size, `images/repub_${size.toFixed()}.png`]),
@@ -13,6 +26,7 @@ const paths = Object.fromEntries(
let loading: Promise | null = null;
let plain: ImageData | null = null;
+let canvas: OffscreenCanvas | null = null;
function glyph(): Promise {
loading ??= fetch(chrome.runtime.getURL("images/repub_128.png"))
@@ -32,25 +46,39 @@ export function arc(portion: number): [number, number] {
return [start, start + span];
}
+/**
+ * where the ring sits `elapsed` milliseconds into a move
+ *
+ * It eases from `start` to the spot it was told about, and then drifts on
+ * toward the end of the step, slowing as it goes, so that a step which reports
+ * nothing for a while still looks like it's working.
+ */
+export function ringAt(
+ start: number,
+ { progress, upto }: Spot,
+ elapsed: number,
+): number {
+ const swept =
+ start + (progress - start) * easeCubicOut(Math.min(elapsed / SWEEP, 1));
+ const drift = 1 - Math.exp(-Math.max(elapsed - SWEEP, 0) / DRIFT);
+ return swept + (reach({ progress, upto }) - progress) * drift;
+}
+
/** the icon, with a ring around it when there's a conversion to show */
async function paint(
ring: { portion: number; color: string } | null,
): Promise {
- const canvas = new OffscreenCanvas(SIZE, SIZE);
+ const bitmap = await glyph();
+ canvas ??= new OffscreenCanvas(SIZE, SIZE);
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("couldn't draw the toolbar icon");
}
+ ctx.clearRect(0, 0, SIZE, SIZE);
// the icon gives up room for the ring, so what it draws stays inside it
const inset = ring ? STROKE * 2 : 0;
- ctx.drawImage(
- await glyph(),
- inset,
- inset,
- SIZE - 2 * inset,
- SIZE - 2 * inset,
- );
+ ctx.drawImage(bitmap, inset, inset, SIZE - 2 * inset, SIZE - 2 * inset);
if (ring) {
const center = SIZE / 2;
@@ -73,17 +101,91 @@ async function paint(
return ctx.getImageData(0, 0, SIZE, SIZE);
}
+/** the moving ring on one tab's icon */
+class Ring {
+ readonly #tabId: number;
+ #spot: Spot = { progress: 0, upto: 0 };
+ #start = 0;
+ // the ring as drawn, counted in nudges so that it compares exactly
+ #drawn = -1;
+ #clock: Timer | null = null;
+ #drawing = false;
+
+ constructor(tabId: number) {
+ this.#tabId = tabId;
+ }
+
+ /** ease to `spot`, then drift on toward the end of its step */
+ aim(spot: Spot): void {
+ // a conversion starting over, or a step that has got less far than the
+ // drift already showed, snaps instead of winding backwards
+ const shown = Math.max(this.#drawn, 0) * NUDGE;
+ this.#start = spot.progress < shown ? spot.progress : shown;
+ this.#spot = spot;
+ this.#clock?.stop();
+ this.#clock = interval((elapsed) => {
+ this.#frame(elapsed);
+ }, FRAME);
+ this.#frame(0);
+ }
+
+ /** stop moving and leave the icon as it is */
+ stop(): void {
+ this.#clock?.stop();
+ this.#clock = null;
+ }
+
+ #frame(elapsed: number): void {
+ const at = ringAt(this.#start, this.#spot, elapsed);
+ if (reach(this.#spot) - at < NUDGE) {
+ // there's nowhere left to drift, so wait for the next thing reported
+ this.stop();
+ }
+ const nudges = Math.round(at / NUDGE);
+ if (this.#drawing || nudges === this.#drawn) {
+ return;
+ }
+ this.#drawn = nudges;
+ this.#drawing = true;
+ paint({ portion: nudges * NUDGE, color: FILL })
+ .then((imageData) =>
+ chrome.action.setIcon({ tabId: this.#tabId, imageData }),
+ )
+ .catch((ex: unknown) => {
+ console.warn(ex);
+ })
+ .finally(() => {
+ this.#drawing = false;
+ });
+ }
+}
+
+const rings = new Map();
+
+function ringOf(tabId: number): Ring {
+ const ring = rings.get(tabId);
+ if (ring) {
+ return ring;
+ } else {
+ const made = new Ring(tabId);
+ rings.set(tabId, made);
+ return made;
+ }
+}
+
+function forget(tabId: number): void {
+ rings.get(tabId)?.stop();
+ rings.delete(tabId);
+}
+
/** ring the icon of `tabId` to show how much of its conversion is done */
-export async function showProgress(
- tabId: number,
- portion: number,
-): Promise {
- const imageData = await paint({ portion, color: FILL });
- await chrome.action.setIcon({ tabId, imageData });
+export function showProgress(tabId: number, spot: Spot): void {
+ ringOf(tabId).aim(spot);
}
/** ring the icon of `tabId` in red, for a conversion that failed */
export async function showFailed(tabId: number): Promise {
+ forget(tabId);
const imageData = await paint({ portion: 1, color: FAILED });
await chrome.action.setIcon({ tabId, imageData });
}
@@ -95,6 +197,7 @@ export async function showFailed(tabId: number): Promise {
* again; the files are only read if it can't be drawn at all.
*/
export async function clearProgress(tabId: number): Promise {
+ forget(tabId);
try {
plain ??= await paint(null);
await chrome.action.setIcon({ tabId, imageData: plain });
diff --git a/src/lib.ts b/src/lib.ts
index ce2c0ea..ab563d5 100644
--- a/src/lib.ts
+++ b/src/lib.ts
@@ -3,6 +3,7 @@ import { baseTableCss, epubCss } from "./css";
import { epub, type ImageData, type ImageMime } from "./epub";
import type { EpubOptions } from "./options";
import { parse } from "./parse";
+import { steps, within } from "./progress";
type Brighten = (
buffer: Uint8Array,
@@ -18,13 +19,31 @@ interface Result {
title?: string;
}
+/** the parts of a render, weighted by how long each tends to take */
+const stage = steps({ read: 2, alter: 5, images: 4, pack: 2 });
+
+/** what a render can be told beyond the page itself */
+export interface GenerateExtra {
+ /** whether to cut the page down to its article first */
+ summarize?: boolean;
+ /** the title to use instead of the one read off the page */
+ title?: string;
+ /** the author to use instead of the one read off the page */
+ author?: string;
+ /** called as the render goes, with how much of it is done */
+ onProgress?: (portion: number) => void;
+}
+
export async function generate(
mhtml: Uint8Array,
brighten: Brighten,
options: EpubOptions,
- summarize: boolean,
- initTitle?: string,
- initAuthor?: string,
+ {
+ summarize = true,
+ title: initTitle,
+ author: initAuthor,
+ onProgress = () => {},
+ }: GenerateExtra = {},
): Promise {
const {
imageHrefSimilarityThreshold,
@@ -61,6 +80,7 @@ export async function generate(
}
}
+ onProgress(stage.alter.from);
const matcher =
imageHrefSimilarityThreshold > 0
? closeMatch(assetData, imageHrefSimilarityThreshold)
@@ -88,15 +108,26 @@ export async function generate(
seen.add(cover);
}
+ // every image is its own slice of the images step, so a page full of them
+ // reports as each one lands rather than all at once
+ let done = 0;
+ const count = seen.size;
+ const ticked = (value: Value): Value => {
+ done += 1;
+ onProgress(within(stage.images, done / count).progress);
+ return value;
+ };
+
+ onProgress(stage.images.from);
const proms = [];
for (const href of seen) {
const { mime, data } = assetData.get(href)!;
proms.push(
brighten(data, mime).then(
- ([data, mime]) => [href, data, mime] as const,
+ ([data, mime]) => ticked([href, data, mime] as const),
(err: unknown) => {
console.warn(`problem brightening ${href}:`, err);
- return null;
+ return ticked(null);
},
),
);
@@ -113,6 +144,7 @@ export async function generate(
brightened.set(url, { data, mime });
}
+ onProgress(stage.pack.from);
const buffer = await epub({
title: initTitle ?? title,
content: altered,
@@ -123,6 +155,7 @@ export async function generate(
byline: bylineHeader,
cover: coverHeader ? cover : undefined,
});
+ onProgress(1);
return {
epub: buffer,
title: initTitle ?? title,
diff --git a/src/messages.ts b/src/messages.ts
index 189d963..459ff77 100644
--- a/src/messages.ts
+++ b/src/messages.ts
@@ -42,6 +42,12 @@ export interface TrimResponse {
zoom: Partial;
}
+/** how much of the epub is done, as a portion of the whole render */
+export interface ProgressResponse {
+ type: "progress";
+ portion: number;
+}
+
export interface ErrorMessage {
type: "error";
err: string;
@@ -49,7 +55,12 @@ export interface ErrorMessage {
export type Message = InitMessage | TrimMessage | PartMessage;
-export type Response = InitResponse | TrimResponse | PartMessage | ErrorMessage;
+export type Response =
+ | InitResponse
+ | TrimResponse
+ | ProgressResponse
+ | PartMessage
+ | ErrorMessage;
export interface TitleRequest {
tabId: number;
diff --git a/src/offscreen.ts b/src/offscreen.ts
index 35813b7..1b43397 100644
--- a/src/offscreen.ts
+++ b/src/offscreen.ts
@@ -18,9 +18,15 @@ async function makeEpub(
payload,
bright,
options,
- summarize,
- title,
- author,
+ {
+ summarize,
+ title,
+ author,
+ onProgress: (portion) => {
+ const progress: Response = { type: "progress", portion };
+ port.postMessage(progress);
+ },
+ },
);
const encoded = fromByteArray(epub);
diff --git a/src/progress.spec.ts b/src/progress.spec.ts
new file mode 100644
index 0000000..2440edb
--- /dev/null
+++ b/src/progress.spec.ts
@@ -0,0 +1,25 @@
+import { expect, test } from "bun:test";
+import { steps, within } from "./progress";
+
+test("steps", () => {
+ expect(steps({ one: 1, three: 3 })).toEqual({
+ one: { from: 0, to: 0.25 },
+ three: { from: 0.25, to: 1 },
+ });
+ // a job of one step is the whole job
+ expect(steps({ all: 7 })).toEqual({ all: { from: 0, to: 1 } });
+ // thirds don't divide evenly, but the last step still ends at the end
+ const { last } = steps({ first: 1, second: 1, last: 1 });
+ expect(last.to).toBe(1);
+});
+
+test("within", () => {
+ const step = { from: 0.2, to: 0.6 };
+
+ expect(within(step, 0)).toEqual({ progress: 0.2, upto: 0.6 });
+ expect(within(step, 0.5)).toEqual({ progress: 0.4, upto: 0.6 });
+ expect(within(step, 1)).toEqual({ progress: 0.6, upto: 0.6 });
+ // a step that reports more or less than it should stays inside itself
+ expect(within(step, -1)).toEqual({ progress: 0.2, upto: 0.6 });
+ expect(within(step, 2)).toEqual({ progress: 0.6, upto: 0.6 });
+});
diff --git a/src/progress.ts b/src/progress.ts
new file mode 100644
index 0000000..6deebde
--- /dev/null
+++ b/src/progress.ts
@@ -0,0 +1,52 @@
+/** the slice of a whole job that one step covers */
+export interface Step {
+ /** how far along the job is when the step starts */
+ from: number;
+ /** how far along it is once the step is done */
+ to: number;
+}
+
+/** how far along a job is, and how far the step it's in can take it */
+export interface Spot {
+ /** how far along the whole job is */
+ progress: number;
+ /** how far along it will be when the current step is done */
+ upto: number;
+}
+
+/**
+ * cut a job into steps, each sized by how long it tends to take
+ *
+ * The weights are relative, so `{ capture: 1, render: 3 }` gives capture the
+ * first quarter of the job and render the rest.
+ */
+export function steps(
+ weights: Record,
+): Record {
+ const named = Object.entries(weights) as [Name, number][];
+ const total = named.reduce((sum, [, weight]) => sum + weight, 0);
+ const cut = {} as Record;
+ let before = 0;
+ for (const [name, weight] of named) {
+ // the ends divide the running total, so the last step ends at exactly one
+ cut[name] = { from: before / total, to: (before + weight) / total };
+ before += weight;
+ }
+ return cut;
+}
+
+// a drift stops short of the step's end, so a step that drags on doesn't leave
+// the ring looking done
+const CREEP = 0.85;
+
+/** how far along the job a step's ring gets while nothing more is reported */
+export function reach({ progress, upto }: Spot): number {
+ return progress + Math.max(upto - progress, 0) * CREEP;
+}
+
+/** how far along the job `portion` of the way through `step` is */
+export function within(step: Step, portion: number): Spot {
+ const { from, to } = step;
+ const part = Math.min(Math.max(portion, 0), 1);
+ return { progress: from + (to - from) * part, upto: to };
+}
diff --git a/src/render.ts b/src/render.ts
index 570e81b..a2f1fd1 100644
--- a/src/render.ts
+++ b/src/render.ts
@@ -78,12 +78,22 @@ async function exchange(
});
}
+/** what a render can be told beyond the page itself */
+export interface RenderExtra {
+ /** the title to use instead of the one read off the page */
+ title?: string;
+ /** the author to use instead of the one read off the page */
+ author?: string;
+ /** whether to cut the page down to its article first */
+ summarize?: boolean;
+ /** called as the render goes, with how much of it is done */
+ onProgress?: (portion: number) => void;
+}
+
export async function render(
mhtml: ArrayBuffer,
opts: EpubOptions,
- title?: string,
- author?: string,
- summarize: boolean = true,
+ { title, author, summarize = true, onProgress = () => {} }: RenderExtra = {},
): Promise<{ epub: Uint8Array; title?: string }> {
const parts: string[] = [];
let receivedParts = 0;
@@ -107,6 +117,8 @@ export async function render(
} else if (message.type === "info") {
expectedParts = message.numParts;
parsedTitle = message.title;
+ } else if (message.type === "progress") {
+ onProgress(message.portion);
}
if (expectedParts === receivedParts) {
resolve(parts);
diff --git a/src/status.ts b/src/status.ts
index c09e936..57fd922 100644
--- a/src/status.ts
+++ b/src/status.ts
@@ -1,4 +1,5 @@
import { clearProgress, showFailed, showProgress } from "./icon";
+import type { Spot } from "./progress";
// store active state for navigation
const activeTabs = new Map();
@@ -13,28 +14,16 @@ class Tab {
this.#tabId = tabId;
}
- async init(): Promise {
- try {
- await showProgress(this.#tabId, 0);
- } catch {
- // ignore
- }
+ init(): void {
+ showProgress(this.#tabId, { progress: 0, upto: 0 });
}
- async progress(perc: number): Promise {
- try {
- await showProgress(this.#tabId, perc / 100);
- } catch {
- // ignore
- }
+ progress(spot: Spot): void {
+ showProgress(this.#tabId, spot);
}
- async complete(): Promise {
- try {
- await showProgress(this.#tabId, 1);
- } catch {
- // ignore
- }
+ complete(): void {
+ showProgress(this.#tabId, { progress: 1, upto: 1 });
}
async error(): Promise {