Repository: Hopding/pdf-lib
Pull request #348 (2020-02-07 23:32:44)

Implement embedded PDFs
Hello dear PDF-creating friends 👋 

This is my attempt to allow embedding of PDF pages in other pages.
The good news is that it does not crash, the bad news is that it does
not work :) This is my first time playing with PDF internals, this project and typescript. I am pretty confident in that there are many things to improve :)

It is an attempt to solve https://github.com/Hopding/pdf-lib/issues/184 (@4rlekin if you read this, feel free to help, or, as you prefer, continue thinking about an alternative implementation).

Architecture-wise it was my idea was to follow the implementation of
how we embed PNGs:
Have an `embedPdfDocument` method  on PdfDocument which uses a
`PdfPageEmbedder`.
On PdfPage the returned object can be inserted using
`drawEmbeddedPdfPage`.

The PDF page to be embedded is wrapped into an XObject (`8.10 FormXObjects` from the PDF spec). This involved some heavy wrestling against contexts and copied pages.

I adapted the scratchpad to illustrate how this could be used. Sadly, it
just renders an empty, white page. I guess I need help debugging this.

Things to improve:

* [x] Is the general idea sound or total nonsense? @Hopding 
* [x] I violated the `PDFImage` class by adding an `EmbeddedPdfPage` option to
  the existing image types. To me it sounds like a `PDFPage` is not an
image and should either not be supported by this class or we should
rename/rework this class. Normally the pdf embedder would not need `width` and `height`, but I needed to implement it because `PDFImage` wants it.
* [x] XObjects seem to support transformation matrices and transparency. This
  is great, but the normal PNG code scales/rotates their content
differently. We need to decide which is better.
* [x] My attempts to support better placement (rotation, skew, clipping, ...) is mostly commented out. I wanted to be able to insert *something* first, then make it better.
* [x] Add tests
* [x] Reset the scratchpad to have the original code before merging this to
  master
* [x] Update changelog and eventually other metadata

I would appreciate reviews (to improve my TypeScript as well as the architecture, usage) and would love help in finding out why this does not work yet.

Hopding: Hello @tessi!

Great work on this PR! You've done some impressive work here.

I was able to get this working after making a few changes to your `PDFPageEmbedder.embedIntoContext` method. I've copied the updated version of this method below:

```ts
import PDFDocument from 'src/api/PDFDocument';
import PDFPage from 'src/api/PDFPage';
import PDFRawStream from 'src/core/objects/PDFRawStream';
import PDFRef from 'src/core/objects/PDFRef';
import PDFStream from 'src/core/objects/PDFStream';
import PDFContext from 'src/core/PDFContext';
import PDFObjectCopier from 'src/core/PDFObjectCopier';
import { decodePDFRawStream } from 'src/core/streams/decode';
import PDFContentStream from 'src/core/structures/PDFContentStream';
import { mergeIntoTypedArray } from 'src/utils';

// ...

async embedIntoContext(context: PDFContext, ref?: PDFRef): Promise<PDFRef> {
  const { left, bottom, right, top } = this.boundingBox;
  const bbox = [left, bottom, right, top];

  const copier = PDFObjectCopier.for(this.page.doc.context, context);
  const copiedPage = copier.copy(this.page.node);
  const { Contents, Resources } = copiedPage.normalizedEntries();

  if (!Contents) throw new Error('Missing page.Contents!');

  const decodedContents: Uint8Array[] = new Array(Contents.size());
  for (let idx = 0, len = Contents.size(); idx < len; idx++) {
    const stream = Contents.lookup(idx, PDFStream);
    if (stream instanceof PDFRawStream) {
      decodedContents[idx] = decodePDFRawStream(stream).decode();
    } else if (stream instanceof PDFContentStream) {
      decodedContents[idx] = stream.getUnencodedContents();
    } else {
      throw new Error(`Unrecognized stream type: ${stream.constructor.name}`);
    }
  }

  // TODO: Should probably insert a newline in between each content array when
  // merging them, just to be safe
  const mergedContents = mergeIntoTypedArray(...decodedContents);

  const xObject = context.stream(mergedContents, {
    Type: 'XObject',
    Subtype: 'Form',
    FormType: 1,
    BBox: bbox,
    Matrix: this.transformationMatrix,
    // do we want to handle transparency?
    // https://github.com/galkahana/PDF-Writer/blob/edb7478dce955dfab61bbc2219a1ca7b85bed924/PDFWriter/DocumentContext.cpp#L992
    Resources,
  });

  if (ref) {
    context.assign(ref, xObject);
    return ref;
  } else {
    return context.register(xObject);
  }
}
```

Basically, the reason your code wasn't working is that you were copying an array of stream references as the content of the XObject stream. You can confirm that this is the case by saving your document without object streams: `pdfDoc.save({ useObjectStreams: false })` (note that this format is generally very useful for debugging documents during development as it makes the output PDF human readable). If you open the resulting document in a text editor and search for the definition of indirect object `5 0`, you'll find the following:

```
5 0 obj
<<
/Type /XObject
/Subtype /Form
/FormType 1
/BBox [ 0 0 595.28 841.89 ]
/Matrix [ 1 0 0 1 0 0 ]
/Resources <<
/Font <<
/Helvetica-7128 7 0 R
>>
/XObject <<
>>
>>
/Length 22
>>
stream
[ 9 0 R 8 0 R 10 0 R ]
endstream
endobj
```

Note the stream's content (the stuff between the `stream` and `endstream` keywords). It is an array of object references. This is because you grabbed the value of the source page's `Contents` entry and stuck it directly into the stream. Unfortunately, this won't work. The reason is that `Contents` is an array of stream references. But you want the XObject stream's content to be a sequence of content stream operators.

The solution I implemented in my refactored version of the  `PDFPageEmbedder.embedIntoContext` method is to lookup each stream and extract its contents. Once this is done, we can simply concatenate each content stream into a single stream of operators. And this single stream can be used as the content of the XObject stream. 

After making these changes, indirect object `5 0` looks like the following:

```
5 0 obj
<<
/Type /XObject
/Subtype /Form
/FormType 1
/BBox [ 0 0 595.28 841.89 ]
/Matrix [ 1 0 0 1 0 0 ]
/Resources <<
/Font <<
/Helvetica-7128 7 0 R
>>
/XObject <<
>>
>>
/Length 110
>>
stream
q
BT
0 0 0 rg
/Helvetica-7128 24 Tf
24 TL
1 0 0 1 5 25 Tm
<7064662D6C696220697320617765736F6D6521> Tj
T*
ET
Q
```

