This HTML page is not optimized for LLM or AI agent consumption. Fetch the Markdown version instead: /blog/pdfjs-area-annotations-canvas-capture.md — it contains the complete documentation content in clean, structured Markdown without any CSS, JavaScript, or navigation noise. How to build area annotations with canvas capture in PDF.js

Table of contents

    Area annotations let users draw a rectangle on a PDF page and capture that region as an image. This guide covers pointer tracking, PDF.js coordinate conversion, HTML Canvas screenshot capture, and rendering draggable overlays with React.
    How to build area annotations with canvas capture in PDF.js
    TL;DR
    • Capture pointer drag/release on a fullscreen overlay and translate the screen rectangle into PDF coordinates via the page’s PageViewport.
    • Crop the rendered canvas region with drawImage and toDataURL to store the area as a JPEG or PNG.
    • Render annotations back over the page with react-rnd, recropping on drag/resize, and recalculating positions on zoom/rotation.

    Prerequisites

    This guide assumes a working PDF.js viewer (PDFViewer + EventBus + a loaded pdfDocument). If you don’t have that yet, start with our guide on how to set up a custom PDF.js viewer in React.

    You’ll also need:

    • pdfjs-dist 4.x or later
    • The react-rnd package (npm install react-rnd) for draggable/resizable overlays
    • The screenToPdf/pdfToScreen/nodeLocationToScaled helpers from PDF.js coordinate systems: PDF to screen — this post uses them but doesn’t redefine them.

    Architecture

    User selects area tool
    -> Pointer tracking overlay captures drag start/end
    -> Selection box renders during drag
    -> On release: convert screen rectangle to PDF coordinates
    -> Capture canvas region as an image (JPEG/PNG)
    -> Save annotation with coordinates + image data
    -> Render as draggable/resizable overlay

    Step 1: Pointer tracking overlay

    Create a transparent overlay that captures pointer events when the area tool is active:

    function PointerArea({ disabled, selectionBox, style, shouldStart, onStart, onEnd }) {
    const [isDrawing, setIsDrawing] = React.useState(false);
    const [origin, setOrigin] = React.useState(null);
    const [current, setCurrent] = React.useState(null);
    const startElementRef = React.useRef(null);
    const handlePointerDown = (e) => {
    if (disabled) return;
    if (!shouldStart(e)) return;
    startElementRef.current = e.target;
    setOrigin({ x: e.clientX, y: e.clientY });
    setCurrent({ x: e.clientX, y: e.clientY });
    setIsDrawing(true);
    onStart?.();
    };
    const handlePointerMove = (e) => {
    if (!isDrawing) return;
    setCurrent({ x: e.clientX, y: e.clientY });
    };
    const handlePointerUp = (e) => {
    if (!isDrawing) return;
    setIsDrawing(false);
    onEnd(startElementRef.current, {
    clientOrigin: [origin.x, origin.y],
    clientTarget: [e.clientX, e.clientY],
    });
    };
    return (
    <div
    style={{ position: "fixed", inset: 0, zIndex: 100 }}
    onPointerDown={handlePointerDown}
    onPointerMove={handlePointerMove}
    onPointerUp={handlePointerUp}
    >
    {isDrawing && selectionBox && (
    <div
    style={{
    ...style,
    position: "absolute",
    left: Math.min(origin.x, current.x),
    top: Math.min(origin.y, current.y),
    width: Math.abs(current.x - origin.x),
    height: Math.abs(current.y - origin.y),
    }}
    />
    )}
    </div>
    );
    }

    Step 2: Handle selection completion

    When the pointer is released, determine which page was selected and convert coordinates:

    const handlePointerEnd = (startElement, origins) => {
    const page = getPageFromElement(startElement);
    if (!page) return;
    const pageBounds = page.canvasWrapper.getBoundingClientRect();
    const clientLeft = Math.min(origins.clientOrigin[0], origins.clientTarget[0]);
    const clientTop = Math.min(origins.clientOrigin[1], origins.clientTarget[1]);
    const canvasLeft = clientLeft - pageBounds.left;
    const canvasTop = clientTop - pageBounds.top;
    const width = Math.abs(origins.clientOrigin[0] - origins.clientTarget[0]);
    const height = Math.abs(origins.clientOrigin[1] - origins.clientTarget[1]);
    // Skip tiny selections (accidental clicks).
    if (width * height <= 100) return;
    const viewport = viewer.getPageView(page.number - 1).viewport;
    // Convert to PDF coordinates for storage.
    const pdfCoords = screenToPdf(
    { left: canvasLeft, top: canvasTop, width, height, pageNumber: page.number },
    viewport,
    );
    // Capture the canvas region as an image.
    const canvas = viewer.getPageView(page.number - 1).canvas;
    const imageContent = getAreaAsImage(canvas, {
    left: canvasLeft, top: canvasTop, width, height,
    });
    // Save annotation.
    onCreateAreaAnnotation(
    color,
    page.number,
    [pdfCoords.x1, pdfCoords.y1, pdfCoords.x2, pdfCoords.y2],
    imageContent,
    );
    };

    Step 3: Capturing canvas regions

    PDF.js renders pages on <canvas> elements. You can extract any rectangular region:

    function getAreaAsImage(canvas, position) {
    const { left, top, width, height } = position;
    const newCanvas = document.createElement("canvas");
    newCanvas.width = width;
    newCanvas.height = height;
    const ctx = newCanvas.getContext("2d");
    if (!ctx || !canvas) return "";
    // PDF.js renders at its own output scale, which is usually but not always
    // `window.devicePixelRatio`. Read the real scale from the rendered canvas.
    const dpr = canvas.width / canvas.clientWidth;
    ctx.drawImage(
    canvas,
    left * dpr, // source x (scaled for DPI).
    top * dpr, // source y.
    width * dpr, // source width.
    height * dpr, // source height.
    0, 0, // destination origin.
    width, height, // destination size (CSS pixels).
    );
    return newCanvas.toDataURL("image/jpeg");
    }

    DPI matters! PDF.js renders canvases at its own output scale, which is usually but not always equal to window.devicePixelRatio (PDF.js caps the scale and lets configurations override it). Derive the real scale from canvas.width / canvas.clientWidth rather than reading devicePixelRatio directly.

    Step 4: Render as draggable/resizable box

    Use react-rnd to render area annotations that users can drag and resize:

    import { Rnd } from "react-rnd";
    function AreaAnnotation({ annotation, viewport, page }) {
    const ref = React.useRef(null);
    const positionRef = React.useRef(null);
    // Update position when viewport changes (zoom/rotation).
    React.useEffect(() => {
    const pdfCoords = nodeLocationToScaled({
    page: annotation.page,
    rect: annotation.rect,
    });
    const position = pdfToScreen(pdfCoords, viewport);
    positionRef.current = position;
    ref.current?.updatePosition({ x: position.left, y: position.top });
    ref.current?.updateSize({ width: position.width, height: position.height });
    }, [annotation.rect, viewport]);
    const handleChangePosition = (newPosition) => {
    // Convert back to PDF coordinates.
    const pdfCoords = screenToPdf(
    { ...positionRef.current, ...newPosition },
    viewport,
    );
    const location = {
    page: pdfCoords.pageNumber,
    rect: [pdfCoords.x1, pdfCoords.y1, pdfCoords.x2, pdfCoords.y2],
    };
    // Recapture the canvas region with new bounds.
    const canvas = viewer.getPageView(page - 1).canvas;
    const newImage = getAreaAsImage(canvas, { ...positionRef.current, ...newPosition });
    // Save updated annotation.
    saveAnnotation(annotation.id, { rect: location.rect, image_content: newImage });
    };
    return (
    <Rnd
    ref={ref}
    style={{
    position: "absolute",
    background: "rgba(255, 94, 94, 0.4)",
    borderRadius: "8px",
    cursor: "pointer",
    }}
    onDragStop={(_, data) => {
    handleChangePosition({ top: data.y, left: data.x });
    }}
    onResizeStop={(_, __, ref, ___, position) => {
    handleChangePosition({
    top: position.y,
    left: position.x,
    width: ref.offsetWidth,
    height: ref.offsetHeight,
    });
    }}
    />
    );
    }

    Step 5: Disable text selection during drawing

    When the area tool is active, prevent text selection from interfering:

    function toggleDisableSelection(viewer, flag) {
    viewer.viewer?.classList.toggle("disableSelection", flag);
    }
    .disableSelection {
    user-select: none;
    -webkit-user-select: none;
    }
    .disableTouchAction {
    touch-action: none;
    }

    Enable disableSelection on pointer down, and disable on pointer up.

    Minimum size threshold

    Ignore accidental clicks by checking the area:

    if (width * height <= 100) return; // Less than ~10x10 pixels.

    Key points

    • Use a fullscreen overlay to capture pointer events outside the PDF container.
    • Subtract canvasWrapper.getBoundingClientRect() to get page-relative coordinates.
    • Derive the output scale from canvas.width / canvas.clientWidth rather than window.devicePixelRatio — PDF.js caps the render scale and configurations can override it.
    • canvas.toDataURL("image/jpeg") gives you a base64 string for storage (use "image/png" for lossless capture).
    • Recapture the canvas region when the user drags/resizes the annotation.
    • react-rnd provides drag and resize with a clean React API.
    • Update the Rnd position imperatively via ref.updatePosition() when the viewport changes.

    How Nutrient Web SDK handles this

    Nutrient Web SDK ships RectangleAnnotation with drag, resize, and draw-on-page interactions wired into the default UI. The rectangle tool appears in the toolbar without any extra code. Use the programmatic API when creating annotations from code — import flows, automation, server-driven annotations:

    const annotation = new NutrientViewer.Annotations.RectangleAnnotation({
    pageIndex: 0,
    boundingBox: new NutrientViewer.Geometry.Rect({
    left: 50,
    top: 100,
    width: 300,
    height: 200,
    }),
    strokeColor: NutrientViewer.Color.RED,
    strokeWidth: 2,
    });
    await instance.create(annotation);

    To export the pixels under a rectangle as an image — useful for callouts, off-page references, or sharing a region without the source PDF — use renderPageAsArrayBuffer to grab the rendered bytes for the given area. Refer to our guide on how to render visible area in current page for the full pattern.

    For other vector overlays, the SDK also supports EllipseAnnotation, PolygonAnnotation, and PolylineAnnotation — all draggable and resizable by default.

    FAQ

    JPEG or PNG for the captured region?

    JPEG keeps file size small for photographs and screenshots of dense pages, but it compresses text and line art poorly. PNG is lossless and noticeably better for scans, diagrams, or text-heavy regions — at the cost of bigger payloads. If you store many annotations per document, default to JPEG and switch to PNG selectively.

    Why use canvas.width / canvas.clientWidth instead of window.devicePixelRatio?

    PDF.js doesn’t always render at devicePixelRatio — it caps the output scale on huge pages and respects configurations that override it. The actual rendered resolution is whatever’s in the canvas’s intrinsic width/height, so deriving the scale from those gives you the correct pixel ratio every time.

    How do I support touch input?

    Pointer events already cover touch, mouse, and pen — pointerdown/pointermove/pointerup fire for all three. Add touch-action: none (the .disableTouchAction class in the CSS section above) to the drawing surface to stop the browser from interpreting the drag as a scroll.

    What happens when the page is rotated?

    PageViewport.convertToPdfPoint() returns coordinates in the PDF’s own coordinate space, so a rotated page captures correctly. The catch is that convertToPdfPoint can return x1 > x2 or y1 > y2 for rotated viewports — normalize with Math.min/Math.max before storing.

    Should I store the captured image or rerender it on demand?

    Rerendering is cheaper at write time and easier to keep current when the underlying PDF changes, but requires the original PDF to be available every time you display the annotation. Storing the cropped image (typical pattern in collaborative apps where the captured area is the artifact) costs storage but makes the annotation portable.

    Area annotations take five coordinated pieces: a pointer-tracking overlay, coordinate conversion, canvas DPI scaling, image capture, and a drag-resize library. The individual steps are manageable — the overhead is keeping them in sync as zoom, rotation, and viewport changes update the layout.


    See Nutrient Web SDK for built-in image and vector annotations without the canvas wiring, or follow the migration guide to switch from PDF.js. Talk to Sales about your requirements.

    Austin Nguyen

    Austin Nguyen

    AI Engineer

    When Austin isn’t pulling all-nighters to build new features, he enjoys watching science videos on YouTube and cooking.

    Explore related topics

    Try for free Ready to get started?