EmbedPDF

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.ts
import { Component, inject, input } from '@angular/core';
import { EpdfComments, annotationKey } from '@embedpdf/angular/annotation';
import type { EpdfStage } from '@embedpdf/angular/stage';

/** Next to `<epdf-stage #stage="epdfStage">`: `<app-thread-links [stage]="stage" />`. */
@Component({
  selector: 'app-thread-links',
  template: `
    @for (thread of comments.threads(); track annotationKey(thread.root.ref)) {
      <button (click)="stage().reveal(thread.page, { rect: thread.root.rect })">
        {{ thread.root.contents }}, page {{ thread.pageLabel }}
      </button>
    }
  `,
})
export class ThreadLinks {
  readonly stage = input.required<EpdfStage>();

  protected readonly comments = inject(EpdfComments);
  protected readonly annotationKey = annotationKey;
}

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 {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  ViewEncapsulation,
} from '@angular/core';
import { EpdfDocument, EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { EpdfSearch, EpdfSearchLayer, withSearch } from '@embedpdf/angular/search';
import { localEngine } from '@embedpdf/engine';

const welcome = async (): Promise<OpenInput> => {
  const response = await fetch('/pdfs/welcome.pdf');
  return { kind: 'bytes', id: 'welcome', bytes: new Uint8Array(await response.arrayBuffer()) };
};

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfSearchLayer],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: welcome }] },
      withStage(),
      withRender(),
      withSearch(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './pass-through.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <div *epdfDocumentGate="let document; fallback: loading" class="layout">
      <!-- A search hit says where it is in page coordinates; the Stage takes them as they are. -->
      <ol class="hits">
        @for (hit of firstHits(); track hit.page.objectNumber + ':' + hit.start) {
          @if (hit.bounds; as bounds) {
            <li>
              <button
                type="button"
                class="hit"
                (click)="stage.reveal(hit.page, { rect: bounds, anchor: { y: 0.35 } })"
              >
                <span class="where">page {{ hit.pageIndex + 1 }}</span>
                <code class="rect">x {{ round(bounds.x) }}, y {{ round(bounds.y) }}</code>
              </button>
            </li>
          }
        }
      </ol>
      <epdf-stage #stage="epdfStage" class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-search-layer />
        </ng-template>
      </epdf-stage>
    </div>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  private readonly search = inject(EpdfSearch);
  private readonly document = inject(EpdfDocument);
  protected readonly firstHits = computed(() => this.search.hits().slice(0, 8));
  protected readonly round = Math.round;

  constructor() {
    effect(() => {
      if (this.document.status() === 'ready') void this.search.search({ text: 'PDF' });
    });
  }
}

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 template or inside <epdf-page-view>, 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.ts
import { Component } from '@angular/core';
import { EpdfRenderLayer } from '@embedpdf/angular/render';
import { EpdfPageTemplate, EpdfStage } from '@embedpdf/angular/stage';

@Component({
  selector: 'app-pages',
  imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
  template: `
    <epdf-stage>
      <ng-template epdfPage let-page>
        <epdf-render-layer />
        @for (point of pins.get(page.ref.objectNumber) ?? []; track $index) {
          <!-- page coordinates → pixels on this page, at its zoom; the page turns them with it -->
          @let at = page.transform().toPixels(point);
          <div style="position: absolute" [style.left.px]="at.x" [style.top.px]="at.y">📌</div>
        }
      </ng-template>
    </epdf-stage>
  `,
})
export class Pages {
  /** Your own data: points in page coordinates, by the page's object number. */
  protected readonly pins = new Map<number, { x: number; y: number }[]>();
}
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 {
  ChangeDetectionStrategy,
  Component,
  computed,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { EpdfPageContext, OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageChrome, EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { localEngine } from '@embedpdf/engine';

const welcome = async (): Promise<OpenInput> => {
  const response = await fetch('/pdfs/welcome.pdf');
  return { kind: 'bytes', id: 'welcome', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// 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 };
}

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfPageChrome, EpdfRenderLayer],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: welcome }] },
      withStage(),
      withRender(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './pins.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <div class="toolbar">
        <button type="button" class="button" (click)="stage.zoomOut()">Zoom out</button>
        <button type="button" class="button" (click)="stage.zoomIn()">Zoom in</button>
        <button type="button" class="button" (click)="stage.rotateViewBy(90)">Rotate ⟳</button>
        <button type="button" class="button" (click)="pins.set([])">Clear pins</button>
        <output class="badge">
          @if (last(); as last) {
            page <strong>{{ last.pageIndex + 1 }}</strong> · x
            <strong>{{ last.point.x.toFixed(1) }}</strong> · y
            <strong>{{ last.point.y.toFixed(1) }}</strong>
          } @else {
            Click a page to drop a pin
          }
        </output>
      </div>
      <epdf-stage #stage="epdfStage" class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
        </ng-template>
        <!-- What's drawn here sits over the page and never turns with it, so it converts with
             pageToView, which includes the turn. -->
        <ng-template epdfPageChrome let-page>
          <div class="surface" (pointerdown)="press($event)" (click)="drop($event, page)">
            @if (page.pageIndex() === 0) {
              <!-- Many things at once: the browser maps page points with one matrix. -->
              <div class="page-space" [style.transform]="page.transform().cssMatrix">
                <div class="inch">1 inch</div>
              </div>
            }
            @for (pin of pins(); track $index) {
              @if (pin.pageIndex === page.pageIndex()) {
                <!-- A point on the page, to pixels on it: at the last moment. -->
                @let at = page.transform().pageToView(pin.point);
                <div class="pin" [style.left.px]="at.x" [style.top.px]="at.y"></div>
              }
            }
          </div>
        </ng-template>
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  // One pin to start with: an inch in from the first page's top-left corner.
  protected readonly pins = signal<Pin[]>([{ pageIndex: 0, point: { x: 72, y: 72 } }]);
  protected readonly last = computed(() => this.pins().at(-1) ?? null);
  // Where the pointer went down: a press that moved was a drag to scroll, not a click.
  private pressed = { x: 0, y: 0 };

  protected press(event: PointerEvent) {
    this.pressed = { x: event.clientX, y: event.clientY };
  }

  protected drop(event: MouseEvent, page: EpdfPageContext) {
    const moved = Math.hypot(event.clientX - this.pressed.x, event.clientY - this.pressed.y);
    if (moved > 4) return;
    // A pointer event, to a point on the page.
    const point = page.toPagePoint(event.clientX, event.clientY);
    this.pins.update((current) => [...current, { pageIndex: page.pageIndex(), point }]);
  }
}

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.

Was this page helpful?

Your feedback goes directly to the documentation team.