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:
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 { cloudEngine } from '@cloudpdf/engine';
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfSearchLayer],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), 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 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 template or inside
<epdf-page-view>, 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.
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.
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 { cloudEngine } from '@cloudpdf/engine';
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
// 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: () => cloudEngine(), 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… | 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.