diff --git a/.changeset/lazy-baths-divide.md b/.changeset/lazy-baths-divide.md new file mode 100644 index 00000000..7fea944b --- /dev/null +++ b/.changeset/lazy-baths-divide.md @@ -0,0 +1,5 @@ +--- +'@vivliostyle/cli': minor +--- + +Show pagination progress (current document, page count, and percentage) during PDF builds. 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/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..373704b5 100644 --- a/src/output/pdf.ts +++ b/src/output/pdf.ts @@ -1,14 +1,14 @@ import fs from 'node:fs'; -import { URL } from 'node:url'; +import { pathToFileURL, 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'; import { launchPreview, runBrowserOperationWithAbort } from '../browser.js'; import type { - ManuscriptEntry, + ParsedEntry, PdfOutput, ResolvedTaskConfig, } from '../config/resolve.js'; @@ -32,50 +32,102 @@ 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: 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 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' - ? `file://${entry.source.pathname}` - : entry.source.href, - { - fallback: () => formattedSourcePath, - }, - )} ${entry.title ? gray(entry.title) : ''}`; + function entryText() { + return lastEntry ? stringifyEntry(lastEntry) : 'Building pages'; } - 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 renderBuildingText() { + const base = entryText(); + if (!paginationProgress) { + return base; + } + const percent = Math.min( + 100, + Math.round(paginationProgress.fraction * 100), ); - if (entry) { - if (!lastEntry) { + const suffix = `(${paginationProgress.pages} pages, ${percent}%)`; + return `${base} ${gray(suffix)}`; + } + + function findEntryByHref(href: string): ParsedEntry | undefined { + if (!URL.canParse(href)) { + return; + } + const url = new URL(href); + return config.entries.find((candidate) => { + 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 }; + + const entry = href ? findEntryByHref(href) : undefined; + if (entry && entry !== lastEntry) { + if (lastEntry) { + Logger.logInfo(stringifyEntry(lastEntry)); + lastEntry = entry; + Logger.startLogging(renderBuildingText()); + } else { lastEntry = entry; - Logger.logUpdate(stringifyEntry(entry)); - return; + Logger.logUpdateProgress(renderBuildingText()); + Logger.logInfo('Building pages'); } - Logger.logSuccess(stringifyEntry(lastEntry)); - Logger.startLogging(stringifyEntry(entry)); - lastEntry = entry; + return; } + + Logger.logUpdateProgress(renderBuildingText()); } const { browser, page, closeBrowser } = await launchPreview({ @@ -86,7 +138,7 @@ export async function buildPDF({ onBrowserOpen: () => { Logger.logUpdate('Building pages'); }, - onPageOpen: (openedPage) => { + onPageOpen: async (openedPage) => { openedPage.on('pageerror', (error) => { Logger.logError(red(toError(error).message)); }); @@ -121,8 +173,6 @@ export async function buildPDF({ response.url(), ); - handleEntry(response); - if (400 > response.status() && 200 <= response.status()) { return; } @@ -134,6 +184,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 +246,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' =>