CloudPDF
DocsPricing
Start building

Coordinates

Every position in EmbedPDF is in page coordinates: points (1/72 inch) from the top-left corner of the page as it’s shown, with y going down, like HTML. Annotations, text, search results, selections, form fields and the places you scroll to all use them. A box is { x, y, width, height } and a point is { x, y }:

annotation.rect;
// { x: 100, y: 80, width: 20, height: 20 }

Colors work the same everywhere too: hex strings such as '#dc143c'. Angles are degrees, clockwise.

Pass values straight through#

Because every part speaks the same coordinates, a value from one goes to another as it is. A comment thread knows where its first comment is, and that’s where the Stage should go:

thread-links.tsx
import { annotationKey, useCommentThreads } from '@embedpdf/react/annotation';
import { useStage } from '@embedpdf/react/stage';

export function ThreadLinks() {
  const threads = useCommentThreads();
  const stage = useStage();

  return threads.map((thread) => (
    <button
      key={annotationKey(thread.root.ref)}
      onClick={() => stage.reveal(thread.page, { rect: thread.root.rect })}
    >
      {thread.root.contents}, page {thread.pageLabel}
    </button>
  ));
}

There’s nothing to convert, whether the page is zoomed, rotated or scrolled. A search match says where it is the same way, and the Stage goes there:

Loading live preview…

import { useEffect } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { SearchLayer, searchPlugin, useSearch, useSearchHits } from '@embedpdf/react/search';
import { cloudEngine } from '@cloudpdf/engine';

import './pass-through.css';

const engine = cloudEngine();
const plugins = [stagePlugin(), renderPlugin(), searchPlugin()];

const ebook: OpenInput = { kind: 'share', shareToken: 'shr_WGj1goAtlNN_fQ5OswPrbJQM' };

// A search hit says where it is in page coordinates; the Stage takes them as they are.
function Hits() {
  const search = useSearch();
  const stage = useStage();
  const hits = useSearchHits();

  useEffect(() => {
    void search.search({ text: 'PDF' });
  }, [search]);

  return (
    <ol className="hits">
      {hits.slice(0, 8).map((hit) =>
        hit.bounds ? (
          <li key={`${hit.page.objectNumber}:${hit.start}`}>
            <button
              type="button"
              className="hit"
              onClick={() => stage.reveal(hit.page, { rect: hit.bounds, anchor: { y: 0.35 } })}
            >
              <span className="where">page {hit.pageIndex + 1}</span>
              <code className="rect">
                x {Math.round(hit.bounds.x)}, y {Math.round(hit.bounds.y)}
              </code>
            </button>
          </li>
        ) : null,
      )}
    </ol>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <div className="layout">
          <Hits />
          <Stage className="stage">
            {() => (
              <>
                <RenderLayer />
                <SearchLayer />
              </>
            )}
          </Stage>
        </div>
      </DocumentGate>
    </Viewer>
  );
}

Pages#

A call that takes a page takes its ref or its index. A ref stays the same when pages move, so keep refs, not indexes, in your own state. Everything that is on a page hands you its page’s ref, such as annotation.ref.page or a search hit’s hit.page. A ref is an object, so compare two with pageRefsEqual(left, right) from /runtime, not ===.

stage.goToPage(4); // the fifth page, counting from 0
stage.goToPage(hit.page); // the page of a search hit

Pixels, only when you draw#

Pixels change with every zoom, rotation and scroll, so nothing in EmbedPDF stores them. When you draw something of your own on a page, convert at the last moment with the page’s transform. Which call depends on where you draw:

  • On the page, in the Stage’s render function or inside <PageView>, with the layers: that box turns with the page, so convert for zoom only, with toPixels. What you draw turns along.
  • Over the page, in its page chrome: that box stays upright, so convert with pageToView, which includes the page’s turn.

An annotation renderer converts nothing: it draws into a frame the layer places and turns for it.

pin.tsx
import { RenderLayer } from '@embedpdf/react/render';
import { Stage } from '@embedpdf/react/stage';

// Your own data: points in page coordinates, by the page's object number.
const pins = new Map<number, { x: number; y: number }[]>();

export const Pages = () => (
  <Stage>
    {(page) => (
      <>
        <RenderLayer />
        {(pins.get(page.ref.objectNumber) ?? []).map((point, index) => {
          // page coordinates → pixels on this page, at its zoom; the page turns them with it
          const { x, y } = page.transform.toPixels(point);
          return (
            <div key={index} style={{ position: 'absolute', left: x, top: y }}>
              📌
            </div>
          );
        })}
      </>
    )}
  </Stage>
);
FromToOn the pageOver the page
A point on a pagePixelspage.transform.toPixels(point)page.transform.pageToView(point)
A box on a pagePixelstwo corners through toPixelspage.transform.pageToViewRect(box)
PixelsA point on a pagepage.transform.fromPixels(point)page.transform.viewToPage(point)
A pointer eventA point on a pagepage.toPagePoint(event.clientX, event.clientY)the same