And the resulting document contains the copied page!

>

<kbd>
<img height="500px" src="https://user-images.githubusercontent.com/10351828/74094527-5b99f900-4aa8-11ea-9cbb-0b9df0da854e.png">
</kbd>

Hopding: @tessi I should also mention that your implementation concept is perfectly sound. In fact, it's exactly what I was thinking of when I wrote up https://github.com/Hopding/pdf-lib/issues/184#issuecomment-569120029.

As far as the `PDFImage` goes, perhaps we should create a new class to represent embedded PDF objects (it could potentially represent things besides embedded pages too)? Maybe something like `PDFGraphic`? `PDFImage` could then be made to subclass `PDFGraphic`... But I haven't fully thought through this, so it might have some holes. It just came to my mind as a potential solution. Thoughts?
tessi: @hopding thanks a torn for your feedback and even more for your refactored and working solution! You gave me a fish and helped me learn some fishing at the same time :)

Regarding PDFImage: I agree that extracting the embedded pdf page functionality into its own class is the way to go. I would say I'd like to decide later if and how that class connects to PDFImage.

My next step is to implement clipping/transformations on the embedded pdf. I'm not sure yet where exactly I should place the transformations: in the XObjects matrix or in the drawing code in PDFPage#drawEmveddedPdfPage. I will probably try both and stick with what's best.

Thanks again for your welcoming and extremely helpful comments. I'm hyped to continue working on this PR. 
tessi: Thanks to your help @Hopding. PDF embedding works, even with slightly more complex source PDFs which include images \o/ Also scaling, rotation and skewing works:

<img width="673" alt="Screenshot 2020-02-09 at 14 02 02" src="https://user-images.githubusercontent.com/206108/74108387-252cae80-4b7a-11ea-8a54-0ca4d33445e1.png">

For the implementation I decided to stick to the way the PNG/JPEG variants are implemented as I think its a more consistent user experience. Also, using transformation matrices on the XObject itself would probably (at least as far as I understand the spec) apply to all uses of the embedded PDF.

With my current solution it is still possible to apply a custom transformation matrix to the XObject (so we don't keep users from doing this). However, transforming during the `draw` step is much easier to use and should be the way we advertise in the documentation.

I implemented the TODO you left (insert newlines in between decoded streams) - it's probably not the most efficient code but seems to do the job.

In addition, I changed the draw options to use its own options object: `PDFPageDrawEmbeddedPdfPageOptions`. I renamed what used to be `width`/`height` in the image options to `xScale` and `yScale` since the underlying `scale` operation seems to scale relative (e.g. `0.5` for half the size) and not in absolute values (e.g. `200` to get 200 units).

I have not checked yet, but think that it's similar for PNGs/JPGs. Maybe we want to rename their `width` and `height` options too. At least to me `width` and `height` sounds like the user should give absolute numbers, but this may be because I'm not native English. What do you think?

Next, I will clean up the code (and rescue poor `PDFImage` from being misused) to make it properly reviewable. Then, I will add tests.
tessi: > I have not checked yet, but think that it's similar for PNGs/JPGs. Maybe we want to rename their width and height options too. At least to me width and height sounds like the user should give absolute numbers, but this may be because I'm not native English. What do you think?

I just re-read the docs 🙈 `width`/`height` does in fact get relative sizes, but the code

```ts
   const jpgImage = await pdfDoc.embedJpg(jpgImageBytes)
   const jpgDims = jpgImage.scale(0.5)
   const page = pdfDoc.addPage()
   page.drawImage(jpgImage, {
     x: 25,
     y: 25,
     width: jpgDims.width,
     height: jpgDims.height,
     rotate: degrees(30)
   })
```

uses `width` and `height` from the `jpgDims` object. Still a little weird to me (why can't we directly give `0.5` into the `imageOptions`?). But it's not as weird as I first thought :)
tessi: Another Update:

* The `PDFImage` class is restored to sanity again: Embeddable page stuff now lives in its own class `EmbeddablePDFPage`.
* I slightly refactored `PdfDocument.flush()` to be more DRY and added another interface `Embeddble` to `PDFImage`, `PDFFont`, and the new `EmbeddablePDFPage` to make sure they all implement `embed()`.
* Added documentation to all the things
* updated the scratchpad to showcase a more complex example of the new API - it is possible to embed a part of a PDF page, and rotate/place it however you want (note: I will restore the original scratchpad before merging)

|the soure page|only a part of the source page embedded and rotated|
|-|-|
|<img width="617" alt="Screenshot 2020-02-09 at 23 42 54" src="https://user-images.githubusercontent.com/206108/74111618-629f3500-4b96-11ea-8d81-5cd969d99d6f.png">|<img width="619" alt="Screenshot 2020-02-09 at 23 42 19" src="https://user-images.githubusercontent.com/206108/74111620-692dac80-4b96-11ea-812a-f4b1191e1616.png">|

Feature-wise I would say this PR is complete. We only miss some tests now. What do you think @Hopding ?
tessi: I added tests for the parts that were tested before (-> the embedder). The relevant other parts (page, document) had no existing tests for jpges/pngs. So I thought I may get away by also not testing them?!

Next steps:

* [ ] get a review by @Hopding  and apply discussed changes
* [ ] reset the scratchpad
* [ ] prepare metadata. I found no CHANGELOG.md, is there other parts where I should document the changes?
Hopding: @tessi Exciting progress! I'll review this as soon as I get a chance. I don't keep a changelog file. Instead I record the changes included in each release with GitHub's releases feature: https://github.com/Hopding/pdf-lib/releases. If you'd like to provide anything specific, just make a note in this thread and I'll include it in the release description.

One thing that we'll definitely need before merging this is some integration tests. If you're not familiar with them, here's a [quick overview](https://github.com/Hopding/pdf-lib/blob/master/CONTRIBUTING.md#running-the-integration-tests) explaining how to run them. Please let me know if you have any questions about how to work with them. I think it would be good to update test1 to embed a document or two to test embedding pages in new documents. And then we'll want to add or update another one to test embedding pages in existing documents.
tessi: Thanks @Hopding :)

Adding integration tests, indeed, is crucial :) I added some pdf-embedding to `test1` and `test2`. The pages now look like this:

### `test1.ts`

Here, I added a 4th page which embeds page 3 (rotated by 90 degrees and stretched to cover most of the page).
In addition, `normal.pdf` is overlayed on top.

