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:
<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:
Loading live preview…
<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>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 hitPixels, 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, withtoPixels. 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.
<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.
Loading live preview…
<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>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… | 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.