To place many things over a page at once, put them in a container with transform: page.transform.cssMatrix and transform-origin: 0 0, and give them page coordinates: the browser does the zoom and the turn.

Loading live preview…

import { useRef, useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { cloudEngine } from '@cloudpdf/engine';

import './pins.css';

const engine = cloudEngine();
const plugins = [stagePlugin(), renderPlugin()];

const ebook: OpenInput = { kind: 'share', shareToken: 'shr_WGj1goAtlNN_fQ5OswPrbJQM' };

// A pin is a page and a point on it, in points from the page's top-left. No pixels.
interface Pin {
  pageIndex: number;
  point: { x: number; y: number };
}

function Controls({ last, onClear }: { last: Pin | null; onClear: () => void }) {
  const stage = useStage();

  return (
    <div className="toolbar">
      <button type="button" className="button" onClick={() => stage.zoomOut()}>
        Zoom out
      </button>
      <button type="button" className="button" onClick={() => stage.zoomIn()}>
        Zoom in
      </button>
      <button type="button" className="button" onClick={() => stage.rotateViewBy(90)}>
        Rotate ⟳
      </button>
      <button type="button" className="button" onClick={onClear}>
        Clear pins
      </button>
      <output className="badge">
        {last ? (
          <>
            page <strong>{last.pageIndex + 1}</strong> · x{' '}
            <strong>{last.point.x.toFixed(1)}</strong> · y{' '}
            <strong>{last.point.y.toFixed(1)}</strong>
          </>
        ) : (
          'Click a page to drop a pin'
        )}
      </output>
    </div>
  );
}

export default function App() {
  // One pin to start with: an inch in from the first page's top-left corner.
  const [pins, setPins] = useState<Pin[]>([{ pageIndex: 0, point: { x: 72, y: 72 } }]);
  // Where the pointer went down: a press that moved was a drag to scroll, not a click.
  const pressed = useRef({ x: 0, y: 0 });

  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <Controls last={pins[pins.length - 1] ?? null} onClear={() => setPins([])} />
        <Stage
          className="stage"
          // What's drawn here sits over the page and never turns with it, so it
          // converts with pageToView, which includes the turn.
          pageChrome={(page) => (
            <div
              className="surface"
              onPointerDown={(event) => (pressed.current = { x: event.clientX, y: event.clientY })}
              onClick={(event) => {
                const moved = Math.hypot(
                  event.clientX - pressed.current.x,
                  event.clientY - pressed.current.y,
                );
                if (moved > 4) return;
                // A pointer event, to a point on the page.
                const point = page.toPagePoint(event.clientX, event.clientY);
                setPins((current) => [...current, { pageIndex: page.pageIndex, point }]);
              }}
            >
              {page.pageIndex === 0 ? (
                // Many things at once: the browser maps page points with one matrix.
                <div className="page-space" style={{ transform: page.transform.cssMatrix }}>
                  <div className="inch">1 inch</div>
                </div>
              ) : null}
              {pins
                .filter((pin) => pin.pageIndex === page.pageIndex)
                .map((pin, index) => {
                  // A point on the page, to pixels on it: at the last moment.
                  const at = page.transform.pageToView(pin.point);
                  return <div key={index} className="pin" style={{ left: at.x, top: at.y }} />;
                })}
            </div>
          )}
        >
          {() => <RenderLayer />}
        </Stage>
      </DocumentGate>
    </Viewer>
  );
}

Try it: click a page to drop a pin, then zoom and turn the view. Each pin keeps its numbers and its spot on the page; only its pixels change.

Tools that use the PDF file’s numbers#

The PDF file stores positions in its own numbers, with y going up, and a page can sit anywhere in them. You only meet those numbers next to a tool that reads them, such as iText, PDFBox or Acrobat JavaScript. page.pdfCropBox says where the page sits in them, and pdfRectOf(), pdfPointOf(), pageBoxOf() and pagePointOf() convert. See Coordinates in the engine docs.

Cheat sheet#

You’re…UseShape
Working with annotations, text, search or revealpage coordinates{ x, y, width, height } and { x, y }
Drawing on a page right nowpage.transformpixels: convert, never store (toPixels on it, pageToView over it)
Talking to a tool that reads the PDF’s own numberspdfRectOf(){ left, bottom, right, top }

Bringing your own coordinate system? Page coordinates are a plain frame in points with y going down, so one transform maps them into yours.