Skip to content

Support for progressive rendering of a page #190

Closed

Description

@arun-mani-j

Hi.

When we want to render a page in web app, we call the page.toPixmap (this is done in a worker to not freeze UI). But the problem is, suppose the user clicks the Next button continuously, then we end up with sequence of calls to page.toPixmap. Once the user finally stopped clicking Next, we need to wait for all the intermediate pages to be rendered and discarded to get the output for current page.

pdf.js returns RenderTask which can be canceled when the rendering is no longer required.

Is there a way to do the same in MuPDF?

I thought of doing something like using microtasks on the worker side:

  1. Divide the page into fixed blocks of a size, like say 64x64.
  2. Use matrix argument of page.toPixmap to decide which block to be rendered.
  3. If the request is cancelled in-between, then stop immediately.
  4. Else continue for all the blocks.
  5. Use an OffscreenCanvas to render the blocks together as the final image.

Does this sound good? Or does MuPDF offer a better way to do progressive rendering?

Thank you.

Activity

  1. arun-mani-j commented on Jul 5, 2026

    @arun-mani-j
    ContributorAuthor

    Any help regarding this, (sorry to ping) @jamie-lemon? :)

  2. jamie-lemon commented on Jul 6, 2026

    @jamie-lemon
    Collaborator

    Hi @arun-mani-j I think MuPDF.js doesn't really attempt to manage the UI - especially in relation to interactions. Therefore it is up to the developer to try to balance the interactions of their web app.

  3. arun-mani-j commented on Jul 6, 2026

    @arun-mani-j
    ContributorAuthor

    Thanks for the reply @jamie-lemon. Yea that's why it is a little tricky. I think MuPDF need not manage UI; but just need a way to render a page step by step.

    I can't think of how it can be solved on the app side either without launching multiple MuPDF.js workers for the same file, which does not sound efficient.

    PyMuPDF offers a parameter called clip - https://pymupdf.readthedocs.io/en/latest/page.html#Page.get_pixmap, which I think does what I'm asking. So if MuPDF.js offered the same, the issue would be solved. 😅

  4. jamie-lemon commented on Jul 6, 2026

    @jamie-lemon
    Collaborator

    @artifex-tor What do you think? Could the clip parameter be implemented?

  5. ccxvii commented on Jul 7, 2026

    @ccxvii

    @jamie-lemon There are two separate concerns here.

    1. Canceling an in-progress render. This requires the "cookie" API from C to flag to the task that it should stop. This requires shared memory. We don't support this.

    2. Rendering a partial image. This we do support, but you have to assemble the lego blocks yourself. There are many different trade-offs involved, and the core library APIs don't lock you into one particular set of trade-offs like the high level PyMuPDF functions do.

    Create a Pixmap for the slice you want to render, then create a DrawDevice to render to it, and pass the device to either Page.run or DisplayList.run.

    Where a file is slow varies a lot. Sometimes it's parsing the vector graphic stream (for complicated CAD drawings), decoding images (high resolution photographic content), and sometimes it's rendering (complicated transparency blending stacks).

    Rendering the page in multiple slices will not make the parsing/decoding steps any faster. In fact, it may make them slower because you need to decode the same content once for each slice. You can use the DisplayList as an intermediate step, so that you only need to parse the content once, and then replay from the display list (which is a faster in-memory representation) to render each slice.

  6. ccxvii commented on Jul 7, 2026

    @ccxvii

    Here is an example script of rendering to a display list and then two slices.

    import mupdf from "mupdf"
     
    function renderSlice(page, transform, bbox) {
            var pixmap = new mupdf.Pixmap(mupdf.ColorSpace.DeviceRGB, bbox, false)
            pixmap.clear(255)
            var device = new mupdf.DrawDevice(mupdf.Matrix.identity, pixmap)
            page.run(device, transform)
            device.close()
            return pixmap
    }
     
    var transform = mupdf.Matrix.scale(2, 2)
     
    var doc = mupdf.Document.openDocument("pdfref17.pdf")
    var page = doc.loadPage(1134).toDisplayList()
    var bbox = Rect.transform(page.getBounds(), transform)
     
    var h = bbox[3] - bbox[1]
    var top = [ bbox[0], bbox[1], bbox[2], bbox[1] + h/2 ]
    var bot = [ bbox[0], bbox[1] + h/2, bbox[2], bbox[3] ]
     
    renderSlice(page, transform, top).saveAsPNG("out1.png")
    renderSlice(page, transform, bot).saveAsPNG("out2.png")
    
    
  7. arun-mani-j commented on Jul 7, 2026

    @arun-mani-j
    ContributorAuthor

    Thank you so much @ccxvii. Yea the little slow-down in doing block by block is fine. Because the other alternative of frozen UI feels like a bad UX. I actually faced this when I was reading an encyclopedia at 3x zoom. May be the presence of a lot of pictures caused this.

    I have used your example to make a full example in TypeScript that does the blocks algorithm with AbortSignal to cancel.

    It works nicely on my side. I'm happy to make a PR if you think we can add this to examples in docs.

    import {
        Document,
        Rect,
        Pixmap,
        ColorSpace,
        Matrix,
        DrawDevice,
        DisplayList,
    } from "mupdf";
    import fs from "node:fs";
    import { createCanvas, Canvas } from "canvas";
    import { scheduler } from "node:timers/promises";
    
    const BLOCK_SIZE = 512;
    
    function* split(
        x0: number,
        y0: number,
        x1: number,
        y1: number,
    ): Iterable<Rect> {
        console.log("splitting", x0, y0, x1, y1);
    
        for (let x = x0; x < x1; x += BLOCK_SIZE) {
            for (let y = y0; y < y1; y += BLOCK_SIZE) {
                yield [
                    x,
                    y,
                    Math.min(x + BLOCK_SIZE, x1),
                    Math.min(y + BLOCK_SIZE, y1),
                ];
            }
        }
    }
    
    function renderBlock(display: DisplayList, transform: Matrix, bbox: Rect) {
        console.log("rendering block", bbox);
        const pixmap = new Pixmap(ColorSpace.DeviceRGB, bbox, true);
        pixmap.clear(255);
        const device = new DrawDevice(Matrix.identity, pixmap);
        display.run(device, transform);
        device.close();
        return pixmap;
    }
    
    async function render(
        doc: Document,
        idx: number,
        scale: number,
        signal: AbortSignal,
    ): Promise<Canvas> {
        const page = doc.loadPage(idx);
        const display = page.toDisplayList();
        const transform = Matrix.scale(scale, scale);
        const [x0, y0, x1, y1] = Rect.transform(display.getBounds(), transform);
    
        const canvas = createCanvas(x1 - x0, y1 - y0);
        const ctx = canvas.getContext("2d");
        if (!ctx) throw new Error("unable to get 2D context");
    
        for (const block of split(x0, y0, x1, y1)) {
            if (signal.aborted) break;
    
            const pixmap = renderBlock(display, transform, block);
            const pixels = pixmap.getPixels();
    
            const blockWidth = block[2] - block[0];
            const blockHeight = block[3] - block[1];
    
            const canvasX = block[0] - x0;
            const canvasY = block[1] - y0;
    
            const image = ctx.getImageData(
                canvasX,
                canvasY,
                blockWidth,
                blockHeight,
            );
            image.data.set(pixels);
            ctx.putImageData(image, canvasX, canvasY);
    
            pixmap.destroy();
    
            await scheduler.yield();
        }
    
        display.destroy();
        page.destroy();
    
        if (signal.aborted) throw new Error("rendering cancelled");
    
        return canvas;
    }
    
    const doc = Document.openDocument("sample.pdf");
    
    const controller = new AbortController();
    const id = setTimeout(() => controller.abort(), 5 * 1000);
    
    const canvas = await render(doc, 0, 20, controller.signal);
    clearTimeout(id);
    
    const data = canvas.toBuffer("image/png");
    fs.writeFileSync("out.png", data);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions