# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.6.1] - 2026-10-04

### Security

- **A damaged PNG could hang the process.** `pdf-lib`'s PNG decoder never
  returns on a file cut off partway through its image data (a truncated
  upload), on well-formed chunks holding an incomplete compressed stream, or
  on an `iTXt` text chunk missing its separators. A single such input froze
  the merge, and the event loop with it. Every PNG input and PNG image
  watermark is now validated before decoding, and these files throw
  `InvalidPdfFormatError`. So does image data that ends cleanly but short,
  which `pdf-lib` would otherwise pad into a partly blank page. Affects image
  watermarks since 1.4.0 and image inputs since 1.5.0. The same hang applied
  to the frame data of a single-frame animated PNG.
- **`maxImagePixels` could be bypassed with a crafted PNG.** The pixel cap
  read the header at its usual offset, but the decoder accepts a header
  anywhere and uses the last one, and also decodes animation frames at
  whatever size each frame declares. A header placed after another chunk, a
  decoy second header, or an animated PNG (APNG) with an oversized frame
  therefore slipped a huge image past the cap (a 105 KB file decoded a
  6000 × 6000 frame). The header must now come first and appear once, an
  animation frame must lie within the image, and image data that inflates
  past the declared size is rejected without decompressing all of it.
  Affects 1.5.2–1.6.0 (the releases with `maxImagePixels`).

  Animated PNGs behave as before: a single-frame APNG embeds exactly as in
  1.6.0, and one with more than one frame is still rejected (`pdf-lib`
  refuses those), now before any frame is decoded. Harmless quirks that
  browsers and earlier versions tolerate (bad CRCs, a missing zlib checksum
  or `IEND` chunk, trailing bytes) are still accepted; all 791 PNGs found on
  a test system pass. The extra check costs about 130 ms for a 12-megapixel
  PNG.
- **`maxBytesPerUrl: NaN` silently removed the response size cap.** A `NaN`,
  typically from an unset environment variable passed through `Number()`,
  made every size comparison false, so a response of any size was accepted.
  `maxBytesPerUrl` must now be a positive number (or `Infinity` to remove the
  cap deliberately); anything else throws `PdfMergeError`.
- **A URL with an embedded username and password exposed them.** `fetch`
  refuses such URLs, and its error, attached as the `PdfFetchError`'s
  `cause`, repeated the full URL including the password and any query-string
  token, although the library documents that URLs are stripped of both.
  These URLs are now rejected up front with a `PdfFetchError` that carries no
  secrets and suggests an `Authorization` header instead. A redirect to such
  a URL also fails without exposing it.
- **A long table-of-contents title cost quadratic CPU time.** Shortening a
  title to fit re-measured it once per removed character: an 8,000-character
  title took about 2 s and a 32,000-character one about 49 s, blocking the
  event loop. It now uses a binary search, with identical output (checked
  against the old method on 20,000 random titles); 32,000 characters take
  about 6 ms.
- **Host checks ignored cancellation and the timeout.** With
  `blockPrivateHosts` or `allowedHosts` set, the DNS lookups for every URL
  ran at once, ignored `signal` and `timeoutMs`, and ran even when the signal
  was already aborted. They now run under the `concurrency` limit, stop
  waiting on abort or after `timeoutMs` (`PdfFetchError`), and an
  already-aborted signal fails the call before any work starts. Lookups for
  redirect targets honor the same signal.
- **Rejected responses kept their connection open.** After an error status,
  an oversized `Content-Length` or a rejected redirect, the response body was
  left unread, and the server kept streaming it until garbage collection.
  These bodies are now discarded immediately.

### Fixed

- **A large or infinite `timeoutMs` failed every request after 1 ms.** Node
  caps timer delays at 2^31 − 1 ms and fires longer ones almost at once.
  `Infinity`, or any value above that limit, now means no timeout. `NaN`,
  zero and negative values, which also failed every request, now throw
  `PdfMergeError` up front.
- **Raw `TypeError`s escaped instead of the documented `PdfMergeError`.**
  - Metadata of the wrong type (a date string, `keywords` as a string, a
    numeric title) now throws `PdfMergeError` naming the field, before the
    merge runs instead of after. A `null` metadata field is treated as unset.
  - A PDF that parses but has a broken page tree (for example no `/Pages`)
    now throws `InvalidPdfFormatError`.
  - `text: null` is treated as unset, like other `null` options.
  - A custom text font that embeds but fails during layout (for example a
    color emoji font) now throws `PdfMergeError`.
- **Text inputs mishandled some line breaks.** Form feeds, vertical tabs, NEL
  and the Unicode line and paragraph separators were drawn as characters, so
  lines overlapped or ran past the margin. They are now line breaks, and a
  form feed starts a new page (a trailing one adds no blank page). Text
  without these characters renders byte-for-byte as before.
- **CLI: a `.txt` file failed when mixed with URLs.** In mixed file-and-URL
  mode, local files were passed as bytes without a format, and plain text has
  no signature to detect, so it was rejected as an invalid PDF. A `.txt` file
  whose content matches no other format is now treated as text.
- **CLI: a colon in a URL's query string could be read as a page selector
  without notice.** `https://host/c.pdf?t=10:3` fetched `?t=10` and kept only
  page 3. The CLI still reads it that way, since signed URLs followed by
  `:pages` rely on it, but now prints a warning; write the colon as `%3A` when
  it belongs to the URL.

- **A page selected more than once showed every copy's page number and
  watermark.** With `pages: [1, 1, 1]` the copies shared one content list, so
  each stamp landed on all of them ("1 / 3", "2 / 3" and "3 / 3" overlapping
  on every copy). Each page now gets its own; the same applies to source PDFs
  whose pages share a content array.
- **Page numbers and watermarks were misplaced on rotated pages and on pages
  not anchored at the origin.** Named positions were computed on the
  unrotated MediaBox, so on a page with `/Rotate 90` a "bottom-center" number
  appeared sideways on the left edge, and on a page whose MediaBox or CropBox
  starts away from (0, 0) stamps could fall outside the visible area. Named
  positions are now computed on the page as a viewer shows it (CropBox,
  rotation), and the stamp reads upright. An explicit `{ x, y }` is
  unchanged: it is still taken in the page's own coordinates.
- A table of contents now matches the first page as displayed, so a rotated
  portrait page gives a landscape TOC.

### Changed

- `timeoutMs` and `maxBytesPerUrl` are now validated like `concurrency` and
  `maxImagePixels`. Two inputs that used to be accepted now throw
  `PdfMergeError`: `maxBytesPerUrl: NaN`, which disabled the size cap (see
  Security above), and a numeric string such as `timeoutMs: '5000'`, which
  the timer coerced; pass a number instead.

### Dependencies

- `pako` (already installed as a dependency of `pdf-lib`) is now a direct
  dependency, used for the PNG data check. No new packages are installed.

## [1.6.0] - 2026-10-03

This release also carries the security fixes prepared as 1.5.3, which was
never published; upgrade from 1.5.2 directly to 1.6.0.

### Security

- **Bound the CPU cost of text conversion.** The 200 KB cap on `.txt` inputs
  bounded bytes but not CPU: the line wrapper measured text by calling the
  font's layout routine once per word and, when hard-breaking, once per
  character, at roughly 20–50 µs per call. Inputs inside the cap could
  therefore block the event loop for minutes — a 200 KB run of tabs took
  ~160 s and a single 200 KB unbroken token ~48 s — a denial-of-service vector
  for any service converting untrusted text. Widths are now summed from a
  per-character cache, so the whole pass is linear with only a handful of
  layout calls; both cases now complete in a few seconds. Wrapping output is
  unchanged. Affects 1.5.0–1.5.2.

### Changed

- **Image inputs are now fitted to a page by default.** Previously each `.png`
  / `.jpg` input became a page sized to the image itself at one pixel per
  point, so a 12-megapixel phone photo produced a page about 56 × 42 inches.
  Next to normal pages that looked heavily zoomed in. Each image now goes on a
  US Letter page with 0.75-inch margins, matching converted text pages and
  keeping page numbers clear of the image. The page turns landscape for a
  landscape image, and the image is scaled down to fit, centered, aspect
  ratio preserved. Images smaller than the page are
  never enlarged. The full-resolution image is still embedded, so no detail is
  lost. To restore the previous behaviour, pass `image: { fit: 'natural' }`.
- **`maxImagePixels` now also applies to JPEG dimensions.** JPEGs are still
  embedded without decoding, but their declared frame-header dimensions are
  now checked against the same pixel cap as PNGs, so an absurd header (e.g.
  65535 × 65535) no longer produces a page far beyond the PDF specification's
  14400-point maximum. In 1.5.2 JPEGs were exempt from the cap, so a JPEG
  whose header declares more than `maxImagePixels` pixels (default 100 MP) is
  now rejected; raise `maxImagePixels` to allow it.

### Added

- **`MergeOptions.image`** controls image page layout: `fit` (`'page'` or
  `'natural'`), `pageSize` (`'letter'`, `'a4'`, or `[width, height]` in
  points), and `margin` in points (default 54). Invalid values throw `PdfMergeError`. New
  exported type: `ImageOptions`.

### Fixed

- With `fit: 'natural'`, pages are now capped at the PDF format's maximum of
  14400 points (200 inches). A wider or taller image previously produced an
  out-of-spec page that some viewers reject; it is now scaled down to fit.
- **Invalid options no longer escape as a raw `TypeError`.** A rejected value
  that JSON can't represent, such as a `BigInt` or a circular array in
  `image.pageSize` or an input's `format` field, crashed while the error
  message was being formatted and surfaced as a `TypeError` instead of the
  documented `PdfMergeError`.

### Documentation

- Security model: document the real memory cost of a large PNG (about
  4 bytes per pixel resident, roughly double transiently while decoding) and
  recommend a lower `maxImagePixels` for services that accept untrusted
  uploads.

### Maintenance

- Development dependencies updated within their existing version ranges
  (`typescript-eslint` 8.71.0, `prettier` 3.9.9, `playwright-core` 1.63.0,
  `esbuild` 0.28.2, `globals` 17.13.0, `@types/node` 20.19.43). This also
  clears new denial-of-service advisories against the `brace-expansion` and
  `js-yaml` lint-tooling dependencies. None of these ship in the package.
- Corrected a mislabelled lockfile entry: `@pdf-lib/standard-fonts` was
  recorded as version 1.1.0, which does not exist, while resolving to the
  genuine 1.0.0 tarball. The installed code is unchanged.

## [1.5.2] - 2026-08-22

### Security

- **Guard against PNG decompression bombs.** `pdf-lib` decodes a PNG into raw
  pixels, allocating `width × height × 4` bytes from the dimensions declared in
  the header *before* reading any pixel data — so a tiny (tens of bytes) PNG
  claiming enormous dimensions could force a multi-gigabyte allocation and
  crash the process. This affected both converted `'png'` inputs and image
  watermarks. Images are now checked against a pixel-count limit from the
  header alone, before `pdf-lib` sees them. New option **`maxImagePixels`**
  (default `100_000_000`, i.e. 100 MP) lets you raise the limit for
  legitimately huge scans. JPEGs are embedded without decoding and are not
  affected.

### Fixed

- **CLI: don't mis-read a URL's port as a page selector.** A URL given with a
  port but no path (e.g. `http://host:8080`) was split on its last colon, so
  the port was treated as a `:pages` selector and the wrong URL was fetched.
  The `:pages` suffix is now only split from URLs when it follows a path
  segment; `http://host:8080/a.pdf:1-3` still selects pages correctly.
- **Table of contents no longer fails on an unrenderable title.** A TOC label
  the built-in Helvetica font can't encode (e.g. a CJK filename picked up as
  the default title) now falls back to `Document N` instead of throwing and
  aborting the whole merge. An explicit `tableOfContents.heading` that can't be
  rendered still throws, as before.

### Documentation

- Security model: document the PNG decompression-bomb guard and
  `maxImagePixels`, and note `pdf-lib`'s maintenance status (no release since
  late 2021) as a known dependency risk.

## [1.5.1] - 2026-08-15

### Security

- **Close an SSRF bypass for IPv4-mapped IPv6 literals.** With
  `blockPrivateHosts` enabled, a URL whose host was an IPv4-mapped IPv6
  address (e.g. `http://[::ffff:127.0.0.1]/` or
  `http://[::ffff:169.254.169.254]/`) slipped past the private-address guard:
  the URL parser normalizes such literals to the compressed hex form
  (`::ffff:7f00:1`), which the old dotted-decimal-only check didn't recognize,
  and because the host is an IP literal no DNS lookup ran to catch it. The
  guard now fully expands IPv6 literals and inspects the embedded IPv4 of both
  IPv4-mapped (`::ffff:0:0/96`) and IPv4-compatible (`::/96`) addresses, so
  loopback, link-local (cloud metadata), and private targets are blocked in
  every representation. Public hosts (including public IPv4-mapped addresses)
  are unaffected.

### Fixed

- **CLI: apply `--https-only`, `--timeout`, and `--concurrency` when mixing
  local files and URLs.** Previously, a command that combined a local file and
  a remote URL fetched the URL with a bare, unbounded request, silently
  ignoring those flags and the response size cap. Remote inputs in the mixed
  case now go through `mergePdfUrls`, so the same protocol/timeout checks,
  size cap, and PDF validation as the URL-only path apply.

### Changed

- Switched the README CI status badge to GitHub's native workflow badge so it
  reflects build status directly without a third-party image proxy.

## [1.5.0] - 2026-08-13

### Added

- **Assemble PDFs from mixed inputs — text and images.** `mergePdfFiles` and
  `mergePdfBuffers` now accept `.txt`, `.png`, `.jpg`/`.jpeg` inputs in addition
  to PDFs; each non-PDF input is converted to a PDF and merged in order.
  - **Images** become one page each, sized to the image's natural pixel
    dimensions and drawn unchanged.
  - **Text** is rendered as multi-page US-Letter pages with **full Unicode**
    support via a bundled, subsetted DejaVu Sans font (Latin, Cyrillic, Greek,
    and many symbols). The font is **lazy-loaded** — PDF/image merges never
    pull it in — and can be overridden per merge (`options.text.font`) for
    scripts it doesn't cover, e.g. CJK. Text layout is configurable via
    `options.text` (`fontSize`, `margin`, `lineHeight`).
  - Format is auto-detected from the file extension (files) or magic bytes
    (PNG/JPEG buffers); plain text has no signature, so buffers must set
    `format: 'txt'`. A per-input `format` field overrides detection.
  - Guardrails: text inputs are capped at **200 KB**; images have no cap.
    Unknown formats and malformed images throw typed errors
    (`PdfMergeError` / `InvalidPdfFormatError`).
  - Works in Node and the browser. Additive and fully backward compatible —
    all-PDF merges keep the existing byte-identical fast path. New runtime
    dependency: `@pdf-lib/fontkit`. New exported types: `InputFormat`,
    `TextOptions`.

## [1.4.0] - 2026-08-03

### Added

- **Image / logo watermark.** The `watermark` option now accepts an image in
  addition to text — provide `image` instead of `text` to stamp a PNG or JPEG
  on every page. The image can be raw bytes (`Uint8Array` / `ArrayBuffer`) or a
  Base64 string (bare payload or `data:image/...;base64,` URI); the format is
  auto-detected from the magic bytes, or set `format` to `'png'`/`'jpg'` to
  override. Size it with `width` / `height` (aspect-ratio preserved when only
  one is given) or a uniform `scale`, defaulting to the image's natural pixel
  size. Reuses the existing `opacity`, `rotate`, and `position` fields, and the
  image is embedded once and reused across pages. Works in Node and the
  browser. Opt-in and fully backward compatible — text watermarks are
  unchanged, and omitting `watermark` keeps the byte-identical fast path. New
  exported types: `TextWatermark`, `ImageWatermark`, `WatermarkPosition`
  (`Watermark` is now the union `TextWatermark | ImageWatermark`).

## [1.3.0] - 2026-07-30

### Added

- **Table of contents.** A new `tableOfContents` merge option prepends a
  printed contents page listing every source document and the page it starts
  on, with each row a clickable internal link that jumps to that document.
  Long lists spill onto additional pages automatically. Entry labels come
  from each input's optional `title`; `mergePdfFiles` defaults the label to
  the file's basename, and everything else falls back to `"Document N"`. The
  listed (and stamped, when combined with `pageNumbers`) page numbers are the
  physical positions in the final PDF, so the TOC page is page 1 and the
  numbers match a viewer. Works in Node and the browser, and the CLI gains a
  `--toc` flag. Opt-in and fully backward compatible — omitting the option
  keeps the byte-identical fast path. New exported type: `TableOfContents`.
- **`*WithInfo` merge variants** that return the merged PDF along with its
  page count and byte size, computed during the merge (no re-parse):
  `mergeBase64PDFsWithInfo`, `mergePdfBuffersWithInfo`,
  `mergePdfFilesWithInfo` (Node only), and `mergePdfUrlsWithInfo`. Each takes
  the same arguments/options as its plain counterpart and returns
  `MergeResult<T>` (`{ pdf, pageCount, byteLength }`). `byteLength` is the
  size of the PDF bytes (for the Base64/URL variants, the decoded size, not
  the Base64 string length). The plain functions are unchanged and now simply
  return the `pdf` field of their `*WithInfo` counterpart — fully backward
  compatible.

### Internal

- Migrated the linter to **ESLint 9** (flat config `eslint.config.mjs`) with
  `typescript-eslint` 8, replacing the end-of-life ESLint 8 / `.eslintrc.json`
  setup. Same rules as before. Also bumped `prettier` and `playwright-core`
  patch/minor. No runtime or published-code changes.

## [1.2.0] - 2026-07-23

### Added

- **Browser support.** `mergeBase64PDFs`, `mergePdfBuffers`, and
  `mergePdfUrls` now run in the browser via a dedicated browser-safe build,
  selected automatically by bundlers through the package's `browser` export
  condition (or imported explicitly from `@lucidaquarian/pdf-merge/browser`).
  The Node API is unchanged. `mergePdfFiles` remains Node-only; in the
  browser `mergePdfUrls` uses the platform `fetch` (subject to CORS) and the
  SSRF host guard is limited to initial-URL literal-IP/`localhost` checks.
  Internally the library was split into an isomorphic `core` with the Node
  entry injecting `fs`/`dns` capabilities. Verified with a real-Chromium
  smoke test (`npm run test:browser`).

### Changed

- Removed the package-rename / migration notice and prior-scope references
  from the README and CHANGELOG; updated the `LICENSE` copyright holder to
  match the package's publishing identity.

### Documentation

- Added a **"Resource limits for untrusted PDFs"** note to the security
  section covering the decompression-bomb / DoS consideration inherent to
  PDF parsing, what the library already bounds (`maxBytesPerUrl`,
  `timeoutMs`), and how to sandbox untrusted input (bounded-heap worker).

## [1.1.0] - 2026-07-19

### Added

- **SSRF hardening for `mergePdfUrls`** via two opt-in options
  (both default off, fully backward compatible):
  - `blockPrivateHosts` — reject any URL or redirect target that is
    `localhost` or resolves to a loopback / private / link-local / reserved
    IP (IPv4 and IPv6, including IPv4-mapped and the `169.254.169.254` cloud
    metadata endpoint).
  - `allowedHosts` — restrict fetches to an explicit hostname allowlist.
  When either is enabled, redirects are followed **manually** and every hop
  is validated *before* it is contacted (so an internal host is never
  reached), and `Authorization` / `Cookie` headers are dropped on
  cross-origin redirects.

### Fixed

- Removed a stray `<<<<<<< HEAD` merge-conflict marker accidentally committed
  to this changelog's `[Unreleased]` section.
- **Non-Latin1 watermark / page-number text now throws `PdfMergeError`
  instead of a raw pdf-lib `Error`.** The built-in Helvetica font can
  only encode WinAnsi (Latin-1); CJK, emoji, and most non-Latin text
  previously escaped the typed-error contract with a cryptic
  `WinAnsi cannot encode "…"` error. `watermark.text` and
  `pageNumbers.format` are now checked up front and surface a
  `PdfMergeError` naming the offending field.
- **`PdfFetchError.index` is now set on oversize-response errors.**
  `readBodyWithCap` threw its `Content-Length exceeds maxBytes` and
  `response body exceeds maxBytes` errors without the input index, so
  `.index` was `undefined` specifically for oversized responses while
  every other fetch error carried it. It now matches the rest.
- `mergeBase64PDFs` now accepts valid **unpadded** Base64 input. The input
  validator previously required the length to be a multiple of 4 and
  rejected unpadded strings that `Buffer.from(x, 'base64')` decodes fine.
- URL merges stop enqueuing new fetches once any fetch fails, bounding
  wasted network work on the failure path (in-flight requests still
  finish, but queued ones are skipped).
- `mergePdfUrls` now validates the `concurrency` option and throws a typed
  `PdfMergeError` for non-integer, zero, negative, `NaN`, or `Infinity`
  values. Previously an invalid `concurrency` propagated into the internal
  fetch pool and surfaced as a raw `RangeError` from `new Array(size)`. The
  CLI already validated `--concurrency`; this closes the gap for the
  programmatic API.

### Internal

- Added ESLint + Prettier with a `lint` / `format:check` CI job.
- Removed dead lint-suppression directives and an unused test variable.

## [1.0.0] - 2026-06-21

Initial release of `@lucidaquarian/pdf-merge`.

### Features

- `mergeBase64PDFs`, `mergePdfBuffers`, `mergePdfFiles`, `mergePdfUrls`
  — four input sources, one consistent API.
- **Per-input page selection** via array (`[1, 3, 5]`) or 1-indexed
  range string (`"1-3,5,8-10"`).
- **Document metadata** — title, author, subject, keywords, creator,
  creation/modification dates on the merged output.
- **Text watermark** with configurable opacity, color, font size,
  rotation, and position (named placement or `{ x, y }`).
- **Page numbers** with `{current}` / `{total}` template tokens,
  configurable `startAt`, six named positions plus absolute `{ x, y }`.
- **`AbortSignal` cancellation** on every merge function; aborts both
  in-flight URL fetches and the merge loop between sources.
- **`ignoreEncryption` option** to merge source PDFs that declare
  encryption metadata but are functionally readable.
- **SSRF-safe URL fetching by default** — protocol allowlist, response
  size cap, per-request timeout, redirect-protocol checks, bounded
  concurrency, URL sanitization in error messages.
- **Typed errors with input-index correlation** (`PdfMergeError`,
  `InvalidPdfFormatError`, `PdfFetchError` with `.url` and `.index`).
- **Bundled `pdf-merge` CLI** with file/URL inputs, page-selector
  suffix syntax, metadata flags, and URL knobs.
- **Dual ESM and CommonJS build** with `"sideEffects": false` for
  tree-shaking.

## Earlier history

The entries below document the project's earlier `1.x` release line,
preserved here for reference.

## [1.5.0] - 2026-06-21

### Added

- **`AbortSignal` support** on every merge function via `options.signal`.
  Aborts both in-flight URL fetches (in `mergePdfUrls`) and the merge
  loop itself between sources. Pre-aborted signals throw immediately
  without doing any work. Follows the Node convention: throws
  `signal.reason` if set, otherwise an `AbortError`-shaped
  `DOMException`.
- **Page numbers** via `options.pageNumbers`. Supports `{current}` and
  `{total}` tokens (`format: 'Page {current} of {total}'`), `startAt`
  for arbitrary first-page values, named positions (six placements
  including `bottom-center` default) or explicit `{ x, y }`, plus
  `fontSize` and `color`. Stamps every page in the merged output.
- **`options.ignoreEncryption`** — `true` skips pdf-lib's encryption
  check at load time. Use for older-generator PDFs that declare
  encryption metadata but are functionally readable. Does **not**
  decrypt: truly password-protected files still fail later.
- **`"sideEffects": false`** in `package.json` so bundlers tree-shake
  the ESM build. Consumers importing a single merge function now ship
  ~5 kB instead of ~25 kB.

### Fixed

- CLI/dual-build test suite now passes on Windows and Node 18. The
  executable-bit assertion is skipped on Windows (which has no Unix mode
  bits) in favor of a cross-platform shebang check, and the ESM
  `import()` smoke test no longer gets downleveled to `require()` by
  ts-node (which threw `ERR_REQUIRE_ESM` on Node < 22). These were test
  bugs only — the published package was unaffected.

### Compatibility

- Every prior 1.x call signature still type-checks and produces
  byte-identical output. The fast-path regression test now covers the
  three new options (omitted / `null` / undefined all hit the original
  `mergePdfBytes` code path).

## [1.4.0] - 2026-06-18

### Added

- **Text watermark** stamped on every page of the merged output via the new
  `watermark?: Watermark` option on every merge function.
  Supports `text`, `opacity` (default 0.2), `fontSize` (default 48),
  `color`, `rotate`, and `position` (`'center'` | named corner |
  `{ x, y }`). Helvetica is used so no extra fonts are embedded.
- Strict validation of watermark fields — invalid input throws
  `PdfMergeError` with field-pointing messages, keeping the API's
  typed-error guarantee intact.

### Fixed

- `mergePdfUrls` silently dropped the `watermark` option (it forwarded
  only `metadata` to the merge engine). The watermark URL test was a
  no-op assertion — the bug went undetected. Test now decodes the
  content streams and asserts the watermark text actually appears.
- Rotated watermarks could land off-page because pdf-lib's `drawText`
  rotates around the baseline-left point, not the visual center. The
  position math now compensates so the rotated text's visual center
  lands at the requested anchor.
