From 577b399097b692f7efb3481f211910863b563a0b Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Fri, 3 Jul 2026 21:19:29 +0900 Subject: [PATCH 01/10] feat: Show pagination progress during PDF build --- src/global-viewer.d.ts | 10 +++ src/logger.ts | 13 ++++ src/output/pdf.ts | 137 +++++++++++++++++++++++++++++-------- src/output/progress-eta.ts | 59 ++++++++++++++++ tests/progress-eta.test.ts | 57 +++++++++++++++ 5 files changed, 249 insertions(+), 27 deletions(-) create mode 100644 src/output/progress-eta.ts create mode 100644 tests/progress-eta.test.ts diff --git a/src/global-viewer.d.ts b/src/global-viewer.d.ts index 651bcc75..8c5b225e 100644 --- a/src/global-viewer.d.ts +++ b/src/global-viewer.d.ts @@ -1,6 +1,11 @@ declare global { export interface Window { coreViewer: CoreViewer; + __vsCliReportPaginationProgress?: ( + pages: number, + fraction: number, + href?: string, + ) => void; } } @@ -26,6 +31,11 @@ export interface CoreViewer { export interface Payload { t: string; a?: string; + + // paginationprogress + fraction?: number; + pages?: number; + href?: string; } export interface Meta { diff --git a/src/logger.ts b/src/logger.ts index 24ffc4c6..5cf25026 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -161,6 +161,19 @@ export class Logger { this.#nonBlockingLogPrinted = false; } + /** + * Update the text of the running spinner in place without printing a + * checkpoint line. Unlike `logUpdate`, this method does nothing when the + * output is not interactive, so it is safe to call at a high frequency. + */ + static logUpdateProgress(...messages: unknown[]): void { + if (this.#logLevel < 1 || !this.#spinner || !this.isInteractive) { + return; + } + this.#spinner.text = messages.join(' '); + this.#nonBlockingLogPrinted = true; + } + static getMessage(message: string, symbol?: string): string { return !this.#customLogger && symbol ? `${symbol} ${message}` : message; } diff --git a/src/output/pdf.ts b/src/output/pdf.ts index e0bddd78..769c4527 100644 --- a/src/output/pdf.ts +++ b/src/output/pdf.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import { URL } from 'node:url'; -import type { Browser, HTTPResponse, Page } from 'puppeteer-core'; +import type { Browser, Page } from 'puppeteer-core'; import terminalLink from 'terminal-link'; import upath from 'upath'; import { cyan, gray, green, red } from 'yoctocolors'; @@ -17,6 +17,7 @@ import { Logger } from '../logger.js'; import { getViewerFullUrl } from '../server.js'; import { pathEquals, toError } from '../util.js'; import { type PageSizeData, PostProcess } from './pdf-postprocess.js'; +import { createEtaEstimator, formatEta } from './progress-eta.js'; export async function buildPDF({ target, @@ -33,6 +34,9 @@ export async function buildPDF({ Logger.debug('viewerFullUrl', viewerFullUrl); let lastEntry: ManuscriptEntry | undefined; + let paginationProgress: { pages: number; fraction: number } | undefined; + let typesettingFinished = false; + const etaEstimator = createEtaEstimator({ now: Date.now }); function stringifyEntry(entry: ManuscriptEntry) { const formattedSourcePath = cyan( @@ -51,31 +55,77 @@ export async function buildPDF({ )} ${entry.title ? gray(entry.title) : ''}`; } - function handleEntry(response: HTTPResponse) { - const entry = config.entries.find( - (candidate): candidate is ManuscriptEntry => { - if (!('source' in candidate)) { - return false; - } - const url = new URL(response.url()); - return url.protocol === 'file:' - ? pathEquals(candidate.target, url.pathname) - : pathEquals( - upath.relative(config.workspaceDir, candidate.target), - url.pathname.slice(1), - ); - }, + function entryText() { + return lastEntry ? stringifyEntry(lastEntry) : 'Building pages'; + } + + function renderBuildingText() { + const base = entryText(); + if (!paginationProgress) { + return base; + } + const percent = Math.min( + 100, + Math.round(paginationProgress.fraction * 100), ); - if (entry) { - if (!lastEntry) { + const eta = percent >= 100 ? undefined : etaEstimator.estimate(); + const suffix = + eta === undefined + ? `(${paginationProgress.pages} pages, ${percent}%)` + : `(${paginationProgress.pages} pages, ${percent}%, ETA ${formatEta(eta)})`; + return `${base} ${gray(suffix)}`; + } + + function findEntryByHref(href: string): ManuscriptEntry | undefined { + return config.entries.find((candidate): candidate is ManuscriptEntry => { + if (!('source' in candidate)) { + return false; + } + const url = new URL(href); + if (url.protocol === 'file:') { + return pathEquals(candidate.target, decodeURI(url.pathname)); + } + // Served documents are mounted under the base path (default: /vivliostyle) + const basePrefix = `${config.base}/`; + return ( + url.pathname.startsWith(basePrefix) && + pathEquals( + upath.relative(config.workspaceDir, candidate.target), + decodeURI(url.pathname.slice(basePrefix.length)), + ) + ); + }); + } + + function handlePaginationProgress( + pages: number, + fraction: number, + href?: string, + ) { + if ( + typesettingFinished || + !Number.isFinite(pages) || + !Number.isFinite(fraction) + ) { + return; + } + paginationProgress = { pages, fraction }; + etaEstimator.update(fraction); + + const entry = href ? findEntryByHref(href) : undefined; + if (entry && entry !== lastEntry) { + if (lastEntry) { + Logger.logInfo(stringifyEntry(lastEntry)); lastEntry = entry; - Logger.logUpdate(stringifyEntry(entry)); - return; + Logger.startLogging(renderBuildingText()); + } else { + lastEntry = entry; + Logger.logUpdate(renderBuildingText()); } - Logger.logSuccess(stringifyEntry(lastEntry)); - Logger.startLogging(stringifyEntry(entry)); - lastEntry = entry; + return; } + + Logger.logUpdateProgress(renderBuildingText()); } const { browser, page, closeBrowser } = await launchPreview({ @@ -84,9 +134,10 @@ export async function buildPDF({ signal, config, onBrowserOpen: () => { - Logger.logUpdate('Building pages'); + Logger.logInfo('Building pages'); + Logger.logUpdateProgress('Building pages'); }, - onPageOpen: (openedPage) => { + onPageOpen: async (openedPage) => { openedPage.on('pageerror', (error) => { Logger.logError(red(toError(error).message)); }); @@ -121,8 +172,6 @@ export async function buildPDF({ response.url(), ); - handleEntry(response); - if (400 > response.status() && 200 <= response.status()) { return; } @@ -134,6 +183,38 @@ export async function buildPDF({ Logger.logError(red(`${response.status()}`), response.url()); }); + try { + await openedPage.exposeFunction( + '__vsCliReportPaginationProgress', + handlePaginationProgress, + ); + await openedPage.evaluateOnNewDocument(() => { + /* v8 ignore start */ + let tick = 0; + const timer = setInterval(() => { + if (!window.coreViewer) { + // Give up if the viewer never appears + if (++tick > 600) { + clearInterval(timer); + } + return; + } + clearInterval(timer); + window.coreViewer.addListener('paginationprogress', (payload) => { + // oxlint-disable-next-line no-underscore-dangle -- exposed function name prefixed to avoid page global collisions + window.__vsCliReportPaginationProgress?.( + payload.pages ?? 0, + payload.fraction ?? 0, + payload.href, + ); + }); + }, 100); + /* v8 ignore stop */ + }); + } catch (error) { + Logger.debug('Failed to set up pagination progress reporting', error); + } + openedPage.setDefaultTimeout(config.timeout); }, }); @@ -164,9 +245,11 @@ export async function buildPDF({ () => window.coreViewer.readyState === 'complete', { polling: 1000, signal }, ); + // Ignore progress dispatched after this point + typesettingFinished = true; if (lastEntry) { - Logger.logSuccess(stringifyEntry(lastEntry)); + Logger.logInfo(stringifyEntry(lastEntry)); } const pageProgression = await page.evaluate((): 'ltr' | 'rtl' => diff --git a/src/output/progress-eta.ts b/src/output/progress-eta.ts new file mode 100644 index 00000000..cf5d8c36 --- /dev/null +++ b/src/output/progress-eta.ts @@ -0,0 +1,59 @@ +export type EtaEstimator = { + update(fraction: number): void; + estimate(): number | undefined; +}; + +const SMOOTHING = 0.3; + +/** + * Estimates the remaining time from the progress fraction, smoothing the + * progress rate with an exponential moving average. + */ +export function createEtaEstimator({ + now, +}: { + now: () => number; +}): EtaEstimator { + let lastFraction = 0; + let lastTime: number | undefined; + let ratePerMs: number | undefined; + + return { + update(fraction) { + const time = now(); + lastTime ??= time; + if (fraction > lastFraction) { + const elapsed = time - lastTime; + if (elapsed > 0) { + const rate = (fraction - lastFraction) / elapsed; + ratePerMs = + ratePerMs === undefined + ? rate + : ratePerMs * (1 - SMOOTHING) + rate * SMOOTHING; + } + lastFraction = fraction; + lastTime = time; + } + }, + estimate() { + if (!ratePerMs) { + return; + } + return (1 - lastFraction) / ratePerMs; + }, + }; +} + +export function formatEta(ms: number): string { + const sec = Math.ceil(ms / 1000); + if (sec < 60) { + return `${sec}s`; + } + const min = Math.ceil(sec / 60); + if (min < 60) { + return `${min}m`; + } + const hour = Math.floor(min / 60); + const restMin = min % 60; + return restMin ? `${hour}h${restMin}m` : `${hour}h`; +} diff --git a/tests/progress-eta.test.ts b/tests/progress-eta.test.ts new file mode 100644 index 00000000..17d2723b --- /dev/null +++ b/tests/progress-eta.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { createEtaEstimator, formatEta } from '../src/output/progress-eta.js'; + +function createClock(start = 0) { + let time = start; + return { + now: () => time, + advance: (ms: number) => { + time += ms; + }, + }; +} + +describe('createEtaEstimator', () => { + it('returns an estimate from the second advancing update', () => { + const clock = createClock(); + const eta = createEtaEstimator({ now: clock.now }); + eta.update(0.1); + expect(eta.estimate()).toBeUndefined(); + clock.advance(1000); + eta.update(0.2); + // 80% remains at 10% per second + expect(eta.estimate()).toBeCloseTo(8000, 0); + }); + + it('estimates the remaining time from the progress rate', () => { + const clock = createClock(); + const eta = createEtaEstimator({ now: clock.now }); + eta.update(0); + // Advances 10% per second at a constant rate + for (let i = 1; i <= 5; i++) { + clock.advance(1000); + eta.update(i / 10); + } + // 50% remains at 10% per second + expect(eta.estimate()).toBeCloseTo(5000, 0); + }); +}); + +describe('formatEta', () => { + it('shows seconds under a minute', () => { + expect(formatEta(1000)).toBe('1s'); + expect(formatEta(34000)).toBe('34s'); + }); + + it('rounds up to minutes under an hour', () => { + expect(formatEta(59999)).toBe('1m'); + expect(formatEta(61000)).toBe('2m'); + expect(formatEta(600000)).toBe('10m'); + }); + + it('formats hours with the remaining minutes', () => { + expect(formatEta(3600000)).toBe('1h'); + expect(formatEta(3900000)).toBe('1h5m'); + }); +}); From 2471cce28470b361e49fe1f3f35ed890a07e0e81 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Sat, 4 Jul 2026 03:37:33 +0900 Subject: [PATCH 02/10] fix: Ignore unparsable href in pagination progress --- src/output/pdf.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/output/pdf.ts b/src/output/pdf.ts index 769c4527..731b1296 100644 --- a/src/output/pdf.ts +++ b/src/output/pdf.ts @@ -77,11 +77,14 @@ export async function buildPDF({ } function findEntryByHref(href: string): ManuscriptEntry | undefined { + if (!URL.canParse(href)) { + return; + } + const url = new URL(href); return config.entries.find((candidate): candidate is ManuscriptEntry => { if (!('source' in candidate)) { return false; } - const url = new URL(href); if (url.protocol === 'file:') { return pathEquals(candidate.target, decodeURI(url.pathname)); } From af9a305f0e7a994a7e907d3d1c1ce35848e78a34 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Sun, 5 Jul 2026 23:02:00 +0900 Subject: [PATCH 03/10] fix: Show progress entry paths as plain text to avoid spinner corruption --- src/output/pdf.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/output/pdf.ts b/src/output/pdf.ts index 731b1296..4dd01e85 100644 --- a/src/output/pdf.ts +++ b/src/output/pdf.ts @@ -2,7 +2,6 @@ import fs from 'node:fs'; import { URL } from 'node:url'; import type { Browser, Page } from 'puppeteer-core'; -import terminalLink from 'terminal-link'; import upath from 'upath'; import { cyan, gray, green, red } from 'yoctocolors'; @@ -44,15 +43,18 @@ export async function buildPDF({ ? upath.relative(config.entryContextDir, entry.source.pathname) : entry.source.href, ); - return `${terminalLink( - formattedSourcePath, - entry.source.type === 'file' - ? `file://${entry.source.pathname}` - : entry.source.href, - { - fallback: () => formattedSourcePath, - }, - )} ${entry.title ? gray(entry.title) : ''}`; + // TODO: yocto-spinner over-counts hyperlink text and erases unrelated log + // lines (sindresorhus/yocto-spinner#18). + // return `${terminalLink( + // formattedSourcePath, + // entry.source.type === 'file' + // ? pathToFileURL(entry.source.pathname).href + // : entry.source.href, + // { + // fallback: () => formattedSourcePath, + // }, + // )} ${entry.title ? gray(entry.title) : ''}`; + return `${formattedSourcePath} ${entry.title ? gray(entry.title) : ''}`; } function entryText() { From b69f2fb8124b0c1a5acfbc5859531e04318150e8 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Sun, 5 Jul 2026 23:02:01 +0900 Subject: [PATCH 04/10] chore: Add changeset for the pagination progress feature --- .changeset/lazy-baths-divide.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lazy-baths-divide.md diff --git a/.changeset/lazy-baths-divide.md b/.changeset/lazy-baths-divide.md new file mode 100644 index 00000000..e8a10a5c --- /dev/null +++ b/.changeset/lazy-baths-divide.md @@ -0,0 +1,5 @@ +--- +'@vivliostyle/cli': minor +--- + +Show pagination progress (current document, page count, percentage, and estimated time remaining) during PDF builds. From a1a47942431a10c249654181b561b23ae4036c58 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Mon, 6 Jul 2026 10:45:09 +0900 Subject: [PATCH 05/10] fix: Restore hyperlinked progress paths with yocto-spinner 1.2.1 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/output/pdf.ts | 22 +++++++++------------- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index ba346d1b..0b44a200 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "vite": "8.1.0", "w3c-xmlserializer": "5.0.0", "whatwg-mimetype": "4.0.0", - "yocto-spinner": "1.2.0", + "yocto-spinner": "1.2.1", "yoctocolors": "2.1.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44ff1bd6..4bfde7eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,8 +177,8 @@ importers: specifier: 4.0.0 version: 4.0.0 yocto-spinner: - specifier: 1.2.0 - version: 1.2.0 + specifier: 1.2.1 + version: 1.2.1 yoctocolors: specifier: 2.1.1 version: 2.1.1 @@ -7041,8 +7041,8 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} - yocto-spinner@1.2.0: - resolution: {integrity: sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==} + yocto-spinner@1.2.1: + resolution: {integrity: sha512-9cbFWLhbiZp+820O4pkHGNncI7+MrUGzBOjw8NMG+ewsY+aG0DdEXnr19Smxao32YOjLZRMdn1UtaxcrXOYOIg==} engines: {node: '>=18.19'} yoctocolors@2.1.1: @@ -14179,7 +14179,7 @@ snapshots: yocto-queue@1.2.2: {} - yocto-spinner@1.2.0: + yocto-spinner@1.2.1: dependencies: yoctocolors: 2.1.1 diff --git a/src/output/pdf.ts b/src/output/pdf.ts index 4dd01e85..c4a81fe5 100644 --- a/src/output/pdf.ts +++ b/src/output/pdf.ts @@ -1,7 +1,8 @@ import fs from 'node:fs'; -import { URL } from 'node:url'; +import { pathToFileURL, URL } from 'node:url'; import type { Browser, Page } from 'puppeteer-core'; +import terminalLink from 'terminal-link'; import upath from 'upath'; import { cyan, gray, green, red } from 'yoctocolors'; @@ -43,18 +44,13 @@ export async function buildPDF({ ? upath.relative(config.entryContextDir, entry.source.pathname) : entry.source.href, ); - // TODO: yocto-spinner over-counts hyperlink text and erases unrelated log - // lines (sindresorhus/yocto-spinner#18). - // return `${terminalLink( - // formattedSourcePath, - // entry.source.type === 'file' - // ? pathToFileURL(entry.source.pathname).href - // : entry.source.href, - // { - // fallback: () => formattedSourcePath, - // }, - // )} ${entry.title ? gray(entry.title) : ''}`; - return `${formattedSourcePath} ${entry.title ? gray(entry.title) : ''}`; + return `${terminalLink( + formattedSourcePath, + entry.source.type === 'file' + ? pathToFileURL(entry.source.pathname).href + : entry.source.href, + { fallback: () => formattedSourcePath }, + )} ${entry.title ? gray(entry.title) : ''}`; } function entryText() { From 35a719b77f327e376198c61732d6699c48f20ae7 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 9 Jul 2026 08:39:40 +0900 Subject: [PATCH 06/10] feat: Show the pagination progress ETA as a live hh:mm:ss countdown --- src/output/pdf.ts | 22 +++++++++++++++++++++- src/output/progress-eta.ts | 18 +++++++----------- tests/progress-eta.test.ts | 21 +++++++++++---------- 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/src/output/pdf.ts b/src/output/pdf.ts index c4a81fe5..da1433d9 100644 --- a/src/output/pdf.ts +++ b/src/output/pdf.ts @@ -37,6 +37,9 @@ export async function buildPDF({ let paginationProgress: { pages: number; fraction: number } | undefined; let typesettingFinished = false; const etaEstimator = createEtaEstimator({ now: Date.now }); + let etaEstimateMs: number | undefined; + let etaEstimateTime = Date.now(); + let etaCountdownTimer: ReturnType | undefined; function stringifyEntry(entry: ManuscriptEntry) { const formattedSourcePath = cyan( @@ -66,7 +69,12 @@ export async function buildPDF({ 100, Math.round(paginationProgress.fraction * 100), ); - const eta = percent >= 100 ? undefined : etaEstimator.estimate(); + // Count down from the last estimate every second so the ETA keeps ticking + // toward zero between progress reports. + const eta = + percent >= 100 || etaEstimateMs === undefined + ? undefined + : Math.max(0, etaEstimateMs - (Date.now() - etaEstimateTime)); const suffix = eta === undefined ? `(${paginationProgress.pages} pages, ${percent}%)` @@ -112,6 +120,14 @@ export async function buildPDF({ } paginationProgress = { pages, fraction }; etaEstimator.update(fraction); + etaEstimateMs = etaEstimator.estimate(); + etaEstimateTime = Date.now(); + if (!etaCountdownTimer) { + etaCountdownTimer = setInterval(() => { + Logger.logUpdateProgress(renderBuildingText()); + }, 1000); + etaCountdownTimer.unref?.(); + } const entry = href ? findEntryByHref(href) : undefined; if (entry && entry !== lastEntry) { @@ -248,6 +264,10 @@ export async function buildPDF({ ); // Ignore progress dispatched after this point typesettingFinished = true; + if (etaCountdownTimer) { + clearInterval(etaCountdownTimer); + etaCountdownTimer = undefined; + } if (lastEntry) { Logger.logInfo(stringifyEntry(lastEntry)); diff --git a/src/output/progress-eta.ts b/src/output/progress-eta.ts index cf5d8c36..8ada1c66 100644 --- a/src/output/progress-eta.ts +++ b/src/output/progress-eta.ts @@ -45,15 +45,11 @@ export function createEtaEstimator({ } export function formatEta(ms: number): string { - const sec = Math.ceil(ms / 1000); - if (sec < 60) { - return `${sec}s`; - } - const min = Math.ceil(sec / 60); - if (min < 60) { - return `${min}m`; - } - const hour = Math.floor(min / 60); - const restMin = min % 60; - return restMin ? `${hour}h${restMin}m` : `${hour}h`; + const total = Math.max(0, Math.ceil(ms / 1000)); + const hh = Math.floor(total / 3600); + const mm = Math.floor((total % 3600) / 60); + const ss = total % 60; + const pad = (n: number) => String(n).padStart(2, '0'); + // hh is not capped at 24h so runaway estimates stay visible (e.g. 27:14:03) + return `${pad(hh)}:${pad(mm)}:${pad(ss)}`; } diff --git a/tests/progress-eta.test.ts b/tests/progress-eta.test.ts index 17d2723b..45a3515e 100644 --- a/tests/progress-eta.test.ts +++ b/tests/progress-eta.test.ts @@ -39,19 +39,20 @@ describe('createEtaEstimator', () => { }); describe('formatEta', () => { - it('shows seconds under a minute', () => { - expect(formatEta(1000)).toBe('1s'); - expect(formatEta(34000)).toBe('34s'); + it('shows a zero-padded hh:mm:ss under a minute', () => { + expect(formatEta(1000)).toBe('00:00:01'); + expect(formatEta(34000)).toBe('00:00:34'); }); - it('rounds up to minutes under an hour', () => { - expect(formatEta(59999)).toBe('1m'); - expect(formatEta(61000)).toBe('2m'); - expect(formatEta(600000)).toBe('10m'); + it('rounds up to whole seconds and carries into minutes', () => { + expect(formatEta(59999)).toBe('00:01:00'); + expect(formatEta(61000)).toBe('00:01:01'); + expect(formatEta(600000)).toBe('00:10:00'); }); - it('formats hours with the remaining minutes', () => { - expect(formatEta(3600000)).toBe('1h'); - expect(formatEta(3900000)).toBe('1h5m'); + it('never caps the hours field at 24h', () => { + expect(formatEta(3600000)).toBe('01:00:00'); + expect(formatEta(3900000)).toBe('01:05:00'); + expect(formatEta(90061000)).toBe('25:01:01'); }); }); From 14a6e7651c46b2198ad461fd0798c66867192088 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 9 Jul 2026 08:40:03 +0900 Subject: [PATCH 07/10] fix: Remove the ETA; the live countdown shows it constantly drifts --- .changeset/lazy-baths-divide.md | 2 +- src/output/pdf.ts | 29 +---------------- src/output/progress-eta.ts | 55 ------------------------------- tests/progress-eta.test.ts | 58 --------------------------------- 4 files changed, 2 insertions(+), 142 deletions(-) delete mode 100644 src/output/progress-eta.ts delete mode 100644 tests/progress-eta.test.ts diff --git a/.changeset/lazy-baths-divide.md b/.changeset/lazy-baths-divide.md index e8a10a5c..7fea944b 100644 --- a/.changeset/lazy-baths-divide.md +++ b/.changeset/lazy-baths-divide.md @@ -2,4 +2,4 @@ '@vivliostyle/cli': minor --- -Show pagination progress (current document, page count, percentage, and estimated time remaining) during PDF builds. +Show pagination progress (current document, page count, and percentage) during PDF builds. diff --git a/src/output/pdf.ts b/src/output/pdf.ts index da1433d9..864d3e47 100644 --- a/src/output/pdf.ts +++ b/src/output/pdf.ts @@ -17,7 +17,6 @@ import { Logger } from '../logger.js'; import { getViewerFullUrl } from '../server.js'; import { pathEquals, toError } from '../util.js'; import { type PageSizeData, PostProcess } from './pdf-postprocess.js'; -import { createEtaEstimator, formatEta } from './progress-eta.js'; export async function buildPDF({ target, @@ -36,10 +35,6 @@ export async function buildPDF({ let lastEntry: ManuscriptEntry | undefined; let paginationProgress: { pages: number; fraction: number } | undefined; let typesettingFinished = false; - const etaEstimator = createEtaEstimator({ now: Date.now }); - let etaEstimateMs: number | undefined; - let etaEstimateTime = Date.now(); - let etaCountdownTimer: ReturnType | undefined; function stringifyEntry(entry: ManuscriptEntry) { const formattedSourcePath = cyan( @@ -69,16 +64,7 @@ export async function buildPDF({ 100, Math.round(paginationProgress.fraction * 100), ); - // Count down from the last estimate every second so the ETA keeps ticking - // toward zero between progress reports. - const eta = - percent >= 100 || etaEstimateMs === undefined - ? undefined - : Math.max(0, etaEstimateMs - (Date.now() - etaEstimateTime)); - const suffix = - eta === undefined - ? `(${paginationProgress.pages} pages, ${percent}%)` - : `(${paginationProgress.pages} pages, ${percent}%, ETA ${formatEta(eta)})`; + const suffix = `(${paginationProgress.pages} pages, ${percent}%)`; return `${base} ${gray(suffix)}`; } @@ -119,15 +105,6 @@ export async function buildPDF({ return; } paginationProgress = { pages, fraction }; - etaEstimator.update(fraction); - etaEstimateMs = etaEstimator.estimate(); - etaEstimateTime = Date.now(); - if (!etaCountdownTimer) { - etaCountdownTimer = setInterval(() => { - Logger.logUpdateProgress(renderBuildingText()); - }, 1000); - etaCountdownTimer.unref?.(); - } const entry = href ? findEntryByHref(href) : undefined; if (entry && entry !== lastEntry) { @@ -264,10 +241,6 @@ export async function buildPDF({ ); // Ignore progress dispatched after this point typesettingFinished = true; - if (etaCountdownTimer) { - clearInterval(etaCountdownTimer); - etaCountdownTimer = undefined; - } if (lastEntry) { Logger.logInfo(stringifyEntry(lastEntry)); diff --git a/src/output/progress-eta.ts b/src/output/progress-eta.ts deleted file mode 100644 index 8ada1c66..00000000 --- a/src/output/progress-eta.ts +++ /dev/null @@ -1,55 +0,0 @@ -export type EtaEstimator = { - update(fraction: number): void; - estimate(): number | undefined; -}; - -const SMOOTHING = 0.3; - -/** - * Estimates the remaining time from the progress fraction, smoothing the - * progress rate with an exponential moving average. - */ -export function createEtaEstimator({ - now, -}: { - now: () => number; -}): EtaEstimator { - let lastFraction = 0; - let lastTime: number | undefined; - let ratePerMs: number | undefined; - - return { - update(fraction) { - const time = now(); - lastTime ??= time; - if (fraction > lastFraction) { - const elapsed = time - lastTime; - if (elapsed > 0) { - const rate = (fraction - lastFraction) / elapsed; - ratePerMs = - ratePerMs === undefined - ? rate - : ratePerMs * (1 - SMOOTHING) + rate * SMOOTHING; - } - lastFraction = fraction; - lastTime = time; - } - }, - estimate() { - if (!ratePerMs) { - return; - } - return (1 - lastFraction) / ratePerMs; - }, - }; -} - -export function formatEta(ms: number): string { - const total = Math.max(0, Math.ceil(ms / 1000)); - const hh = Math.floor(total / 3600); - const mm = Math.floor((total % 3600) / 60); - const ss = total % 60; - const pad = (n: number) => String(n).padStart(2, '0'); - // hh is not capped at 24h so runaway estimates stay visible (e.g. 27:14:03) - return `${pad(hh)}:${pad(mm)}:${pad(ss)}`; -} diff --git a/tests/progress-eta.test.ts b/tests/progress-eta.test.ts deleted file mode 100644 index 45a3515e..00000000 --- a/tests/progress-eta.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { createEtaEstimator, formatEta } from '../src/output/progress-eta.js'; - -function createClock(start = 0) { - let time = start; - return { - now: () => time, - advance: (ms: number) => { - time += ms; - }, - }; -} - -describe('createEtaEstimator', () => { - it('returns an estimate from the second advancing update', () => { - const clock = createClock(); - const eta = createEtaEstimator({ now: clock.now }); - eta.update(0.1); - expect(eta.estimate()).toBeUndefined(); - clock.advance(1000); - eta.update(0.2); - // 80% remains at 10% per second - expect(eta.estimate()).toBeCloseTo(8000, 0); - }); - - it('estimates the remaining time from the progress rate', () => { - const clock = createClock(); - const eta = createEtaEstimator({ now: clock.now }); - eta.update(0); - // Advances 10% per second at a constant rate - for (let i = 1; i <= 5; i++) { - clock.advance(1000); - eta.update(i / 10); - } - // 50% remains at 10% per second - expect(eta.estimate()).toBeCloseTo(5000, 0); - }); -}); - -describe('formatEta', () => { - it('shows a zero-padded hh:mm:ss under a minute', () => { - expect(formatEta(1000)).toBe('00:00:01'); - expect(formatEta(34000)).toBe('00:00:34'); - }); - - it('rounds up to whole seconds and carries into minutes', () => { - expect(formatEta(59999)).toBe('00:01:00'); - expect(formatEta(61000)).toBe('00:01:01'); - expect(formatEta(600000)).toBe('00:10:00'); - }); - - it('never caps the hours field at 24h', () => { - expect(formatEta(3600000)).toBe('01:00:00'); - expect(formatEta(3900000)).toBe('01:05:00'); - expect(formatEta(90061000)).toBe('25:01:01'); - }); -}); From c8d777bec6f29fd0fed9686a3f0a20f018951c95 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 9 Jul 2026 09:04:36 +0900 Subject: [PATCH 08/10] fix: Don't emit a spurious 0% progress line for the first document --- src/output/pdf.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/output/pdf.ts b/src/output/pdf.ts index 864d3e47..376c903b 100644 --- a/src/output/pdf.ts +++ b/src/output/pdf.ts @@ -114,7 +114,7 @@ export async function buildPDF({ Logger.startLogging(renderBuildingText()); } else { lastEntry = entry; - Logger.logUpdate(renderBuildingText()); + Logger.logUpdateProgress(renderBuildingText()); } return; } From 26325cb4247e8044fe04d0a9d777d7237bd48ce7 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 9 Jul 2026 10:55:30 +0900 Subject: [PATCH 09/10] fix: Don't checkpoint 'Building pages' until the first document arrives --- src/output/pdf.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/output/pdf.ts b/src/output/pdf.ts index 376c903b..68517b52 100644 --- a/src/output/pdf.ts +++ b/src/output/pdf.ts @@ -115,6 +115,7 @@ export async function buildPDF({ } else { lastEntry = entry; Logger.logUpdateProgress(renderBuildingText()); + Logger.logInfo('Building pages'); } return; } @@ -128,8 +129,7 @@ export async function buildPDF({ signal, config, onBrowserOpen: () => { - Logger.logInfo('Building pages'); - Logger.logUpdateProgress('Building pages'); + Logger.logUpdate('Building pages'); }, onPageOpen: async (openedPage) => { openedPage.on('pageerror', (error) => { From a66e12bc6831564ffe8e7ee10f0075cfe3bacf76 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 9 Jul 2026 10:13:12 +0900 Subject: [PATCH 10/10] fix: Attribute pagination progress to contents and cover entries, not just manuscripts --- src/output/pdf.ts | 47 +++++++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/src/output/pdf.ts b/src/output/pdf.ts index 68517b52..373704b5 100644 --- a/src/output/pdf.ts +++ b/src/output/pdf.ts @@ -8,7 +8,7 @@ import { cyan, gray, green, red } from 'yoctocolors'; import { launchPreview, runBrowserOperationWithAbort } from '../browser.js'; import type { - ManuscriptEntry, + ParsedEntry, PdfOutput, ResolvedTaskConfig, } from '../config/resolve.js'; @@ -32,23 +32,33 @@ export async function buildPDF({ const viewerFullUrl = await getViewerFullUrl(config); Logger.debug('viewerFullUrl', viewerFullUrl); - let lastEntry: ManuscriptEntry | undefined; + let lastEntry: ParsedEntry | undefined; let paginationProgress: { pages: number; fraction: number } | undefined; let typesettingFinished = false; - function stringifyEntry(entry: ManuscriptEntry) { - const formattedSourcePath = cyan( - entry.source.type === 'file' - ? upath.relative(config.entryContextDir, entry.source.pathname) - : entry.source.href, - ); - return `${terminalLink( - formattedSourcePath, - entry.source.type === 'file' - ? pathToFileURL(entry.source.pathname).href - : entry.source.href, - { fallback: () => formattedSourcePath }, - )} ${entry.title ? gray(entry.title) : ''}`; + function stringifyEntry(entry: ParsedEntry) { + const title = entry.title ? gray(entry.title) : ''; + const source = entry.source ?? entry.template; + const fileLink = (file: string) => ({ + path: upath.relative(config.entryContextDir, file), + href: pathToFileURL(file).href, + }); + let input: { path: string; href: string } | undefined; + if (source?.type === 'file') { + input = fileLink(source.pathname); + } else if (source) { + // remote (uri) source + input = { path: source.href, href: source.href }; + } else if ('coverImageSrc' in entry) { + input = fileLink(entry.coverImageSrc); + } + if (!input) { + // generated entry with no input (auto-generated TOC) + return title; + } + const label = cyan(input.path); + const link = terminalLink(label, input.href, { fallback: () => label }); + return title ? `${link} ${title}` : link; } function entryText() { @@ -68,15 +78,12 @@ export async function buildPDF({ return `${base} ${gray(suffix)}`; } - function findEntryByHref(href: string): ManuscriptEntry | undefined { + function findEntryByHref(href: string): ParsedEntry | undefined { if (!URL.canParse(href)) { return; } const url = new URL(href); - return config.entries.find((candidate): candidate is ManuscriptEntry => { - if (!('source' in candidate)) { - return false; - } + return config.entries.find((candidate) => { if (url.protocol === 'file:') { return pathEquals(candidate.target, decodeURI(url.pathname)); }