---
title: "Coordinates — Svelte"
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: "Svelte"
source: "https://www.cloudpdf.com/docs/headless/svelte/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.svelte`**

```svelte
<script lang="ts">
  import { annotationKey, useCommentThreads } from '@embedpdf/svelte/annotation';
  import { useStage } from '@embedpdf/svelte/stage';

  const threads = useCommentThreads();
  const stage = useStage();
</script>

{#each threads.current as thread (annotationKey(thread.root.ref))}
  <button onclick={() => stage.reveal(thread.page, { rect: thread.root.rect })}>
    {thread.root.contents}, page {thread.pageLabel}
  </button>
{/each}
```

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:

**`App.svelte`**

```svelte
<script lang="ts" module>
  import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
  import { RenderLayer, renderPlugin } from '@embedpdf/svelte/render';
  import { SearchLayer, searchPlugin } from '@embedpdf/svelte/search';
  import { Stage, stagePlugin } from '@embedpdf/svelte/stage';
  import { cloudEngine } from '@cloudpdf/engine';
  import Hits from './Hits.svelte';

  import '../pass-through.css';

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

  const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
</script>

<Viewer {engine} {plugins} initialDocuments={[{ source: welcome }]}>
  <DocumentGate>
    {#snippet fallback()}
      <p class="loading">Loading…</p>
    {/snippet}
    <div class="layout">
      <Hits />
      <Stage class="stage">
        <RenderLayer />
        <SearchLayer />
      </Stage>
    </div>
  </DocumentGate>
</Viewer>
```

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

**`Hits.svelte`**

```svelte
<!-- A search hit says where it is in page coordinates; the Stage takes them as they are. -->
<script lang="ts">
  import { useSearch, useSearchHits } from '@embedpdf/svelte/search';
  import { useStage } from '@embedpdf/svelte/stage';

  const search = useSearch();
  const stage = useStage();
  const hits = useSearchHits();

  $effect(() => {
    void search.search({ text: 'PDF' });
  });
</script>

<ol class="hits">
  {#each hits.current.slice(0, 8) as hit (`${hit.page.objectNumber}:${hit.start}`)}
    {@const bounds = hit.bounds}
    {#if bounds}
      <li>
        <button
          type="button"
          class="hit"
          onclick={() => stage.reveal(hit.page, { rect: bounds, anchor: { y: 0.35 } })}
        >
          <span class="where">page {hit.pageIndex + 1}</span>
          <code class="rect">x {Math.round(bounds.x)}, y {Math.round(bounds.y)}</code>
        </button>
      </li>
    {/if}
  {/each}
</ol>
```

## 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 snippet 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](../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.svelte`**

```svelte
<script lang="ts">
  import { RenderLayer } from '@embedpdf/svelte/render';
  import { Stage } from '@embedpdf/svelte/stage';

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

<Stage>
  {#snippet children(page)}
    <RenderLayer />
    {#each pins.get(page.ref.objectNumber) ?? [] as point}
      <!-- page coordinates → pixels on this page, at its zoom; the page turns them with it -->
      {@const at = page.transform.toPixels(point)}
      <div style:position="absolute" style:left="{at.x}px" style:top="{at.y}px">📌</div>
    {/each}
  {/snippet}
</Stage>
```

| 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.

**`App.svelte`**

```svelte
<script lang="ts" module>
  import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
  import { RenderLayer, renderPlugin } from '@embedpdf/svelte/render';
  import { Stage, stagePlugin } from '@embedpdf/svelte/stage';
  import { cloudEngine } from '@cloudpdf/engine';
  import Controls from './Controls.svelte';
  import type { Pin } from './pin';

  import '../pins.css';

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

  const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
</script>

<script lang="ts">
  // One pin to start with: an inch in from the first page's top-left corner.
  let pins = $state<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.
  let pressed = { x: 0, y: 0 };
</script>

<Viewer {engine} {plugins} initialDocuments={[{ source: welcome }]}>
  <DocumentGate>
    {#snippet fallback()}
      <p class="loading">Loading…</p>
    {/snippet}
    <Controls last={pins[pins.length - 1] ?? null} onClear={() => (pins = [])} />
    <Stage class="stage">
      <RenderLayer />
      <!-- What's drawn here sits over the page and never turns with it, so it converts with
           pageToView, which includes the turn. -->
      {#snippet pageChrome(page)}
        <div
          class="surface"
          role="presentation"
          onpointerdown={(event) => (pressed = { x: event.clientX, y: event.clientY })}
          onclick={(event) => {
            const moved = Math.hypot(event.clientX - pressed.x, event.clientY - pressed.y);
            if (moved > 4) return;
            // A pointer event, to a point on the page.
            const point = page.toPagePoint(event.clientX, event.clientY);
            pins.push({ pageIndex: page.pageIndex, point });
          }}
        >
          {#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>
          {/if}
          {#each pins.filter((pin) => pin.pageIndex === page.pageIndex) as pin, index (index)}
            <!-- A point on the page, to pixels on it: at the last moment. -->
            {@const at = page.transform.pageToView(pin.point)}
            <div class="pin" style:left="{at.x}px" style:top="{at.y}px"></div>
          {/each}
        </div>
      {/snippet}
    </Stage>
  </DocumentGate>
</Viewer>
```

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

**`Controls.svelte`**

```svelte
<script lang="ts">
  import { useStage } from '@embedpdf/svelte/stage';
  import type { Pin } from './pin';

  let { last, onClear }: { last: Pin | null; onClear: () => void } = $props();

  const stage = useStage();
</script>

<div class="toolbar">
  <button type="button" class="button" onclick={() => stage.zoomOut()}>Zoom out</button>
  <button type="button" class="button" onclick={() => stage.zoomIn()}>Zoom in</button>
  <button type="button" class="button" onclick={() => stage.rotateViewBy(90)}>Rotate ⟳</button>
  <button type="button" class="button" onclick={onClear}>Clear pins</button>
  <output class="badge">
    {#if 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
    {/if}
  </output>
</div>
```

**`pin.ts`**

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

> **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.cloudpdf.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.