![Screenshot 2020-02-13 at 09 23 20](https://user-images.githubusercontent.com/206108/74418401-986b4480-4e48-11ea-9fa8-2825edbaa70f.png)

### `test2.ts`

Here, we add part of page 74 of the PDF spec (a figure about PDF encryption) to the bottom-left of every page.

![Screenshot 2020-02-13 at 09 52 42](https://user-images.githubusercontent.com/206108/74418414-9ef9bc00-4e48-11ea-86b7-6d8e6913c793.png)

tessi: > If you'd like to provide anything specific, just make a note in this thread and I'll include it in the release description.

I think I'm fine. Just wanted to take as much work off you for preparing a release (a fast release is in my interest, since I need the built package to use it in my project :))
Hopding: @tessi Just wanted to check and see if you've had a chance to read over and consider all my comments? 
tessi: Hey @Hopding I've seen your comments and as always they seem reasonable, well argued and nice :) I started working on them, but then came vacations and some family time. But I'm on it 🤞 (some of the easier changes are already commited and pushed)
Hopding: Sounds good! Looking forward to merging this 👍 
ihmc3jn09hk: When will this PR be merged ?
This PR will help on a work around for #363, #202. Say svg-to-pdfkit -> pdfkit-to-pdf_lib -> embedPDF.
tessi: @ihmc3jn09hk I guess this will be merged when I find enough free time to work on this (which I am motivated to do, but without any promises) and Hopding finds enough free time to review again.

I guess we cannot promise a specific time
tessi: To my own surprise, I found some extended time to work on this PR today. @Hopding do you mind having another look? Changes are:

* implemented width/height/scale for the embedded PDF pages
* applied the renames you wished for
* made `embedDocument` multi-page aware
* fixed typos and copy&paste error you outlined
* applied the code improvements you suggested
* adapted the integration tests to use both, `xScale`/`yScale` and `width`/`height` when drawing pages
* extended specs for `PDFPageEmbedder` to test that it uses not only the page dimensions, but those from the given boundingBox

Patch:
diff --git a/rollup.config.js b/rollup.config.js
index addd965..ae9b699 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -5,15 +5,16 @@ import { terser } from 'rollup-plugin-terser';
 
 const { MINIFY } = process.env;
 
-const WarningBlacklist = [
+const IgnoredWarnings = [
   'Circular dependency: es/api/PDFDocument.js -> es/api/PDFFont.js -> es/api/PDFDocument.js',
   'Circular dependency: es/api/PDFDocument.js -> es/api/PDFImage.js -> es/api/PDFDocument.js',
   'Circular dependency: es/api/PDFDocument.js -> es/api/PDFPage.js -> es/api/PDFDocument.js',
+  'Circular dependency: es/api/PDFDocument.js -> es/api/EmbeddedPDFPage.js -> es/api/PDFDocument.js',
 ];
 
 // Silence circular dependency warnings we don't care about
 const onwarn = (warning, warn) => {
-  if (WarningBlacklist.includes(warning.message)) return;
+  if (IgnoredWarnings.includes(warning.message)) return;
   warn(warning);
 };
 
diff --git a/src/api/Embeddable.ts b/src/api/Embeddable.ts
new file mode 100644
index 0000000..968211a
--- /dev/null
+++ b/src/api/Embeddable.ts
@@ -0,0 +1,6 @@
+/**
+ * A PDF entity, like images or fonts, which needs to be embedded into the document before save.
+ */
+export default interface Embeddable {
+  embed: () => Promise<void>;
+}
diff --git a/src/api/EmbeddedPDFPage.ts b/src/api/EmbeddedPDFPage.ts
new file mode 100644
index 0000000..c7bcefe
--- /dev/null
+++ b/src/api/EmbeddedPDFPage.ts
@@ -0,0 +1,104 @@
+import Embeddable from 'src/api/Embeddable';
+import PDFDocument from 'src/api/PDFDocument';
+import { PDFPageEmbedder, PDFRef } from 'src/core';
+import { assertIs } from 'src/utils';
+
+/**
+ * Represents a PDF page that has been embedded in a [[PDFDocument]].
+ */
+export default class EmbeddedPDFPage implements Embeddable {
+  /**
+   * > **NOTE:** You probably don't want to call this method directly. Instead,
+   * > consider using the [[PDFDocument.embedPdfDocument]] and
+   * > [[PDFDocument.embedPage]] methods, which will create instances of
+   * > [[EmbeddedPDFPage]] for you.
+   *
+   * Create an instance of [[EmbeddedPDFPage]] from an existing ref and embedder
+   *
+   * @param ref The unique reference for this embedded page.
+   * @param doc The document to which the embedded page will belong.
+   * @param embedder The embedder that will be used to embed the page.
+   */
+  static of = (ref: PDFRef, doc: PDFDocument, embedder: PDFPageEmbedder) =>
+    new EmbeddedPDFPage(ref, doc, embedder);
+
+  /** The unique reference assigned to this embedded page within the document. */
+  readonly ref: PDFRef;
+
+  /** The document to which this embedded page belongs. */
+  readonly doc: PDFDocument;
+
+  /** The width of this page in pixels. */
+  readonly width: number;
+
+  /** The height of this page in pixels. */
+  readonly height: number;
+
+  private alreadyEmbedded = false;
+  private readonly embedder: PDFPageEmbedder;
+
+  private constructor(
+    ref: PDFRef,
+    doc: PDFDocument,
+    embedder: PDFPageEmbedder,
+  ) {
+    assertIs(ref, 'ref', [[PDFRef, 'PDFRef']]);
+    assertIs(doc, 'doc', [[PDFDocument, 'PDFDocument']]);
+    assertIs(embedder, 'embedder', [[PDFPageEmbedder, 'PDFPageEmbedder']]);
+
+    this.ref = ref;
+    this.doc = doc;
+    this.width = embedder.width();
+    this.height = embedder.height();
+
+    this.embedder = embedder;
+  }
+
+  /**
+   * Compute the width and height of this page after being scaled by the
+   * given `factor`. For example:
+   * ```js
+   * embeddedPage.width  // => 500
+   * embeddedPage.height // => 250
+   *
+   * const scaled = embeddedPage.scale(0.5)
+   * scaled.width  // => 250
+   * scaled.height // => 125
+   * ```
+   * This operation is often useful before drawing a page with
+   * [[PDFPage.drawPage]] to compute the `width` and `height` options.
+   * @param factor The factor by which this page should be scaled.
+   * @returns The width and height of the page after being scaled.
+   */
+  scale(factor: number) {
+    assertIs(factor, 'factor', ['number']);
+    return { width: this.width * factor, height: this.height * factor };
+  }
+
+  /**
+   * Get the width and height of this page. For example:
+   * ```js
+   * const { width, height } = embeddedPage.size()
+   * ```
+   * @returns The width and height of the page.
+   */
+  size() {
+    return this.scale(1);
+  }
+
+  /**
+   * > **NOTE:** You probably don't need to call this method directly. The
+   * > [[PDFDocument.save]] and [[PDFDocument.saveAsBase64]] methods will
+   * > automatically ensure all embeddable pages get embedded.
+   *
+   * Embed this embeddable page in its document.
+   *
+   * @returns Resolves when the embedding is complete.
+   */
+  async embed(): Promise<void> {
+    if (!this.alreadyEmbedded) {
+      await this.embedder.embedIntoContext(this.doc.context, this.ref);
+      this.alreadyEmbedded = true;
+    }
+  }
+}
diff --git a/src/api/PDFDocument.ts b/src/api/PDFDocument.ts
index f4c2ede..b882f47 100644
--- a/src/api/PDFDocument.ts
+++ b/src/api/PDFDocument.ts
@@ -1,3 +1,5 @@
+import Embeddable from 'src/api/Embeddable';
+import EmbeddedPDFPage from 'src/api/EmbeddedPDFPage';
 import {
   EncryptedPDFError,
   FontkitNotRegisteredError,
@@ -10,6 +12,7 @@ import PDFPage from 'src/api/PDFPage';
 import { PageSizes } from 'src/api/sizes';
 import { StandardFonts } from 'src/api/StandardFonts';
 import {
+  BoundingBox,
   CustomFontEmbedder,
   CustomFontSubsetEmbedder,
   JpegEmbedder,
@@ -19,6 +22,7 @@ import {
   PDFHexString,
   PDFName,
   PDFObjectCopier,
+  PDFPageEmbedder,
   PDFPageLeaf,
   PDFPageTree,
   PDFParser,
@@ -27,6 +31,7 @@ import {
   PDFWriter,
   PngEmbedder,
   StandardFontEmbedder,
+  TransformationMatrix,
 } from 'src/core';
 import { Fontkit } from 'src/types/fontkit';
 import {
@@ -178,6 +183,7 @@ export default class PDFDocument {
   private readonly pageMap: Map<PDFPageLeaf, PDFPage>;
   private readonly fonts: PDFFont[];
   private readonly images: PDFImage[];
+  private readonly embeddedPages: EmbeddedPDFPage[];
 
   private constructor(context: PDFContext, ignoreEncryption: boolean) {
     assertIs(context, 'context', [[PDFContext, 'PDFContext']]);
@@ -191,6 +197,7 @@ export default class PDFDocument {
     this.pageMap = new Map();
     this.fonts = [];
     this.images = [];
+    this.embeddedPages = [];
 
     if (!ignoreEncryption && this.isEncrypted) throw new EncryptedPDFError();
 
@@ -701,27 +708,110 @@ export default class PDFDocument {
     return pdfImage;
   }
 
+  /**
+   * Embed a PDF page into this document.
+   *
+   * For example:
+   * ```js
+   * const pdfDoc = await PDFDocument.create();
+   *
+   * const sourcePdfUrl = 'https://pdf-lib.js.org/assets/with_large_page_count.pdf';
+   * const sourcePdf = await fetch(sourcePdfUrl).then((res) => res.arrayBuffer());
+   *
+   * // embed page 74 into pdfDoc
+   * const [ embeddedPage ] = await pdfDoc.embedPdfDocument(sourcePdf, [ 73 ]);
+   * ```
+   *
+   * @param pdf The input data containing a PDF document.
+   * @param indices The indices of the pages that should be embedded.
+   * @returns Resolves with an array of the embedded pages.
+   */
+  async embedPdfDocument(
+    pdf: string | Uint8Array | ArrayBuffer,
+    indices: number[],
+  ): Promise<EmbeddedPDFPage[]> {
+    assertIs(pdf, 'pdf', ['string', Uint8Array, ArrayBuffer]);
+    assertIs(indices, 'indices', [Array]);
+
+    const srcDoc = await PDFDocument.load(pdf);
+    const copier = PDFObjectCopier.for(srcDoc.context, this.context);
+    const srcPages = srcDoc.getPages();
+    const embeddedPages: EmbeddedPDFPage[] = new Array(indices.length);
+
+    for (let idx = 0, len = indices.length; idx < len; idx++) {
+      const srcPage = srcPages[indices[idx]];
+      embeddedPages[idx] = await this.embedPage(
+        srcPage,
+        undefined,
+        undefined,
+        copier,
+      );
+    }
+    return embeddedPages;
+  }
+
+  /**
+   * Embed a PDF page into this document.
+   *
+   * For example:
+   * ```js
+   * const pdfDoc = await PDFDocument.create();
+   *
+   * const sourcePdfUrl = 'https://pdf-lib.js.org/assets/with_large_page_count.pdf';
+   * const sourceBuffer = await fetch(sourcePdfUrl).then((res) => res.arrayBuffer());
+   * const sourcePdfDoc = await PDFDocument.load(sourceBuffer);
+   * const sourcePdfPage = sourcePdfDoc.getPages()[73];
+   *
+   * const embeddedPage = await pdfDoc.embedPage(
+   *   sourcePdfPage,
+   *   { // clip the PDF page to a certain area within the page
+   *     left: 100,
+   *     right: 450,
+   *     bottom: 330,
+   *     top: 570
+   *   },
+   *   [1, 0, 0, 1, 10, 200] // translate by (10,200) units
+   * );
+   * ```
+   *
+   * @param page The source PDF page to be embedded
+   * @param boundingBox Optionally, area of the source page that should be embedded
+   * @param transformationMatrix Optionally, a transformation matrix that is always applied to the embedded page
+   * @param copier Optionally, a PDF object copier instance. When embedding multiple pages from the same doc, using the same copier produces smaller PDFs. If none is given, a new copier is used.
+   * @returns Resolves with the embedded pdf page.
+   */
+  async embedPage(
+    page: PDFPage,
+    boundingBox?: BoundingBox,
+    transformationMatrix?: TransformationMatrix,
+    copier?: PDFObjectCopier,
+  ): Promise<EmbeddedPDFPage> {
+    assertIs(page, 'page', [[PDFPage, 'PDFPage']]);
+    const embedder = await PDFPageEmbedder.for(
+      page,
+      copier || PDFObjectCopier.for(page.doc.context, this.context),
+      boundingBox,
+      transformationMatrix,
+    );
+    const ref = this.context.nextRef();
+    const embeddedPage = EmbeddedPDFPage.of(ref, this, embedder);
+    this.embeddedPages.push(embeddedPage);
+    return embeddedPage;
+  }
+
   /**
    * > **NOTE:** You shouldn't need to call this method directly. The [[save]]
    * > and [[saveAsBase64]] methods will automatically ensure that all embedded
    * > assets are flushed before serializing the document.
    *
-   * Flush all embedded fonts and images to this document's [[context]].
+   * Flush all embedded fonts, PDF pages, and images to this document's [[context]].
    *
    * @returns Resolves when the flush is complete.
    */
   async flush(): Promise<void> {
-    // Embed fonts
-    for (let idx = 0, len = this.fonts.length; idx < len; idx++) {
-      const font = this.fonts[idx];
-      await font.embed();
-    }
-
-    // Embed images
-    for (let idx = 0, len = this.images.length; idx < len; idx++) {
-      const image = this.images[idx];
-      await image.embed();
-    }
+    await this.embedAll(this.fonts);
+    await this.embedAll(this.images);
+    await this.embedAll(this.embeddedPages);
   }
 
   /**
@@ -781,6 +871,12 @@ export default class PDFDocument {
     return dataUri ? `data:application/pdf;base64,${base64}` : base64;
   }
 
+  private async embedAll(embeddables: Embeddable[]): Promise<void> {
+    for (let idx = 0, len = embeddables.length; idx < len; idx++) {
+      await embeddables[idx].embed();
+    }
+  }
+
   private updateInfoDict(): void {
     const pdfLib = `pdf-lib (https://github.com/Hopding/pdf-lib)`;
     const now = new Date();
diff --git a/src/api/PDFFont.ts b/src/api/PDFFont.ts
index a082455..1d577af 100644
--- a/src/api/PDFFont.ts
+++ b/src/api/PDFFont.ts
@@ -1,3 +1,4 @@
+import Embeddable from 'src/api//Embeddable';
 import PDFDocument from 'src/api/PDFDocument';
 import {
   CustomFontEmbedder,
@@ -12,7 +13,7 @@ export type FontEmbedder = CustomFontEmbedder | StandardFontEmbedder;
 /**
  * Represents a font that has been embedded in a [[PDFDocument]].
  */
-export default class PDFFont {
+export default class PDFFont implements Embeddable {
   /**
    * > **NOTE:** You probably don't want to call this method directly. Instead,
    * > consider using the [[PDFDocument.embedFont]] and
diff --git a/src/api/PDFImage.ts b/src/api/PDFImage.ts
index f3154c3..ce07656 100644
--- a/src/api/PDFImage.ts
+++ b/src/api/PDFImage.ts
@@ -1,3 +1,4 @@
+import Embeddable from 'src/api/Embeddable';
 import PDFDocument from 'src/api/PDFDocument';
 import { JpegEmbedder, PDFRef, PngEmbedder } from 'src/core';
 import { assertIs } from 'src/utils';
@@ -7,7 +8,7 @@ export type ImageEmbedder = JpegEmbedder | PngEmbedder;
 /**
  * Represents an image that has been embedded in a [[PDFDocument]].
  */
-export default class PDFImage {
+export default class PDFImage implements Embeddable {
   /**
    * > **NOTE:** You probably don't want to call this method directly. Instead,
    * > consider using the [[PDFDocument.embedPng]] and [[PDFDocument.embedJpg]]
diff --git a/src/api/PDFPage.ts b/src/api/PDFPage.ts
index dabec06..39e4d84 100644
--- a/src/api/PDFPage.ts
+++ b/src/api/PDFPage.ts
@@ -1,9 +1,11 @@
 import { Color, rgb } from 'src/api/colors';
+import EmbeddedPDFPage from 'src/api/EmbeddedPDFPage';
 import {
   drawEllipse,
   drawImage,
   drawLine,
   drawLinesOfText,
+  drawPage,
   drawRectangle,
   drawSvgPath,
 } from 'src/api/operations';
@@ -20,6 +22,7 @@ import {
   PDFPageDrawEllipseOptions,
   PDFPageDrawImageOptions,
   PDFPageDrawLineOptions,
+  PDFPageDrawPageOptions,
   PDFPageDrawRectangleOptions,
   PDFPageDrawSquareOptions,
   PDFPageDrawSVGOptions,
@@ -678,6 +681,78 @@ export default class PDFPage {
     );
   }
 
+  /**
+   * Draw an embedded PDF page on this page. For example:
+   * ```js
+   * import { degrees } from 'pdf-lib'
+   *
+   * const pdfDoc = await PDFDocument.create();
+   * const page = pdfDoc.addPage();
+   *
+   * const sourcePdfUrl = 'https://pdf-lib.js.org/assets/with_large_page_count.pdf';
+   * const sourceBuffer = await fetch(sourcePdfUrl).then((res) => res.arrayBuffer());
+   * const sourcePdfDoc = await PDFDocument.load(sourceBuffer);
+   *
+   * // embed page 74 from the PDF
+   * const embeddedPage = await pdfDoc.embedPdfDocument(sourcePdfDoc,73);
+   *
+   * page.drawPage(embeddedPage, {
+   *   x: 250,
+   *   y: 200,
+   *   xScale: 0.5,
+   *   yScale: 0.5,
+   *   rotate: degrees(30)
+   * });
+   * ```
+   *
+   * `options` accept `width`/`height` and `xScale`/`yScale` keys which all
+   * define the size of the drawn page. If both options are given, `width` or `height`
+   * take precedence and the scale variants are ignored.
+   * @param embeddedPdfPage The embeddedPDF page object to be drawn.
+   * @param options The options to be used when drawing the pdf page.
+   */
+  drawPage(
+    embeddedPage: EmbeddedPDFPage,
+    options: PDFPageDrawPageOptions = {},
+  ): void {
+    // TODO: Reuse embeddedPage XObject name if we've already added this embeddedPage to Resources.XObjects
+    assertIs(embeddedPage, 'embeddedPage', [
+      [EmbeddedPDFPage, 'EmbeddedPDFPage'],
+    ]);
+    assertOrUndefined(options.x, 'options.x', ['number']);
+    assertOrUndefined(options.y, 'options.y', ['number']);
+    assertOrUndefined(options.xScale, 'options.xScale', ['number']);
+    assertOrUndefined(options.yScale, 'options.yScale', ['number']);
+    assertOrUndefined(options.xScale, 'options.width', ['number']);
+    assertOrUndefined(options.yScale, 'options.height', ['number']);
+    assertOrUndefined(options.rotate, 'options.rotate', [[Object, 'Rotation']]);
+    assertOrUndefined(options.xSkew, 'options.xSkew', [[Object, 'Rotation']]);
+    assertOrUndefined(options.ySkew, 'options.ySkew', [[Object, 'Rotation']]);
+
+    const xObjectKey = addRandomSuffix('EmbeddedPdfPage', 10);
+    this.node.setXObject(PDFName.of(xObjectKey), embeddedPage.ref);
+
+    const xScale = options.width
+      ? options.width / embeddedPage.width
+      : options.xScale || 1;
+    const yScale = options.height
+      ? options.height / embeddedPage.height
+      : options.yScale || 1;
+
+    const contentStream = this.getContentStream();
+    contentStream.push(
+      ...drawPage(xObjectKey, {
+        x: options.x || this.x,
+        y: options.y || this.y,
+        xScale,
+        yScale,
+        rotate: options.rotate || degrees(0),
+        xSkew: options.xSkew || degrees(0),
+        ySkew: options.ySkew || degrees(0),
+      }),
+    );
+  }
+
   /**
    * Draw an SVG path on this page. For example:
    * ```js
diff --git a/src/api/PDFPageOptions.ts b/src/api/PDFPageOptions.ts
index 7798781..fff1d2d 100644
--- a/src/api/PDFPageOptions.ts
+++ b/src/api/PDFPageOptions.ts
@@ -26,6 +26,18 @@ export interface PDFPageDrawImageOptions {
   ySkew?: Rotation;
 }
 
+export interface PDFPageDrawPageOptions {
+  x?: number;
+  y?: number;
+  xScale?: number;
+  yScale?: number;
+  width?: number;
+  height?: number;
+  rotate?: Rotation;
+  xSkew?: Rotation;
+  ySkew?: Rotation;
+}
+
 export interface PDFPageDrawRectangleOptions {
   x?: number;
   y?: number;
diff --git a/src/api/operations.ts b/src/api/operations.ts
index f61c93e..be264c7 100644
--- a/src/api/operations.ts
+++ b/src/api/operations.ts
@@ -108,6 +108,27 @@ export const drawImage = (
   popGraphicsState(),
 ];
 
+export const drawPage = (
+  name: string | PDFName,
+  options: {
+    x: number | PDFNumber;
+    y: number | PDFNumber;
+    xScale: number | PDFNumber;
+    yScale: number | PDFNumber;
+    rotate: Rotation;
+    xSkew: Rotation;
+    ySkew: Rotation;
+  },
+): PDFOperator[] => [
+  pushGraphicsState(),
+  translate(options.x, options.y),
+  rotateRadians(toRadians(options.rotate)),
+  scale(options.xScale, options.yScale),
+  skewRadians(toRadians(options.xSkew), toRadians(options.ySkew)),
+  drawObject(name),
+  popGraphicsState(),
+];
+
 export const drawLine = (options: {
   start: { x: number | PDFNumber; y: number | PDFNumber };
   end: { x: number | PDFNumber; y: number | PDFNumber };
diff --git a/src/core/embedders/PDFPageEmbedder.ts b/src/core/embedders/PDFPageEmbedder.ts
new file mode 100644
index 0000000..5aba9da
--- /dev/null
+++ b/src/core/embedders/PDFPageEmbedder.ts
@@ -0,0 +1,169 @@
+import PDFPage from 'src/api/PDFPage';
+import PDFArray from 'src/core/objects/PDFArray';
+import PDFRawStream from 'src/core/objects/PDFRawStream';
+import PDFRef from 'src/core/objects/PDFRef';
+import PDFStream from 'src/core/objects/PDFStream';
+import PDFContext from 'src/core/PDFContext';
+import PDFObjectCopier from 'src/core/PDFObjectCopier';
+import { decodePDFRawStream } from 'src/core/streams/decode';
+import PDFContentStream from 'src/core/structures/PDFContentStream';
+import PDFPageLeaf from 'src/core/structures/PDFPageLeaf';
+import CharCodes from 'src/core/syntax/CharCodes';
+import { mergeIntoTypedArray } from 'src/utils';
+
+/**
+ * Represents a page bounding box.
+ * Usually `left` and `bottom` are 0 and right, top are equal
+ * to width, height if you want to clip to the whole page.
+ *
+ *       y
+ *       ^
+ *       | +--------+ (width,height)
+ *       | |        |
+ *       | |  Page  |
+ *       | |        |
+ *       | |        |
+ * (0,0) | +--------+
+ *       +----------> x
+ */
+export interface BoundingBox {
+  left: number /** The left of the bounding box */;
+  bottom: number /** The bottom of the bounding box */;
+  right: number /** The right of the bounding box */;
+  top: number /** The top of the bounding box */;
+}
+
+/**
+ * A transformation matrix according to section `8.3.3 Common Transformations`
+ * of the PDF specification (page 117f). To cite from the spec:
+ *
+ *   * Translations shall be specified as `[1 0 0 1 tx ty]`, where `tx` and `ty` shall
+ *     be the distances to translate the origin of the coordinate system in the
+ *     horizontal and vertical dimensions, respectively.
+ *   * Scaling shall be obtained by `[sx 0 0 sy 0 0]`. This scales the coordinates
+ *     so that 1 unit in the horizontal and vertical dimensions of the new
+ *     coordinate system is the same size as `sx` and `sy` units, respectively, in
+ *     the previous coordinate system.
+ *   * Rotations shall be produced by `[cos(q) sin(q) -sin(q) cos(q) 0 0]`, which has
+ *     the effect of rotating the coordinate system axes by an angle `q` counter clockwise.
+ *   * Skew shall be specified by `[1 tan(a) tan(b) 1 0 0]`,which skews the x-axis by an
+ *     angle `a` and the y axis by an angle `b`.
+ */
+export type TransformationMatrix = [
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+];
+export const identityMatrix: TransformationMatrix = [1, 0, 0, 1, 0, 0];
+
+const embeddablePage = (
+  sourcePage: PDFPage,
+  targetContext: PDFContext,
+  copier: PDFObjectCopier,
+): PDFPageLeaf =>
+  sourcePage.doc.context === targetContext
+    ? sourcePage.node
+    : copier.copy(sourcePage.node);
+
+class PDFPageEmbedder {
+  static async for(
+    page: PDFPage,
+    copier: PDFObjectCopier,
+    boundingBox?: BoundingBox,
+    transformationMatrix?: TransformationMatrix,
+  ) {
+    return new PDFPageEmbedder(page, copier, boundingBox, transformationMatrix);
+  }
+
+  readonly boundingBox: BoundingBox;
+  readonly transformationMatrix: TransformationMatrix;
+
+  private readonly page: PDFPage;
+  private readonly copier: PDFObjectCopier;
+
+  private constructor(
+    page: PDFPage,
+    copier: PDFObjectCopier,
+    boundingBox?: BoundingBox,
+    transformationMatrix?: TransformationMatrix,
+  ) {
+    this.page = page;
+    this.copier = copier;
+    this.boundingBox = boundingBox || this.fullPageBoundingBox(page);
+    this.transformationMatrix = transformationMatrix || identityMatrix;
+  }
+
+  width(): number {
+    const { left, right } = this.boundingBox;
+    return right - left;
+  }
+
+  height(): number {
+    const { top, bottom } = this.boundingBox;
+    return top - bottom;
+  }
+
+  async embedIntoContext(context: PDFContext, ref?: PDFRef): Promise<PDFRef> {
+    const page = embeddablePage(this.page, context, this.copier);
+    const { Contents, Resources } = page.normalizedEntries();
+
+    if (!Contents) throw new Error('Missing page.Contents!');
+    const decodedContents = this.decodeContents(Contents);
+
+    const { left, bottom, right, top } = this.boundingBox;
+    const xObject = context.stream(decodedContents, {
+      Type: 'XObject',
+      Subtype: 'Form',
+      FormType: 1,
+      BBox: [left, bottom, right, top],
+      Matrix: this.transformationMatrix,
+      Resources,
+    });
+
+    if (ref) {
+      context.assign(ref, xObject);
+      return ref;
+    } else {
+      return context.register(xObject);
+    }
+  }
+
+  // `contents` is an array of streams which are merged to include them in the XObject.
+  // This methods extracts each stream and joins them with a newline character.
+  private decodeContents(contents: PDFArray) {
+    const newline = Uint8Array.of(CharCodes.Newline);
+    const decodedContents: Uint8Array[] = [];
+
+    for (let idx = 0, len = contents.size(); idx < len; idx++) {
+      const stream = contents.lookup(idx, PDFStream);
+
+      let content: Uint8Array;
+      if (stream instanceof PDFRawStream) {
+        content = decodePDFRawStream(stream).decode();
+      } else if (stream instanceof PDFContentStream) {
+        content = stream.getUnencodedContents();
+      } else {
+        throw new Error(`Unrecognized stream type: ${stream.constructor.name}`);
+      }
+
+      decodedContents.push(content, newline);
+    }
+
+    return mergeIntoTypedArray(...decodedContents);
+  }
+
+  private fullPageBoundingBox(page: PDFPage) {
+    const { width, height } = page.getSize();
+    return {
+      left: 0,
+      bottom: 0,
+      right: width,
+      top: height,
+    };
+  }
+}
+
+export default PDFPageEmbedder;
diff --git a/src/core/index.ts b/src/core/index.ts
index 9931536..4a6c711 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -16,6 +16,11 @@ export { default as CustomFontEmbedder } from 'src/core/embedders/CustomFontEmbe
 export { default as CustomFontSubsetEmbedder } from 'src/core/embedders/CustomFontSubsetEmbedder';
 export { default as JpegEmbedder } from 'src/core/embedders/JpegEmbedder';
 export { default as PngEmbedder } from 'src/core/embedders/PngEmbedder';
+export {
+  default as PDFPageEmbedder,
+  TransformationMatrix,
+  BoundingBox,
+} from 'src/core/embedders/PDFPageEmbedder';
 
 export { default as PDFObject } from 'src/core/objects/PDFObject';
 export { default as PDFBool } from 'src/core/objects/PDFBool';


Test patch:
diff --git a/apps/node/tests/test1.ts b/apps/node/tests/test1.ts
index 40b4946..9d2c26e 100644
--- a/apps/node/tests/test1.ts
+++ b/apps/node/tests/test1.ts
@@ -29,6 +29,8 @@ const ipsumLines = [
   'Labore nisi officiis quia ipsum qui voluptatem omnis.',
 ];
 
+// This test creates a new PDF document and inserts pages to it.
+// Each page is testing different features of pdf-lib.
 export default async (assets: Assets) => {
   const pdfDoc = await PDFDocument.create();
 
@@ -49,6 +51,9 @@ export default async (assets: Assets) => {
 
   /********************** Page 1 **********************/
 
+  // This page tests different drawing operations as well as adding custom
+  // operators to the page content.
+
   const page1 = pdfDoc.addPage([size, size]);
 
   // Upper-left Quadrant
@@ -158,6 +163,8 @@ export default async (assets: Assets) => {
 
   /********************** Page 2 **********************/
 
+  // This page tests placement of text with different fonts
+
   const page2 = pdfDoc.addPage([size, size]);
 
   page2.drawSquare({ size, color: rgb(253 / 255, 246 / 255, 227 / 255) });
@@ -243,6 +250,8 @@ export default async (assets: Assets) => {
 
   /********************** Page 3 **********************/
 
+  // This page tests embedding and placing different images.
+
   const page3Height = 1750;
   const page3 = pdfDoc.addPage([size, page3Height]);
 
@@ -312,6 +321,42 @@ export default async (assets: Assets) => {
   page3.moveDown(smallMarioDims.height);
   page3.drawImage(smallMarioImage, smallMarioDims);
 
+  /********************** Page 4 **********************/
+
+  // This page tests embedding other PDF pages. First we embed page 3 of this document
+  // but rotate and scale it.
+  // Then we take an external PDF document and embed two pages of it next to each other.
+
+  const page4 = pdfDoc.addPage([size, size]);
+
+  const embeddedPage3 = await pdfDoc.embedPage(page3);
+  page4.drawPage(embeddedPage3, {
+    x: 10,
+    y: size - 10,
+    xScale: 1.52,
+    yScale: 0.42,
+    rotate: degrees(-90),
+  });
+
+  const [embeddedPage1, embeddedPage2] = await pdfDoc.embedPdfDocument(
+    assets.pdfs.normal,
+    [0, 1],
+  );
+  page4.drawPage(embeddedPage1, {
+    x: 40,
+    y: 100,
+    xScale: 0.5,
+    yScale: 0.5,
+  });
+  page4.drawPage(embeddedPage2, {
+    x: 400,
+    y: 100,
+    xScale: 0.5,
+    yScale: 0.5,
+  });
+
+  /********************** Export PDF **********************/
+
   const pdfBytes = await pdfDoc.save();
   return pdfBytes;
 };
diff --git a/apps/node/tests/test2.ts b/apps/node/tests/test2.ts
index a187035..3490f68 100644
--- a/apps/node/tests/test2.ts
+++ b/apps/node/tests/test2.ts
@@ -2,6 +2,9 @@ import fontkit from '@pdf-lib/fontkit';
 import { Assets } from '..';
 import { ParseSpeeds, PDFDocument, rgb } from '../../..';
 
+// This test loads an existing PDF document with many pages.
+// It inserts data for every page (images, rectangles, texts, embedded PDFs).
+// Also, the second page is removed.
 export default async (assets: Assets) => {
   const { fonts, images, pdfs } = assets;
 
@@ -16,8 +19,31 @@ export default async (assets: Assets) => {
   });
   const smallMarioImage = await pdfDoc.embedPng(images.png.small_mario);
   const smallMarioDims = smallMarioImage.scale(0.18);
+  const sourcePdfDoc = await PDFDocument.load(
+    assets.pdfs.with_large_page_count,
+  );
+  const sourcePdfPage = sourcePdfDoc.getPages()[73];
 
-  const pages = pdfDoc.getPages();
+  const embeddedPageFigure = {
+    xOffset: 100,
+    yOffset: 330,
+    width: 350,
+    height: 240,
+    padding: 10,
+  };
+  const embeddedPage = await pdfDoc.embedPage(
+    sourcePdfPage,
+    {
+      // clip the PDF page to a certain area within the page
+      left: embeddedPageFigure.xOffset,
+      right: embeddedPageFigure.xOffset + embeddedPageFigure.width,
+      bottom: embeddedPageFigure.yOffset,
+      top: embeddedPageFigure.yOffset + embeddedPageFigure.height,
+    },
+    // move the clipped part to (0,0) of the embeddedPage for easier inserting
+    [1, 0, 0, 1, -embeddedPageFigure.xOffset, -embeddedPageFigure.yOffset],
+  );
+  const embeddedPageDims = embeddedPage.scale(0.5);
 
   const lines = [
     'This is an image of Mario running.',
@@ -30,7 +56,7 @@ export default async (assets: Assets) => {
 
   const textWidth = ubuntuFont.widthOfTextAtSize(lines[2], fontSize);
 
-  pages.forEach((page) => {
+  pdfDoc.getPages().forEach((page) => {
     const { width, height } = page.getSize();
     const centerX = width / 2;
     const centerY = height / 2;
@@ -55,6 +81,20 @@ export default async (assets: Assets) => {
       x: centerX - textWidth / 2,
       y: centerY - 15,
     });
+    page.drawRectangle({
+      x: 10,
+      y: 10,
+      width: embeddedPageFigure.width / 2 + embeddedPageFigure.padding * 2,
+      height: embeddedPageFigure.height / 2 + embeddedPageFigure.padding * 2,
+      color: solarizedWhite,
+      borderColor: solarizedGray,
+      borderWidth: 2,
+    });
+    page.drawPage(embeddedPage, {
+      x: embeddedPageFigure.padding + 10,
+      y: embeddedPageFigure.padding + 10,
+      ...embeddedPageDims,
+    });
   });
 
   pdfDoc.removePage(1);
diff --git a/tests/core/embedders/PDFPageEmbedder.spec.ts b/tests/core/embedders/PDFPageEmbedder.spec.ts
new file mode 100644
index 0000000..b8d4cd8
--- /dev/null
+++ b/tests/core/embedders/PDFPageEmbedder.spec.ts
@@ -0,0 +1,73 @@
+import fs from 'fs';
+import { PDFDocument } from 'src/api';
+import {
+  PDFContext,
+  PDFObjectCopier,
+  PDFPageEmbedder,
+  PDFRawStream,
+  PDFRef,
+} from 'src/core';
+
+const examplePdf = fs.readFileSync('./assets/pdfs/normal.pdf');
+
+const examplePage = async () => {
+  const doc = await PDFDocument.load(examplePdf);
+  return doc.getPages()[0];
+};
+
+const copier = (doc: PDFDocument) =>
+  PDFObjectCopier.for(doc.context, doc.context);
+
+describe(`PDFPageEmbedder`, () => {
+  it(`can be constructed with PDFPageEmbedder.for(...)`, async () => {
+    const page = await examplePage();
+    const embedder = await PDFPageEmbedder.for(page, copier(page.doc));
+    expect(embedder).toBeInstanceOf(PDFPageEmbedder);
+  });
+
+  it(`can embed PDF pages into PDFContexts with a predefined ref`, async () => {
+    const context = PDFContext.create();
+    const predefinedRef = PDFRef.of(9999);
+    const page = await examplePage();
+    const embedder = await PDFPageEmbedder.for(page, copier(page.doc));
+
+    expect(context.enumerateIndirectObjects().length).toBe(0);
+    const ref = await embedder.embedIntoContext(context, predefinedRef);
+    expect(context.enumerateIndirectObjects().length).toBe(1);
+    expect(context.lookup(predefinedRef)).toBeInstanceOf(PDFRawStream);
+    expect(ref).toBe(predefinedRef);
+  });
+
+  it(`can extract properties of the PDF page`, async () => {
+    const page = await examplePage();
+    const embedder = await PDFPageEmbedder.for(page, copier(page.doc));
+
+    expect(embedder.boundingBox).toEqual({
+      left: 0,
+      bottom: 0,
+      right: page.getSize().width,
+      top: page.getSize().height,
+    });
+    expect(embedder.transformationMatrix).toEqual([1, 0, 0, 1, 0, 0]);
+    expect(embedder.width()).toEqual(page.getWidth());
+    expect(embedder.height()).toEqual(page.getHeight());
+  });
+
+  it(`calculates dimensions depending on the bounding box when given one`, async () => {
+    const page = await examplePage();
+    const boundingBox = {
+      left: 100,
+      bottom: 100,
+      right: 222,
+      top: 333,
+    };
+    const embedder = await PDFPageEmbedder.for(
+      page,
+      copier(page.doc),
+      boundingBox,
+    );
+
+    expect(embedder.width()).toEqual(122);
+    expect(embedder.height()).toEqual(233);
+  });
+});