- Named corner positions (`top-right` etc.) now use the rotated
  axis-aligned bounding box rather than the un-rotated dimensions —
  rotated watermarks sit snug against the corner instead of overshooting
  past the page edge.

## [1.3.0] - 2026-06-17

### Added

- **Document metadata** option — stamp the merged output with `title`,
  `author`, `subject`, `keywords`, `creator`, `creationDate`, and
  `modificationDate` via `options.metadata`.
- **`pdf-merge` CLI** shipped as a `bin` entry. Accepts file paths and
  http(s) URLs, supports `path:1-3,5` page-selector suffix syntax,
  metadata flags (`--title`, `--author`, etc.), and URL knobs
  (`--concurrency`, `--timeout`, `--https-only`).
- **Dual ESM / CommonJS build** via conditional `exports` map.
  TypeScript definitions are shipped from the CJS build and apply to
  both consumers.
- npm package metadata: README badges, broader `keywords` list, and
  corrected `repository` / `bugs` / `homepage` URLs.

### Changed

- Package `main` moved from `dist/pdfMerger.js` to `dist/cjs/pdfMerger.js`.
  Standard `import` / `require()` callers are unaffected; deep paths into
  the old `dist/` location no longer work (non-idiomatic anyway).

## [1.2.0] - 2026-06-17

### Added

- **`mergePdfBuffers(inputs, options?)`** — merge raw PDF bytes
  (`Buffer` / `Uint8Array`) without the Base64 round-trip.
  Returns a `Uint8Array`.
- **`mergePdfFiles(inputs, options?)`** — read PDFs from disk in parallel
  and merge them. Returns a `Uint8Array`.
- **Per-input page selection** via a `PageSelector` (`number[]` or
  1-indexed range string like `"1-3,5,8-10"`) on every merge function.
  Descending ranges reverse, duplicates are kept, out-of-range selectors
  throw `PdfMergeError`.
- `mergePdfUrls` gained a `concurrency` option (default 8), a `headers`
  option for default request headers, and a `{ url, headers?, pages? }`
  object form per input for per-URL overrides.
- `PdfFetchError.index` now identifies which URL in the input array
  failed.

### Changed

- API signatures widened with additive unions so every previous 1.x
  call still type-checks and produces byte-identical output (regression
  pinned by a fast-path test).

## [1.1.0] - 2026-06-14

### Added

- Hardened `mergePdfUrls` against SSRF and abuse:
  - Protocol allowlist (`http:` and `https:` only by default)
  - Response size cap (`maxBytesPerUrl`, default ~100 MiB)
  - Per-request timeout via `AbortController` (`timeoutMs`, default
    5000 ms)
  - Redirect-protocol check
  - URL sanitization in error messages (credentials and query strings
    stripped before they reach logs)
- `MergePdfUrlsOptions` interface with `timeoutMs`, `maxBytesPerUrl`,
  `allowedProtocols`.

## [1.0.0] - 2026-06-09

### Added

- Initial release.
- `mergeBase64PDFs(base64Strings)` — merge Base64-encoded PDFs into a
  single Base64 PDF.
- `mergePdfUrls(urls)` — fetch and merge PDFs from URLs.
- Typed errors: `PdfMergeError`, `InvalidPdfFormatError`, `PdfFetchError`.

[Unreleased]: https://github.com/lucidaquarian/pdf-merge/compare/v1.6.1...HEAD
[1.6.1]: https://github.com/lucidaquarian/pdf-merge/compare/v1.6.0...v1.6.1
[1.6.0]: https://github.com/lucidaquarian/pdf-merge/compare/v1.5.2...v1.6.0
[1.5.2]: https://github.com/lucidaquarian/pdf-merge/compare/v1.5.1...v1.5.2
[1.5.1]: https://github.com/lucidaquarian/pdf-merge/compare/v1.5.0...v1.5.1
[1.5.0]: https://github.com/lucidaquarian/pdf-merge/compare/v1.4.0...v1.5.0
[1.4.0]: https://github.com/lucidaquarian/pdf-merge/compare/v1.3.0...v1.4.0
[1.3.0]: https://github.com/lucidaquarian/pdf-merge/compare/v1.2.0...v1.3.0
[1.2.0]: https://github.com/lucidaquarian/pdf-merge/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/lucidaquarian/pdf-merge/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/lucidaquarian/pdf-merge/releases/tag/v1.0.0

