---
title: "Coordinates — Angular"
description: "EmbedPDF has one way to say where something is: points from the page's top-left corner, y going down. Pixels only come in when you draw."
framework: "Angular"
source: "https://www.embedpdf.com/docs/headless/angular/concepts/coordinates"
---

# 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 }`:

```ts
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`**

```typescript
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:

**`pass-through.ts`**

```typescript
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' });
    });
  }
}
```

**`pass-through.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The matches beside the pages; above them on a narrow screen */
.layout {
  container-type: inline-size;
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

.hits {
  flex: 1 1 100%;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 6px;
  align-content: start;
  margin: 0;
  padding: 0;
  list-style: none;
}

.hit {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  width: 100%;
  font: inherit;
  text-align: left;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 7px 10px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
}

.hit:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.hit:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.where {
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
}

.rect {
  font-size: 11.5px;
  color: var(--accent);
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
  flex: 1 1 100%;
}

@container (min-width: 620px) {
  .hits {
    flex: 0 0 180px;
    grid-template-columns: 1fr;
  }

  .stage {
    flex: 1 1 0;
    min-width: 0;
  }
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

## 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 `===`.

```ts
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](../viewing/stage#labels-and-buttons-on-every-page): that
  box stays upright, so convert with `pageToView`, which includes the page's turn.

An [annotation renderer](../annotations/custom-rendering) converts nothing: it draws into a frame the
layer places and turns for it.

**`pin.ts`**

```typescript
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 }[]>();
}
```

| From              | To                | On the page                                      | Over the page                        |
| :---------------- | :---------------- | :----------------------------------------------- | :----------------------------------- |
| A point on a page | Pixels            | `page.transform.toPixels(point)`                 | `page.transform.pageToView(point)`   |
| A box on a page   | Pixels            | two corners through `toPixels`                   | `page.transform.pageToViewRect(box)` |
| Pixels            | A point on a page | `page.transform.fromPixels(point)`               | `page.transform.viewToPage(point)`   |
| A pointer event   | A point on a page | `page.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.

**`pins.ts`**

```typescript
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 }]);
  }
}
```

**`pins.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
  --mark: #0876fd;
}

/* Zoom and turn the view: the pins stay on their spot of the page */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.button {
  min-width: 38px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 11px;
  cursor: pointer;
  white-space: nowrap;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease;
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:active {
  transform: translateY(1px);
}

.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
  transform: none;
}

.badge {
  font-size: 12.5px;
  font-weight: 600;
  color: var(--muted);
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 6px 11px;
  white-space: nowrap;
  margin-left: auto;
}

.badge strong {
  color: var(--ink);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

/* Over each page, catching clicks */
.surface {
  position: absolute;
  inset: 0;
  cursor: crosshair;
}

.pin {
  position: absolute;
  width: 14px;
  height: 14px;
  margin: -7px 0 0 -7px;
  border: 2px solid #fff;
  border-radius: 50%;
  background: var(--mark);
  box-shadow: 0 1px 4px rgb(7 32 76 / 0.45);
  pointer-events: none;
}

/* A layer in page points: 1 unit here is 1 point on the page */
.page-space {
  position: absolute;
  left: 0;
  top: 0;
  transform-origin: 0 0;
  pointer-events: none;
}

.inch {
  position: absolute;
  left: 72px;
  top: 72px;
  width: 72px;
  height: 72px;
  box-sizing: border-box;
  display: flex;
  align-items: flex-end;
  justify-content: flex-end;
  padding: 3px;
  font-size: 8px;
  font-weight: 700;
  color: var(--mark);
  border: 1px dashed var(--mark);
  background: rgb(8 118 253 / 0.06);
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

> **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](https://www.embedpdf.com/docs/engine/concepts/coordinates) in the
engine docs.

## Cheat sheet

| You're…                                            | Use              | Shape                                                                 |
| :------------------------------------------------- | :--------------- | :-------------------------------------------------------------------- |
| Working with annotations, text, search or `reveal` | page coordinates | `{ x, y, width, height }` and `{ x, y }`                              |
| Drawing on a page right now                        | `page.transform` | pixels: convert, never store (`toPixels` on it, `pageToView` over it) |
| Talking to a tool that reads the PDF's own numbers | `pdfRectOf()`    | `{ 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.
