EmbedPDF

Measurements

On a floor plan or a technical drawing, people want real sizes: how long this wall is, how big this room. The measurement plugin adds the tools for it, and keeps each page’s scale, so a line of 3 centimeters on the page reads as 3 meters in the room. Register withMeasurement() next to withInteraction() and withAnnotation(), and turn on a tool:

tool.ts
import { Component, inject } from '@angular/core';
import { EpdfInteraction } from '@embedpdf/angular/interaction';

@Component({
  selector: 'app-measure-button',
  template: `<button (click)="interaction.activateTool('distance')">Measure</button>`,
})
export class MeasureButton {
  protected readonly interaction = inject(EpdfInteraction);
}
ToolMeasuresHow
distanceThe length of a lineDrag from one point to the other
perimeterThe length of a path of several linesClick each point, double-click to end
areaThe area of a shapeClick each corner, double-click to end

Each measurement is an annotation with its value as a label, so it shows the same in Acrobat and other PDF apps, and you can select, move and restyle it like any annotation. The example measures the cover’s width at 1:100; pick a tool and measure something else:

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  ViewEncapsulation,
} from '@angular/core';
import { 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 { EpdfInteraction, withInteraction } from '@embedpdf/angular/interaction';
import { EpdfAnnotationLayer, withAnnotation } from '@embedpdf/angular/annotation';
import { EpdfMeasurement, withMeasurement } from '@embedpdf/angular/measurement';
import { localEngine } from '@embedpdf/engine';

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

const TOOLS = [
  { id: 'pointer', label: 'Select' },
  { id: 'distance', label: 'Distance' },
  { id: 'perimeter', label: 'Perimeter' },
  { id: 'area', label: 'Area' },
];

@Component({
  selector: 'demo-measure-toolbar',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toolbar">
      <div class="segmented" role="group" aria-label="Tool">
        @for (tool of tools; track tool.id) {
          <button
            type="button"
            [attr.aria-pressed]="interaction.activeToolId() === tool.id"
            (click)="interaction.activateTool(tool.id)"
          >
            {{ tool.label }}
          </button>
        }
      </div>
      <span class="spacer"></span>
      <output class="badge">Scale {{ ratio() }}</output>
    </div>
  `,
})
export class MeasureToolbar {
  protected readonly interaction = inject(EpdfInteraction);
  private readonly measurement = inject(EpdfMeasurement);
  protected readonly tools = TOOLS;
  private readonly scale = this.measurement.scaleOf(0); // the cover's scale
  protected readonly ratio = computed(() => {
    const measure = this.scale().measure;
    return measure?.subtype === 'rectilinear' ? measure.ratio : '…';
  });
  private started = false;

  constructor() {
    // On load: the cover at 1:100, its width measured along the top, and the distance tool on.
    effect(() => {
      if (!this.scale().ready || this.started) return;
      this.started = true;
      void this.measurement
        .setPreset(0, 'metric-100')
        .then(() =>
          this.measurement.createMeasurement({
            kind: 'distance',
            page: 0,
            points: [
              { x: 30, y: 28 },
              { x: 582, y: 28 },
            ],
          }),
        )
        .then(() => this.interaction.activateTool('distance'));
    });
  }
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfAnnotationLayer,
    MeasureToolbar,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
      withMeasurement(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './basic.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <demo-measure-toolbar />
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-annotation-layer />
        </ng-template>
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {}

Set the scale#

A page starts at the default scale, 1:1 in meters, unless the PDF already has one. Most plans say their scale, such as 1:100. Pick it from a list:

measurement.listPresets(); // 1:1, 1:10, 1:20, 1:50, 1:100, 1:200, 1/4 in = 1 ft, 1/8 in = 1 ft
await measurement.setPreset([page.ref], 'metric-100');
await measurement.setPreset('all', 'metric-100'); // every page

Measurements already on the page are recalculated for the new scale. Change the scale, the units or the precision, and the two measurements follow:

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  signal,
  untracked,
  viewChild,
  ViewEncapsulation,
} from '@angular/core';
import { 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 { withInteraction } from '@embedpdf/angular/interaction';
import { EpdfAnnotationLayer, withAnnotation } from '@embedpdf/angular/annotation';
import {
  EpdfMeasurement,
  withMeasurement,
  type AreaUnit,
  type LengthUnit,
} from '@embedpdf/angular/measurement';
import { localEngine } from '@embedpdf/engine';

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

// Precision is steps per unit: 100 shows two decimals.
const PRECISIONS = [1, 10, 100, 1000];

// The cover's empty lower half, where the measurements go.
const LOWER_HALF = { x: 40, y: 560, width: 532, height: 210 };

@Component({
  selector: 'demo-scale-toolbar',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toolbar">
      <label class="label">
        Scale
        <select
          #presetField
          class="field"
          [disabled]="measurement.busy()"
          (change)="setPreset(presetField.value)"
        >
          @for (choice of presets; track choice.id) {
            <option [value]="choice.id" [selected]="choice.id === preset()">
              {{ choice.label }}
            </option>
          }
        </select>
      </label>
      <label class="label">
        Length
        <select
          #unitField
          class="field"
          [disabled]="measurement.busy()"
          (change)="setUnit(unitField.value)"
        >
          @for (choice of units; track choice) {
            <option [value]="choice" [selected]="choice === unit()">{{ choice }}</option>
          }
        </select>
      </label>
      <label class="label">
        Area
        <select
          #areaUnitField
          class="field"
          [disabled]="measurement.busy()"
          (change)="setAreaUnit(areaUnitField.value)"
        >
          @for (choice of areaUnits; track choice) {
            <option [value]="choice" [selected]="choice === areaUnit()">{{ choice }}</option>
          }
        </select>
      </label>
      <label class="label">
        Steps
        <select
          #precisionField
          class="field"
          [disabled]="measurement.busy()"
          (change)="setPrecision(+precisionField.value)"
        >
          @for (choice of precisions; track choice) {
            <option [value]="choice" [selected]="choice === precision()">
              {{ choice === 1 ? 'Whole' : '1/' + choice }}
            </option>
          }
        </select>
      </label>
      <span class="spacer"></span>
      <output class="badge">{{ ratio() }}</output>
    </div>
  `,
})
export class ScaleToolbar {
  protected readonly measurement = inject(EpdfMeasurement);
  protected readonly presets = this.measurement.listPresets();
  protected readonly units = this.measurement.listUnits();
  protected readonly areaUnits = this.measurement.listAreaUnits();
  protected readonly precisions = PRECISIONS;
  protected readonly preset = signal('metric-100');
  protected readonly unit = signal<LengthUnit>('m');
  protected readonly areaUnit = signal<AreaUnit>('m2');
  protected readonly precision = signal(100);
  private readonly scale = this.measurement.scaleOf(0);
  protected readonly ratio = computed(() => {
    const measure = this.scale().measure;
    return measure?.subtype === 'rectilinear' ? measure.ratio : '…';
  });

  // Every change recalculates the measurements already on the page.
  protected setPreset(preset: string) {
    this.preset.set(preset);
    void this.measurement.setPreset(0, preset);
  }

  // A <select> gives a string: one of the units the plugin listed.
  protected setUnit(value: string) {
    const unit = value as LengthUnit;
    this.unit.set(unit);
    void this.measurement.setUnit(0, unit);
  }

  protected setAreaUnit(value: string) {
    const unit = value as AreaUnit;
    this.areaUnit.set(unit);
    void this.measurement.setAreaUnit(0, unit);
  }

  protected setPrecision(precision: number) {
    this.precision.set(precision);
    void this.measurement.setPrecision(0, precision);
  }
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfAnnotationLayer,
    ScaleToolbar,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
      withMeasurement(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './scale.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <demo-scale-toolbar />
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-annotation-layer />
        </ng-template>
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  private readonly measurement = inject(EpdfMeasurement);
  private readonly coverScale = this.measurement.scaleOf(0);
  private readonly stage = viewChild(EpdfStage);
  private started = false;

  constructor() {
    // On load: a distance and an area on the cover, at 1:100, scrolled into view.
    effect(() => {
      const stage = this.stage();
      if (!stage || !this.coverScale().ready || this.started) return;
      this.started = true;
      untracked(() => this.measureCover(stage));
    });
  }

  private measureCover(stage: EpdfStage) {
    void this.measurement.setPreset(0, 'metric-100').then(() =>
      Promise.all([
        this.measurement.createMeasurement({
          kind: 'distance',
          page: 0,
          points: [
            { x: 60, y: 740 },
            { x: 550, y: 740 },
          ],
        }),
        this.measurement.createMeasurement({
          kind: 'area',
          page: 0,
          points: [
            { x: 60, y: 580 },
            { x: 260, y: 580 },
            { x: 260, y: 700 },
            { x: 60, y: 700 },
          ],
        }),
      ]).then(() => stage.reveal(0, { rect: LOWER_HALF })),
    );
  }
}

Calibrate from a known length#

When the scale isn’t printed, measure something you know, such as a door that’s 90 centimeters wide:

measurement.startCalibration(); // the next line the user draws is the known length

The plugin asks your UI for the real length, through the measurement.calibrationRequest() signal:

calibration.ts
import { Component, inject } from '@angular/core';
import { EpdfMeasurement, type CalibrationRequest } from '@embedpdf/angular/measurement';

import { LengthPrompt } from './length-prompt';

@Component({
  selector: 'app-calibration-dialog',
  imports: [LengthPrompt],
  template: `
    @if (measurement.calibrationRequest(); as request) {
      <app-length-prompt
        (submitted)="apply(request, $event)"
        (cancelled)="measurement.dismissCalibration()"
      />
    }
  `,
})
export class CalibrationDialog {
  protected readonly measurement = inject(EpdfMeasurement);

  protected apply(request: CalibrationRequest, value: number) {
    void this.measurement.calibrate({ ...request, distance: { value, unit: 'cm' } });
  }
}

calibrate() sets the page’s scale from the two points and the length you give. Pass { applyTo: 'all' } to use the same scale on every page.

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  signal,
  untracked,
  ViewEncapsulation,
} from '@angular/core';
import { 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 { EpdfInteraction, withInteraction } from '@embedpdf/angular/interaction';
import { EpdfAnnotationLayer, withAnnotation } from '@embedpdf/angular/annotation';
import {
  EpdfMeasurement,
  withMeasurement,
  type CalibrationRequest,
  type LengthUnit,
} from '@embedpdf/angular/measurement';
import { localEngine } from '@embedpdf/engine';

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

@Component({
  selector: 'demo-calibration',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <!-- The plugin asks for the real length of the line you drew. -->
    @if (measurement.calibrationRequest(); as request) {
      <form class="toolbar" (submit)="$event.preventDefault(); calibrate(request)">
        <span class="readout">That line is</span>
        <input
          #lengthField
          class="field length"
          type="number"
          min="0"
          step="any"
          aria-label="Its real length"
          [value]="length()"
          (input)="length.set(lengthField.value)"
        />
        <select #unitField class="field" aria-label="Unit" (change)="setUnit(unitField.value)">
          @for (choice of units; track choice) {
            <option [value]="choice" [selected]="choice === unit()">{{ choice }}</option>
          }
        </select>
        <button type="submit" class="button" [disabled]="measurement.busy() || !(value() > 0)">
          Set the scale
        </button>
        <button type="button" class="button" (click)="measurement.dismissCalibration()">
          Cancel
        </button>
      </form>
    } @else {
      <div class="toolbar">
        <button
          type="button"
          class="button"
          [disabled]="!measurement.canCalibrate()"
          (click)="measurement.startCalibration()"
        >
          Calibrate
        </button>
        <output class="readout">
          Drag along something you know the length of, then measure with the new scale
        </output>
        <span class="spacer"></span>
        <output class="badge">{{ ratio() }}</output>
      </div>
    }
  `,
})
export class Calibration {
  protected readonly measurement = inject(EpdfMeasurement);
  private readonly interaction = inject(EpdfInteraction);
  protected readonly units = this.measurement.listUnits();
  protected readonly length = signal('20');
  protected readonly value = computed(() => Number(this.length()));
  protected readonly unit = signal<LengthUnit>('cm');
  private readonly scale = this.measurement.scaleOf(0);
  protected readonly ratio = computed(() => {
    const measure = this.scale().measure;
    return measure?.subtype === 'rectilinear' ? measure.ratio : '…';
  });
  private started = false;

  constructor() {
    // On load: calibrating, so the next line you drag is the known length.
    effect(() => {
      if (!this.scale().ready || this.started) return;
      this.started = true;
      untracked(() => {
        if (this.measurement.canCalibrate()) this.measurement.startCalibration();
      });
    });
  }

  // A <select> gives a string: one of the units the plugin listed.
  protected setUnit(value: string) {
    this.unit.set(value as LengthUnit);
  }

  protected calibrate(request: CalibrationRequest) {
    void this.measurement
      .calibrate({ ...request, distance: { value: this.value(), unit: this.unit() } })
      .then(() => this.interaction.activateTool('distance'));
  }
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfAnnotationLayer,
    Calibration,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
      withMeasurement(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './calibrate.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <demo-calibration />
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-annotation-layer />
        </ng-template>
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {}

Units and precision#

await measurement.setUnit([page.ref], 'ft');
await measurement.setAreaUnit([page.ref], 'ft2'); // or 'm2', 'ha', 'acre'
await measurement.setPrecision([page.ref], 100); // two decimals

listUnits() and listAreaUnits() give you the choices for a menu. Precision is 1, 10, 100 and so on: the number of steps per unit.

Read a measurement#

readout.ts
import { Component, computed, inject, input } from '@angular/core';
import type { AnnotationRef } from '@embedpdf/angular/annotation';
import { EpdfMeasurement } from '@embedpdf/angular/measurement';

@Component({
  selector: 'app-measurement-label',
  template: `<span>{{ label() }}</span>`,
})
export class MeasurementLabel {
  readonly annotation = input.required<AnnotationRef>();

  private readonly readout = inject(EpdfMeasurement).readoutOf(this.annotation);
  // { kind: 'distance', value: 3.42, label: '3.42 m' }

  protected readonly label = computed(() => {
    const readout = this.readout();
    return 'unavailable' in readout ? '—' : readout.label;
  });
}

label is the text shown on the page, in the page’s units. Without a scale to compute from, a readout says why: { unavailable: 'no-scale' }.

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  input,
  untracked,
  viewChild,
  ViewEncapsulation,
} from '@angular/core';
import { 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 { withInteraction } from '@embedpdf/angular/interaction';
import {
  annotationKey,
  EpdfAnnotation,
  EpdfAnnotationLayer,
  withAnnotation,
  type Annotation,
} from '@embedpdf/angular/annotation';
import { EpdfMeasurement, withMeasurement } from '@embedpdf/angular/measurement';
import { localEngine } from '@embedpdf/engine';

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

// The cover's empty lower half, where the measurements go.
const LOWER_HALF = { x: 40, y: 560, width: 532, height: 210 };

// A measurement is a line, polyline or polygon with a dimension intent.
const isMeasurement = (annotation: Annotation) =>
  'intent' in annotation && !!annotation.intent?.endsWith('-dimension');

@Component({
  selector: 'li[demoReadout]',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <button type="button" class="row" (click)="select()">
      <span class="kind">{{ kind() }}</span>
      <span class="value">{{ value() }}</span>
    </button>
  `,
})
export class Readout {
  readonly annotation = input.required<Annotation>();

  private readonly selection = inject(EpdfAnnotation).selection;
  private readonly readout = inject(EpdfMeasurement).readoutOf(() => this.annotation().ref);
  protected readonly kind = computed(() => {
    const readout = this.readout();
    return 'unavailable' in readout ? 'No scale' : readout.kind;
  });
  protected readonly value = computed(() => {
    const readout = this.readout();
    return 'unavailable' in readout ? '—' : readout.label;
  });

  protected select() {
    this.selection.set([this.annotation().ref]);
  }
}

@Component({
  selector: 'ul[demoReadouts]',
  imports: [Readout],
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { class: 'readouts', 'aria-label': 'Measurements on the cover' },
  template: `
    @for (annotation of measurements(); track key(annotation.ref)) {
      <li demoReadout [annotation]="annotation"></li>
    }
  `,
})
export class Readouts {
  private readonly onCover = inject(EpdfAnnotation).watch({ pages: [0] });
  protected readonly measurements = computed(() => this.onCover().filter(isMeasurement));
  protected readonly key = annotationKey;
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfAnnotationLayer,
    Readouts,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
      withMeasurement(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './readout.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <div class="viewer">
        <epdf-stage class="stage">
          <ng-template epdfPage>
            <epdf-render-layer />
            <epdf-annotation-layer />
          </ng-template>
        </epdf-stage>
        <ul demoReadouts></ul>
      </div>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  private readonly measurement = inject(EpdfMeasurement);
  private readonly coverScale = this.measurement.scaleOf(0);
  private readonly stage = viewChild(EpdfStage);
  private started = false;

  constructor() {
    // On load: the cover at 1:50, and one measurement of each kind on it, scrolled into view.
    effect(() => {
      const stage = this.stage();
      if (!stage || !this.coverScale().ready || this.started) return;
      this.started = true;
      untracked(() => this.measureCover(stage));
    });
  }

  private measureCover(stage: EpdfStage) {
    void this.measurement.setPreset(0, 'metric-50').then(() =>
      Promise.all([
        this.measurement.createMeasurement({
          kind: 'distance',
          page: 0,
          points: [
            { x: 60, y: 740 },
            { x: 550, y: 740 },
          ],
        }),
        this.measurement.createMeasurement({
          kind: 'perimeter',
          page: 0,
          points: [
            { x: 60, y: 580 },
            { x: 160, y: 640 },
            { x: 260, y: 590 },
          ],
        }),
        this.measurement.createMeasurement({
          kind: 'area',
          page: 0,
          points: [
            { x: 80, y: 650 },
            { x: 250, y: 650 },
            { x: 250, y: 710 },
            { x: 80, y: 710 },
          ],
        }),
      ]).then(() => stage.reveal(0, { rect: LOWER_HALF })),
    );
  }
}

To measure without drawing anything, for a tooltip that follows the pointer:

measurement.measureDistance(page.ref, from, to); // { value, label }
measurement.measureArea(page.ref, corners);

measurement.scaleOf(page.ref) gives you a page’s scale, for a scale bar or a “1:100” badge.

Measure from code#

const { annotation } = await measurement.createMeasurement({
  kind: 'area',
  page: page.ref,
  points: [
    { x: 100, y: 100 },
    { x: 300, y: 100 },
    { x: 300, y: 250 },
  ],
});

It makes the same annotation the tool would, with the page’s scale and the tool’s style.

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  input,
  ViewEncapsulation,
} from '@angular/core';
import { 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 { withInteraction } from '@embedpdf/angular/interaction';
import { EpdfAnnotation, EpdfAnnotationLayer, withAnnotation } from '@embedpdf/angular/annotation';
import {
  EpdfMeasurement,
  withMeasurement,
  type MeasurementKind,
} from '@embedpdf/angular/measurement';
import { localEngine } from '@embedpdf/engine';

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

// Points on the cover's empty lower half, in page coordinates: two for a
// distance, the corners otherwise.
const SHAPES: Record<MeasurementKind, { x: number; y: number }[]> = {
  distance: [
    { x: 60, y: 750 },
    { x: 550, y: 750 },
  ],
  perimeter: [
    { x: 70, y: 600 },
    { x: 150, y: 650 },
    { x: 250, y: 600 },
  ],
  area: [
    { x: 70, y: 670 },
    { x: 250, y: 670 },
    { x: 250, y: 730 },
    { x: 70, y: 730 },
  ],
};

// The cover's empty lower half, where the measurements go.
const LOWER_HALF = { x: 40, y: 560, width: 532, height: 210 };

/** Next to `<epdf-stage #stage="epdfStage">`: `<demo-measure-buttons [stage]="stage" />`. */
@Component({
  selector: 'demo-measure-buttons',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toolbar">
      <button
        type="button"
        class="button"
        [disabled]="!scale().ready"
        (click)="measure('distance')"
      >
        Distance
      </button>
      <button
        type="button"
        class="button"
        [disabled]="!scale().ready"
        (click)="measure('perimeter')"
      >
        Perimeter
      </button>
      <button type="button" class="button" [disabled]="!scale().ready" (click)="measure('area')">
        Area
      </button>
      <span class="spacer"></span>
      <output class="readout">
        {{ count() }} {{ count() === 1 ? 'measurement' : 'measurements' }} on the cover
      </output>
    </div>
  `,
})
export class MeasureButtons {
  readonly stage = input.required<EpdfStage>();

  private readonly measurement = inject(EpdfMeasurement);
  protected readonly scale = this.measurement.scaleOf(0);
  private readonly onCover = inject(EpdfAnnotation).watch({ pages: [0] });
  protected readonly count = computed(
    () =>
      this.onCover().filter(
        (annotation) => 'intent' in annotation && !!annotation.intent?.endsWith('-dimension'),
      ).length,
  );
  private started = false;

  constructor() {
    // On load: an area, scrolled into view.
    effect(() => {
      if (!this.scale().ready || this.started) return;
      this.started = true;
      const stage = this.stage();
      void this.measurement
        .createMeasurement({ kind: 'area', page: 0, points: SHAPES.area })
        .then(() => stage.reveal(0, { rect: LOWER_HALF }));
    });
  }

  // The same measurement the tool makes, with the page's scale and the tool's style.
  protected measure(kind: MeasurementKind) {
    void this.measurement.createMeasurement({ kind, page: 0, points: SHAPES[kind] });
  }
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfAnnotationLayer,
    MeasureButtons,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
      withMeasurement(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './from-code.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <demo-measure-buttons [stage]="stage" />
      <epdf-stage #stage="epdfStage" class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-annotation-layer />
        </ng-template>
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {}

Methods#

Everything inject(EpdfMeasurement) gives you.

MethodWhat it does
listPresets()The scales to pick from, each with an id and a label such as 1:100. Set the scale
setPreset(pages, presetId)Gives pages a scale from the list. Set the scale
setScale(pages, scale)Gives pages a scale of your own, in the form the engine reads and writes
clearScale(pages)Puts pages back to the default scale
startCalibration()Makes the next line the user draws the known length, then asks your UI for its real length. Calibrate from a known length
calibrate(request, { applyTo? })Sets the scale from two points and the real length between them, on their page or on the pages in applyTo
dismissCalibration()Cancels calibrating and drops the request
setUnit(pages, unit), setAreaUnit(pages, unit)Changes the unit lengths, or areas, are shown in. Units and precision
setPrecision(pages, precision)Changes how finely a label rounds: 100 shows two decimals
listUnits(), listAreaUnits()The length and area units to pick from, for a menu
getPageScale(page)A page’s scale, whether it came from the file or is the default, and whether it has loaded. In a component, use measurement.scaleOf(page).
ensureLoaded(page)Loads a page’s scale. Resolves at once when it’s already known.
getReadout(ref)A measurement’s { kind, value, label }, or { unavailable } with why there’s none. In a component, use measurement.readoutOf(ref). Read a measurement
measureDistance(page, from, to), measureArea(page, corners)Measures in the page’s scale without drawing anything: { value, label }
createMeasurement({ kind, page, points })Makes the same measurement the tool would. Resolves { annotation }. Measure from code
canCalibrate(), canMeasure(page)Whether setting a scale, and measuring on a page, are allowed. Permissions
getSettings(), updateSettings(changes), resetSettings()Reads the settings, changes them while the app runs, or goes back to what you registered. In a component, use inject(EpdfMeasurement).settings(). Settings

pages is a page, a list of pages, or 'all'. A scale change recalculates the measurements already on those pages and resolves one report per page; pass { recalculate: false } to keep their values. Every method that returns a promise also takes a signal in its options, to cancel it.

State#

Every value is a signal on inject(EpdfMeasurement): read it in a template or a computed(), and your view updates when it changes. The method in the last column reads the same value once.

FieldWhat it isRead once
busy()Whether a scale change is still runningisBusy()
calibrationRequest()The known length the user drew, { page, from, to }, waiting for its real length, or nullgetCalibrationRequest()
lastReports()The reports of the last scale change, one per pagelistLastReports()

A page’s scale and a measurement’s readout come from measurement.scaleOf(page) and measurement.readoutOf(ref).

Settings#

Set them when you register the plugin, and change them while the app runs with measurement.updateSettings({ … }); measurement.resetSettings() goes back to what you registered, and inject(EpdfMeasurement).settings() gives you the current settings.

SettingWhat it doesDefault
defaultScaleThe scale of a page that has none: 'metric', 'imperial', or a scale of your own'metric' (1:1 in meters)
presetsThe scales listPresets() offersthe eight above

A measurement’s look, such as its line, arrows and label position, is its tool’s defaults; see Tools & defaults.

Events#

events.ts
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { EpdfMeasurement } from '@embedpdf/angular/measurement';

@Component({
  selector: 'app-scale-notice',
  template: `<p aria-live="polite">{{ message() }}</p>`,
})
export class ScaleNotice {
  private readonly measurement = inject(EpdfMeasurement);

  protected readonly message = toSignal(
    this.measurement.scaleChanged$.pipe(map(() => 'The scale changed')),
    { initialValue: '' },
  );
}
EventFires whenCarries
scaleChanged$A page’s scale changedpage, report
calibrationRequested$The user drew a known length, and your UI should ask for itrequest
calibrationCompleted$A page was calibratedpage
calibrationDismissed$Calibrating was cancelled—
settingsChanged$The settings changedsettings, changed

report lists the measurements recalculated for the new scale, and any that couldn’t be.

Permissions#

PermissionWhat it allowsCheck
doc.annotate.modifySetting a page’s scale and calibratingmeasurement.canCalibrate()
annotations:createMeasuringmeasurement.canMeasure(page.ref)

canMeasure is also false while a page’s scale is still loading.

Good to know#

The scale is in the PDF#

A page’s scale is saved in the file, in the form Acrobat and Bluebeam use, so their measuring tools read the same scale, and yours read theirs.

Scales from map software#

A few PDFs, such as maps, carry a geographic scale. It’s kept as it is, and measurements on such a page say { unavailable: 'foreign-measure' }. See Measurements for how scales and labels are stored.

Was this page helpful?

Your feedback goes directly to the documentation team.