├── .editorconfig ├── .github └── workflows │ ├── linux.yml │ └── windows.yml ├── .gitignore ├── .ncurc.js ├── .npmignore ├── .nvmrc ├── .prettierrc.json ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── publish.sh ├── docs ├── .nojekyll ├── assets │ ├── hierarchy.js │ ├── highlight.css │ ├── icons.js │ ├── icons.svg │ ├── main.js │ ├── navigation.js │ ├── search.js │ └── style.css ├── enums │ └── Dpi.html ├── functions │ └── comparePdfToSnapshot.html ├── index.html ├── modules.html └── types │ ├── CompareOptions.html │ ├── HighlightColor.html │ ├── MaskRegions.html │ ├── PdfToPngOptions.html │ ├── RectangleMask.html │ └── RegionMask.html ├── eslint.config.mjs ├── examples ├── cjs_sample │ ├── .gitignore │ ├── .nvmrc │ ├── README.md │ ├── index.cjs │ ├── package-lock.json │ ├── package.json │ └── pdf │ │ ├── test_doc_1.pdf │ │ └── test_doc_1_changed.pdf ├── esm_jest_and_node_test_runners │ ├── .gitignore │ ├── .nvmrc │ ├── README.md │ ├── jest.config.js │ ├── package-lock.json │ ├── package.json │ ├── pdf │ │ ├── test_doc_1.pdf │ │ └── test_doc_1_changed.pdf │ └── src │ │ ├── __snapshots__ │ │ ├── PDF_visual_regression.png │ │ └── Snapshot_should_match.png │ │ ├── jest_test_runner.test.js │ │ ├── node_test_runner.test.js │ │ └── utils.js └── ts-sample │ ├── .eslintignore │ ├── .eslintrc.js │ ├── .gitignore │ ├── .mocharc.json │ ├── .nvmrc │ ├── .prettierrc.json │ ├── .vscode │ └── settings.json │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── __snapshots__ │ │ ├── dynamically-generated-pdf-with-text.diff.png │ │ ├── dynamically-generated-pdf-with-text.new.png │ │ ├── dynamically-generated-pdf-with-text.png │ │ └── dynamically-generated-pdf.png │ ├── index.test.ts │ └── index.ts │ └── tsconfig.json ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── __snapshots__ │ ├── barcodes-1-default-low-x-4.png │ ├── barcodes-1-default-opts-dpi-low-x-4.png │ ├── barcodes-1-default-opts-dpi-low.png │ ├── barcodes-1-default-opts.png │ ├── barcodes-1-dpi-low.png │ ├── mask-different-mask-per-page.png │ ├── mask-multi-page-pdf.png │ ├── mask-only-second-page-of-the-pdf-with-undefined.png │ ├── mask-only-second-page-of-the-pdf.png │ ├── mask-rectangle-masks.png │ ├── mask-rectangle-masks_without_scaling.png │ ├── should-remove-diff-and-new.png │ ├── two-page-success.png │ ├── two-page.diff.png │ ├── two-page.new.png │ └── two-page.png ├── cli │ ├── approve.ts │ ├── discard.ts │ ├── index.ts │ ├── utils.test.ts │ └── utils.ts ├── compare-images.test.ts ├── compare-images.ts ├── compare-pdf-to-snapshot.test.ts ├── compare-pdf-to-snapshot.ts ├── conversions │ ├── conversions.test.ts │ ├── conversions.ts │ └── index.ts ├── imageUtils │ ├── index.ts │ ├── mergeImages.ts │ └── writeImages.ts ├── index.ts ├── pdf2png │ ├── index.ts │ ├── pdf2png.test.ts │ └── pdf2png.ts ├── test-data │ ├── expected-initial-rectangle-masks.png │ ├── pdf2png-expected │ │ ├── __snapshots__ │ │ │ ├── TAMReview.png │ │ │ ├── TAMReview_without_scaling.png │ │ │ ├── single-page-small.png │ │ │ ├── single-page.png │ │ │ └── two-page.png │ │ ├── cmaps.png │ │ ├── should_scale_using_custom_DPI.png │ │ ├── two-page_png_per_page_1.png │ │ ├── two-page_png_per_page_2.png │ │ ├── two-page_png_per_page_scaled_1.png │ │ └── two-page_png_per_page_scaled_2.png │ ├── pdfs │ │ ├── TAMReview.pdf │ │ ├── barcodes-1.pdf │ │ ├── cmaps.pdf │ │ ├── single-page-small.pdf │ │ ├── single-page.pdf │ │ └── two-page.pdf │ ├── sample-image-2.diff.png │ ├── sample-image-2.png │ ├── sample-image-expected.png │ ├── sample-image.png │ ├── single-page-expected.png │ ├── single-page-small-expected.png │ └── two-page-expected.png ├── toMatchPdfSnapshot.ts └── types.ts ├── test ├── __snapshots__ │ ├── jest_smoke_tests_custom_matcher.png │ └── jest_smoke_tests_plain_fn.png └── jest.test.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | 11 | [CHANGELOG.md] 12 | indent_size = false 13 | -------------------------------------------------------------------------------- /.github/workflows/linux.yml: -------------------------------------------------------------------------------- 1 | name: Linux CI/CD 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | 9 | jobs: 10 | linux-build: 11 | runs-on: ubuntu-20.04 12 | strategy: 13 | matrix: 14 | node-version: [20.x, 22.x] 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - name: Print versions 22 | run: cat /etc/os-release 23 | - name: Install dependencies 24 | run: npm install 25 | - name: Run tests 26 | run: npm run test 27 | - name: Store snapshots from tests 28 | uses: actions/upload-artifact@v4 29 | if: failure() 30 | with: 31 | name: snapshots-linux-node-${{ matrix.node-version }} 32 | path: ${{ github.workspace }}/src/__snapshots__/ 33 | - name: Run lint 34 | run: npm run lint 35 | - name: Run format 36 | run: npm run format 37 | - name: Make sure docs can be build 38 | run: npm run build:docs 39 | - name: Run jest smoke tests 40 | run: npm run test:jest 41 | -------------------------------------------------------------------------------- /.github/workflows/windows.yml: -------------------------------------------------------------------------------- 1 | name: Windows CI/CD 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | 9 | jobs: 10 | windows-build: 11 | runs-on: windows-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Use Node.js 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 22.x 18 | - name: Print versions 19 | shell: pwsh 20 | run: | 21 | $PSVersionTable 22 | Get-ComputerInfo | Select-Object WindowsProductName, WindowsVersion, OsHardwareAbstractionLayer 23 | - name: Install dependencies 24 | shell: pwsh 25 | run: npm install 26 | - name: Run tests 27 | shell: pwsh 28 | run: npm run test 29 | - name: Store snapshots from tests 30 | if: failure() 31 | uses: actions/upload-artifact@v4 32 | with: 33 | name: snapshots-windows 34 | path: ${{ github.workspace }}/src/__snapshots__/ 35 | - name: Run lint 36 | shell: pwsh 37 | run: npm run lint 38 | - name: Run format 39 | shell: pwsh 40 | run: npm run format 41 | - name: Make sure docs can be build 42 | shell: pwsh 43 | run: npm run build:docs 44 | - name: Run jest smoke tests 45 | shell: pwsh 46 | run: npm run test:jest 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_STORE 3 | npm-debug.log* 4 | lib 5 | coverage 6 | *.tgz 7 | -------------------------------------------------------------------------------- /.ncurc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configure which packages to ignore version upgrades. 3 | */ 4 | const ignoredPackages = []; 5 | 6 | /** 7 | * Configure which packages to ignore for major version upgrades. 8 | * Type: { [packageName]: reason }, i.e. { '@storybook/react': 'some reason' } 9 | */ 10 | const ignoreMajorVersions = { 11 | }; 12 | 13 | module.exports = { 14 | upgrade: true, 15 | reject: ignoredPackages, 16 | packageManager: 'npm', 17 | /** Custom target that performs minor upgrades for selected packages. 18 | @param dependencyName The name of the dependency. 19 | @param parsedVersion A parsed Semver object from semver-utils. 20 | (See https://git.coolaj86.com/coolaj86/semver-utils.js#semverutils-parse-semverstring) 21 | @returns 'latest' | 'newest' | 'greatest' | 'minor' | 'patch' 22 | */ 23 | target: (dependencyName, parsedVersion) => { 24 | const ignored = ignoreMajorVersions[dependencyName] 25 | if (ignored !== undefined) { 26 | const res = 'minor'; 27 | console.log(`\n👀 ️${dependencyName} is pinned to ${res}. Reason: ${ignored}`); 28 | return res; 29 | } 30 | return 'latest'; 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | .vscode 3 | bin 4 | docs 5 | examples 6 | coverage 7 | lib/*.spec.* 8 | src 9 | .ncurc.js 10 | .nvmrc 11 | .prettierrc.json 12 | eslint.config.mjs 13 | tsconfig.json 14 | *.tgz -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.18.1 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "typescript.tsdk": "node_modules/typescript/lib", 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit" 6 | }, 7 | "cSpell.words": [ 8 | "pdfs" 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.15.0 / Unreleased 4 | 5 | - [PR#90](https://github.com/moshensky/pdf-visual-diff/pull/90) chore: update 6 | `pdfjs-dist` to `v4.10.38`. This is considered a **BREAKING CHANGE** 7 | due to potential minor changes to generated snapshots. 8 | 9 | ## 0.14.0 / 2024-12-06 10 | 11 | ### :tada: Enhancements 12 | 13 | - [PR#85](https://github.com/moshensky/pdf-visual-diff/pull/85) chore: update `pdfjs-dist` to latest `v4.9.155`. This is a **BREAKING CHANGE**: 14 | - The minimum supported Node.js version is now v20.18.1. 15 | - `pdfjs-dist` introduces a major change in how PDFs are rasterized by replacing `node/canvas` with `@napi-rs/canvas`. This is a positive improvement because the latter has zero dependecines. However, you should expect to update all your existing snapshots. 16 | 17 | ## 0.13.0 / 2024-11-27 18 | 19 | ### 🐛 Bug Fix 20 | 21 | - [PR#82](https://github.com/moshensky/pdf-visual-diff/pull/82) fix: error due to pdfjs-dist update and fix new vulnerabilities by updating dependecies 22 | [pdfjs-dist v4.7.76](https://github.com/mozilla/pdf.js/releases/tag/v4.7.76) introduced changes to CanvasFactory that fail with: 23 | 24 | TypeError: Image or Canvas expected 25 | 26 | ```sh 27 | at drawImageAtIntegerCoords (node_modules/pdfjs-dist/legacy/build/webpack:/pdf.js/src/display/canvas.js:261:9) 28 | at CanvasGraphics.paintInlineImageXObject (node_modules/pdfjs-dist/legacy/build/webpack:/pdf.js/src/display/canvas.js:2990:5) 29 | at CanvasGraphics.apply (node_modules/pdfjs-dist/legacy/build/webpack:/pdf.js/src/display/canvas.js:2879:10) 30 | at CanvasGraphics.executeOperatorList (node_modules/pdfjs-dist/legacy/build/webpack:/pdf.js/src/display/canvas.js:967:20) 31 | at InternalRenderTask._next (node_modules/pdfjs-dist/legacy/build/webpack:/pdf.js/src/display/api.js:3486:37) 32 | ``` 33 | 34 | - [PR#76](https://github.com/moshensky/pdf-visual-diff/pull/76) fix: remove new snapshot when existing snapshot matches 35 | 36 | ## 0.12.0 / 2024-09-26 37 | 38 | ### :tada: Enhancements 39 | 40 | - [PR#70](https://github.com/moshensky/pdf-visual-diff/pull/70): feat: add failOnMissingSnapshot to `comparePdfToSnapshot` options. If no snapshot exists: 41 | - If `failOnMissingSnapshot` is `false` (default), the PDF is converted to an image, 42 | saved as a new snapshot, and the function returns `true`. 43 | - If `failOnMissingSnapshot` is `true`, the function returns `false` without creating a new snapshot. 44 | 45 | ## 0.11.1 / 2024-09-11 46 | 47 | - [PR#68](https://github.com/moshensky/pdf-visual-diff/pull/68): Fix when running in jest context. 48 | 49 | ## 0.11.0 / 2024-09-11 50 | 51 | - [PR#67](https://github.com/moshensky/pdf-visual-diff/pull/67): Dependencies update. **BREAKING CHANGE**: Minimum supported **Node.js v18**. 52 | Notably, the update of `pdfjs-dist` to **v4** (`^4.6.82`) from v3 introduces significant changes. As a result, this release is a **BREAKING CHANGE**: 53 | - Due to the update in `pdfjs-dist`, the minimum supported Node.js version is now 18. 54 | - If you were using a version of `pdfjs-dist` lower than [v3.7.107](https://github.com/mozilla/pdf.js/releases/tag/v3.7.107), your snapshots might start to fail due to changes in how fonts are loaded and used in certain circumstances. 55 | 56 | For the time being, this release has 0 vulnerabilities according to `npm audit`. 57 | 58 | ## 0.10.0 / 2024-09-06 59 | 60 | - [#58](https://github.com/moshensky/pdf-visual-diff/issues/58): Expose option to set rendering DPI. 61 | - [PR#65](https://github.com/moshensky/pdf-visual-diff/pull/65): Add [API documentation](https://moshensky.github.io/pdf-visual-diff/). 62 | 63 | ## 0.9.0 / 2023-09-04 64 | 65 | - [PR#54](https://github.com/moshensky/pdf-visual-diff/pull/54): Export MaskRegions type. 66 | 67 | ## 0.8.0 / 2023-04-22 68 | 69 | - [PR#52](https://github.com/moshensky/pdf-visual-diff/pull/52): Dependencies update. **BREAKING CHANGE** minimum supported **node v16**. 70 | 71 | ### :tada: Enhancements 72 | 73 | - [#51](https://github.com/moshensky/pdf-visual-diff/issues/51): Enable mask regions for multi page pdfs. It is possible to have different mask regions per each page. This is an api **BREAKING CHANGE**. 74 | 75 | If you haven't used `maskRegions` then you don't have to change anything. 76 | `maskRegions` is changed from `ReadonlyArray` to `(page: number) => ReadonlyArray`. Straight forward code update could be: 77 | 78 | ```ts 79 | // Change options from: 80 | const opts = { 81 | maskRegions: [ 82 | // Your mask definitions... 83 | ] 84 | } 85 | 86 | // To 87 | const opts = { 88 | // Here one can use `page` parameter to provide different mask regions for every page 89 | maskRegions: (page) => [ 90 | // Your mask definitions... 91 | ] 92 | } 93 | 94 | const opts = { 95 | maskRegions: () => [blueMask, greenMask] 96 | } 97 | 98 | comparePdfToSnapshot( singlePagePdfPath, __dirname, 'mask-rectangle-masks', opts) 99 | ``` 100 | 101 | ## 0.7.1 / 2023-02-23 102 | 103 | ### 🐛 Bug Fix 104 | 105 | - [#50](https://github.com/moshensky/pdf-visual-diff/pull/50): Fixed pdfjs cmaps path resolution 106 | 107 | ## 0.7.0 / 2023-01-18 108 | 109 | - **BREAKING CHANGE** due to dependencies update. Minimum supported node 14. Some image diffs might occur as well. 110 | - [#48](https://github.com/moshensky/pdf-visual-diff/issues/48): Crash on Nodejs18 111 | 112 | ## 0.6.0 / 2022-05-16 113 | 114 | ### 🐛 Bug Fix 115 | 116 | - [#40](https://github.com/moshensky/pdf-visual-diff/pull/40): masked areas not in initial file 117 | 118 | ### :tada: Enhancements 119 | 120 | - Graphicsmagick is not needed any more, but this is a **BREAKING CHANGE** that requires all snapshots to be regenerated. Please see tools section from README.md for quick approval of new snapshots 121 | - Added cli tools to approve and discard snapshots in bulk 122 | 123 | ## 0.5.0 / 2020-10-31 124 | 125 | ### :tada: Enhancements 126 | 127 | - add compare image options to the custom jest matcher 128 | 129 | ## 0.4.0 / 2020-10-31 130 | 131 | ### :tada: Enhancements 132 | 133 | - [#15](https://github.com/moshensky/pdf-visual-diff/pull/15): Exclude regions from diff 134 | 135 | ## 0.3.0 / 2020-09-12 136 | 137 | ### :tada: Enhancements 138 | 139 | - Add custom jest matcher 140 | 141 | ## 0.2.1 / 2020-09-11 142 | 143 | - Fix package publish 144 | - Fix highlight color 145 | 146 | ## 0.2.0 / 2020-09-11 147 | 148 | ### :tada: Enhancements 149 | 150 | - [#6](https://github.com/moshensky/pdf-visual-diff/pull/6): Allow configuration of compare-images 151 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Nikita Moshensky 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Test Visual Regression in PDFs 2 | 3 | `pdf-visual-diff` is a library for testing visual regressions in PDFs. It uses [pdf.js](https://github.com/mozilla/pdf.js) to convert PDFs into PNGs and [jimp](https://github.com/oliver-moran/jimp) for image comparisons. 4 | 5 | > [API Documentation](https://moshensky.github.io/pdf-visual-diff) 6 | 7 | ## Installation 8 | 9 | ```sh 10 | npm install -D pdf-visual-diff 11 | ``` 12 | 13 | ## Description 14 | 15 | This package exports [comparePdfToSnapshot](https://moshensky.github.io/pdf-visual-diff/functions/comparePdfToSnapshot.html) function, with the following signature: 16 | 17 | ```ts 18 | function comparePdfToSnapshot( 19 | pdf: string | Buffer, 20 | snapshotDir: string, 21 | snapshotName: string, 22 | options?: CompareOptions 23 | ): Promise 24 | ``` 25 | 26 | Compares a PDF to a persisted snapshot, with behavior for handling missing snapshots controlled by the `failOnMissingSnapshot` option. 27 | 28 | The function has the following **side effects**: 29 | 30 | - If no snapshot exists: 31 | - If `failOnMissingSnapshot` is `false` (default), the PDF is converted to an image, saved as a new snapshot, and the function returns `true`. 32 | - If `failOnMissingSnapshot` is `true`, the function returns `false` without creating a new snapshot. 33 | - If a snapshot exists, the PDF is converted to an image and compared to the snapshot: 34 | - If they differ, the function returns `false` and creates two additional images next to the snapshot: one with the suffix `new` (the current view of the PDF as an image) and one with the suffix `diff` (showing the difference between the snapshot and the `new` image). 35 | - If they are equal, the function returns `true`. If `new` and `diff` versions are present, they are deleted. 36 | 37 | Returns a promise that resolves to `true` if the PDF matches the snapshot or if the behavior for a missing snapshot is configured to allow it. Returns `false` if the PDF differs from the snapshot or if `failOnMissingSnapshot` is `true` and the snapshot is missing. 38 | 39 | For further details and [configuration options](https://moshensky.github.io/pdf-visual-diff/types/CompareOptions.html), please refer to the [API Documentation](https://moshensky.github.io/pdf-visual-diff). 40 | 41 | > There may be differences between the images generated from the same PDF on different operating systems. 42 | 43 | ## Sample usage 44 | 45 | > **Note:** You can find sample projects in the [examples](https://github.com/moshensky/pdf-visual-diff/tree/master/examples) folder. 46 | 47 | Write a test file: 48 | 49 | ```js 50 | import { comparePdfToSnapshot } from 'pdf-visual-diff' 51 | import { expect } from 'chai' 52 | 53 | describe('test PDF report visual regression', () => { 54 | const pathToPdf = 'path to your PDF' // or you might pass a Buffer instead 55 | it('should pass', () => 56 | comparePdfToSnapshot(pathToPdf, __dirname, 'my-awesome-report').then( 57 | (x) => expect(x).to.be.true, 58 | )) 59 | }) 60 | 61 | // Example with masking regions of a two-page PDF 62 | describe('PDF masking', () => { 63 | it('should mask two-page PDF', () => { 64 | const blueMask: RegionMask = { 65 | type: 'rectangle-mask', 66 | x: 50, 67 | y: 75, 68 | width: 140, 69 | height: 100, 70 | color: 'Blue', 71 | } 72 | const greenMask: RegionMask = { 73 | type: 'rectangle-mask', 74 | x: 110, 75 | y: 200, 76 | width: 90, 77 | height: 50, 78 | color: 'Green', 79 | } 80 | 81 | comparePdfToSnapshot(twoPagePdfPath, __dirname, 'different-mask-per-page', { 82 | maskRegions: (page) => { 83 | switch (page) { 84 | case 1: 85 | return [blueMask] 86 | case 2: 87 | return [greenMask] 88 | default: 89 | return [] 90 | } 91 | }, 92 | }).then((x) => expect(x).to.be.true)) 93 | }) 94 | }) 95 | 96 | ``` 97 | 98 | ## Tools 99 | 100 | `pdf-visual-diff` provides a CLI for approving or discarding new PDF snapshots. The CLI can be used via `npx` or `npm` by updating the `scripts` section of your `package.json`: 101 | 102 | ```json 103 | "scripts": { 104 | "test:pdf-approve": "pdf-visual-diff approve", 105 | "test:pdf-discard": "pdf-visual-diff discard" 106 | } 107 | ``` 108 | 109 | To approve new snapshots, run the following command in your terminal: 110 | 111 | ```sh 112 | npm run test:pdf-approve 113 | ``` 114 | 115 | Paths for the new snapshots will be listed. You will then be prompted to confirm whether you want to replace the old snapshots with the new ones: 116 | 117 | ```sh 118 | New snapshots: 119 | ./__snapshots__/test_doc_1.new.png 120 | ./__snapshots__/single-page-snapshot.new.png 121 | Are you sure you want to overwrite current snapshots? [Y/n]: 122 | ``` 123 | 124 | These commands can be customized by specifying a custom path and snapshots folder name. 125 | 126 | Approve command help: 127 | 128 | ```sh 129 | npx pdf-visual-diff approve --help 130 | 131 | Approve new snapshots 132 | 133 | Options: 134 | --help Show help [boolean] 135 | --version Show version number [boolean] 136 | -p, --path [default: "."] 137 | -s, --snapshots-dir-name [default: "__snapshots__"] 138 | ``` 139 | 140 | Discard command help: 141 | 142 | ```sh 143 | npx pdf-visual-diff discard --help 144 | 145 | Discard new snapshots and diffs 146 | 147 | Options: 148 | --help Show help [boolean] 149 | --version Show version number [boolean] 150 | -p, --path [default: "."] 151 | -s, --snapshots-dir-name [default: "__snapshots__"] 152 | ``` 153 | 154 | ## Usage with Jest 155 | 156 | This packages provides a custom Jest matcher `toMatchPdfSnapshot`. 157 | 158 | ### Setup 159 | 160 | ```json 161 | "jest": { 162 | "setupFilesAfterEnv": ["pdf-visual-diff/lib/toMatchPdfSnapshot"] 163 | } 164 | ``` 165 | 166 | If you are using **TypeScript** add `import('pdf-visual-diff/lib/toMatchPdfSnapshot')` to your typings. 167 | 168 | ### Usage 169 | 170 | In your tests, pass a path to the PDF or PDF content a Buffer. 171 | 172 | ```ts 173 | const pathToPdf = 'path to your PDF' // or you might pass a Buffer instead 174 | describe('test PDF report visual regression', () => { 175 | it('should match', () => expect(pathToPdf).toMatchPdfSnapshot()) 176 | }) 177 | ``` 178 | 179 | As you can see, there is no need to manage directories or names manually. The necessary information is extracted from the Jest context. 180 | -------------------------------------------------------------------------------- /bin/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | usage() 6 | { 7 | echo "usage: publish [[[--set-version X.X.X ] [-i]] | [-h]]" 8 | } 9 | 10 | new_version= 11 | 12 | while [ "$1" != "" ]; do 13 | case $1 in 14 | --set-version ) shift 15 | new_version=$1 16 | ;; 17 | -h | --help ) usage 18 | exit 19 | ;; 20 | * ) usage 21 | exit 1 22 | esac 23 | shift 24 | done 25 | 26 | if [ -z "$new_version" ]; then 27 | usage 28 | exit 1 29 | fi 30 | 31 | if [ "$(git branch --show-current)" != "master" ]; then 32 | echo "Git branch is NOT master!" 33 | exit 1 34 | fi 35 | 36 | if [ ! -z "$(git status --porcelain)" ]; then 37 | echo "Git working tree is NOT clean!" 38 | exit 1 39 | fi 40 | 41 | # Update version in package.json and package-lock.json 42 | npm version $new_version --no-git-tag-version 43 | 44 | rm -rf node_modules 45 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" 46 | nvm use 47 | npm ci 48 | npm run build 49 | npm run build:docs 50 | npm pack 51 | npm publish 52 | 53 | git add . -A 54 | git commit -m 'publish '${new_version} 55 | git tag $new_version 56 | git push 57 | 58 | echo "Success" 59 | exit 0 60 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /docs/assets/hierarchy.js: -------------------------------------------------------------------------------- 1 | window.hierarchyData = "data:application/octet-stream;base64,H4sIAAAAAAAAA6tWKsrPLylWsoqO1VEqSk3LSU0uyczPK1ayqq6tBQAWeT+5HQAAAA==" -------------------------------------------------------------------------------- /docs/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-hl-0: #795E26; 3 | --dark-hl-0: #DCDCAA; 4 | --light-hl-1: #000000; 5 | --dark-hl-1: #D4D4D4; 6 | --light-hl-2: #A31515; 7 | --dark-hl-2: #CE9178; 8 | --light-hl-3: #0000FF; 9 | --dark-hl-3: #569CD6; 10 | --light-hl-4: #001080; 11 | --dark-hl-4: #9CDCFE; 12 | --light-hl-5: #267F99; 13 | --dark-hl-5: #4EC9B0; 14 | --light-hl-6: #AF00DB; 15 | --dark-hl-6: #C586C0; 16 | --light-hl-7: #0070C1; 17 | --dark-hl-7: #4FC1FF; 18 | --light-hl-8: #008000; 19 | --dark-hl-8: #6A9955; 20 | --light-hl-9: #098658; 21 | --dark-hl-9: #B5CEA8; 22 | --light-hl-10: #0451A5; 23 | --dark-hl-10: #9CDCFE; 24 | --light-code-background: #FFFFFF; 25 | --dark-code-background: #1E1E1E; 26 | } 27 | 28 | @media (prefers-color-scheme: light) { :root { 29 | --hl-0: var(--light-hl-0); 30 | --hl-1: var(--light-hl-1); 31 | --hl-2: var(--light-hl-2); 32 | --hl-3: var(--light-hl-3); 33 | --hl-4: var(--light-hl-4); 34 | --hl-5: var(--light-hl-5); 35 | --hl-6: var(--light-hl-6); 36 | --hl-7: var(--light-hl-7); 37 | --hl-8: var(--light-hl-8); 38 | --hl-9: var(--light-hl-9); 39 | --hl-10: var(--light-hl-10); 40 | --code-background: var(--light-code-background); 41 | } } 42 | 43 | @media (prefers-color-scheme: dark) { :root { 44 | --hl-0: var(--dark-hl-0); 45 | --hl-1: var(--dark-hl-1); 46 | --hl-2: var(--dark-hl-2); 47 | --hl-3: var(--dark-hl-3); 48 | --hl-4: var(--dark-hl-4); 49 | --hl-5: var(--dark-hl-5); 50 | --hl-6: var(--dark-hl-6); 51 | --hl-7: var(--dark-hl-7); 52 | --hl-8: var(--dark-hl-8); 53 | --hl-9: var(--dark-hl-9); 54 | --hl-10: var(--dark-hl-10); 55 | --code-background: var(--dark-code-background); 56 | } } 57 | 58 | :root[data-theme='light'] { 59 | --hl-0: var(--light-hl-0); 60 | --hl-1: var(--light-hl-1); 61 | --hl-2: var(--light-hl-2); 62 | --hl-3: var(--light-hl-3); 63 | --hl-4: var(--light-hl-4); 64 | --hl-5: var(--light-hl-5); 65 | --hl-6: var(--light-hl-6); 66 | --hl-7: var(--light-hl-7); 67 | --hl-8: var(--light-hl-8); 68 | --hl-9: var(--light-hl-9); 69 | --hl-10: var(--light-hl-10); 70 | --code-background: var(--light-code-background); 71 | } 72 | 73 | :root[data-theme='dark'] { 74 | --hl-0: var(--dark-hl-0); 75 | --hl-1: var(--dark-hl-1); 76 | --hl-2: var(--dark-hl-2); 77 | --hl-3: var(--dark-hl-3); 78 | --hl-4: var(--dark-hl-4); 79 | --hl-5: var(--dark-hl-5); 80 | --hl-6: var(--dark-hl-6); 81 | --hl-7: var(--dark-hl-7); 82 | --hl-8: var(--dark-hl-8); 83 | --hl-9: var(--dark-hl-9); 84 | --hl-10: var(--dark-hl-10); 85 | --code-background: var(--dark-code-background); 86 | } 87 | 88 | .hl-0 { color: var(--hl-0); } 89 | .hl-1 { color: var(--hl-1); } 90 | .hl-2 { color: var(--hl-2); } 91 | .hl-3 { color: var(--hl-3); } 92 | .hl-4 { color: var(--hl-4); } 93 | .hl-5 { color: var(--hl-5); } 94 | .hl-6 { color: var(--hl-6); } 95 | .hl-7 { color: var(--hl-7); } 96 | .hl-8 { color: var(--hl-8); } 97 | .hl-9 { color: var(--hl-9); } 98 | .hl-10 { color: var(--hl-10); } 99 | pre, code { background: var(--code-background); } 100 | -------------------------------------------------------------------------------- /docs/assets/icons.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | addIcons(); 3 | function addIcons() { 4 | if (document.readyState === "loading") return document.addEventListener("DOMContentLoaded", addIcons); 5 | const svg = document.body.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "svg")); 6 | svg.innerHTML = `MMNEPVFCICPMFPCPTTAAATR`; 7 | svg.style.display = "none"; 8 | if (location.protocol === "file:") updateUseElements(); 9 | } 10 | 11 | function updateUseElements() { 12 | document.querySelectorAll("use").forEach(el => { 13 | if (el.getAttribute("href").includes("#icon-")) { 14 | el.setAttribute("href", el.getAttribute("href").replace(/.*#/, "#")); 15 | } 16 | }); 17 | } 18 | })() -------------------------------------------------------------------------------- /docs/assets/icons.svg: -------------------------------------------------------------------------------- 1 | MMNEPVFCICPMFPCPTTAAATR -------------------------------------------------------------------------------- /docs/assets/navigation.js: -------------------------------------------------------------------------------- 1 | window.navigationData = "data:application/octet-stream;base64,H4sIAAAAAAAAA4XROwvCMBQF4P9y52JVfHZtBxexVDdxCDVNQtMkNLegSP+7tOIjpZL53HycQ84PQHpDiCAxAgIwBDlEQFVT2TAxYsKxkhBAKdQVok0bfO5jXRlS04NBoZX9PsW7oTZ0U1eZT7fr2XL+Y+0E41IwjrGWuh5abuqz9sSWGWVjpX4in5Jei5NOFfszbxD7tIzmSBSTtGswtJzQL3X1x5l34jPy19/0G46KGMs1frWiUXk/Khy7c+3Vor08AR+bhQlCAgAA" -------------------------------------------------------------------------------- /docs/assets/search.js: -------------------------------------------------------------------------------- 1 | window.searchData = "data:application/octet-stream;base64,H4sIAAAAAAAAA62XS2+jMBSF/8vt1kpyeQa27WJGmqpVZzQbFFUInAQVDALSzijKfx8ZUoyxaZxpV4mwz3cP9vGDI9TlWwNhdISXjKUQeg4BFhcUQkjKoopr+phuf5U/WVw1+7IFAoc6hxC2B5a0Wcmapa7bYt8WORBI8rhpaAMhwIm8l7BWgY+uNdS5j5uXJ7rjsAHf/q1osxy1fEj0XNf2Bt7zM1dfQN0MvUZEAvxNWDsxNW+978H7TsqJhquG4okmbcx2OdUiR21XUW/7GXqoWs0Yy42fHWYN7dJIT9yJcriyRBjbMqd1zBLzgmOFSeXF2eeMgWI2pfMWuKYeNF9gokq31iPbGc/k2QeXVWxXDrIvsLKNs/yB3WdNk7Gdsj1ccsTVJSt6dSPU/2dsGvhv2W6fZ7t9e1vmZT0xJTdetZC6PW52/Cetn11KOtyltTQ1ODN3aZWZF+s7G9VTZ2Y91Lwb1aTsUDTLuyr7cIxQDNCP8m1GfJN3TXp7vKaWxzMwB9z3bR8QNwQyltI/EB7hldZNVjIIwVrYiwAIbDOap/xI7YsRfo4WXE0gLZND93dz7vabJm1Z88597+UKSLQilruwg82GRO/a7nn34B0hnnQ6BBIhsVYLa42SEBUhSkILSGQR9BYB+pLQUoSWJLSBRLbGqa3obEnnAIkcjc5RdI6kc4FErkbnKjpX0nlzL+gpQk8S+kAiT1PQV3S+pFvPTcVaEa4lYQAk8jUFA0UXyHPPo7DWCFFNDU5iw8MQ6KSa3MjBQR4H1GUV1eignB205yYF1fignB/ksUB91tUMoRwidOfFapBQThLyfKCle2M1SyiHCXlGULdeUM3T+VG3zbzSuqXp9367iaLhqDjC83kPGo6nI1gQHk8EvP4Hbf57EhtQ93TYg3jb2Qjf3obLgSC7guxeg6rSbVuKQ10AVwK4MgJ2Z4/QIwoAOue3NLM2c+EQ7PUIbWauPyhG7uwRwjdGdHeRpL+oCFgwYqERqzsIR25GuUDPiCBdV0ekEahXro1w01unIPqCGJii2lLPwlGo0DKi1e9fUkX3lSVYjkA5hiQ+WlPMKAdmS3D0qSIonqAYhGlDoMoqmmeMQhhtTqd/4sPgHdoPAAA="; -------------------------------------------------------------------------------- /docs/enums/Dpi.html: -------------------------------------------------------------------------------- 1 | Dpi | pdf-visual-diff

Enumeration Dpi

Enum representing predefined DPI (Dots Per Inch) values.

2 |

Enumeration Members

High 3 | Low 4 |

Enumeration Members

High: 144
Low: 72
5 | -------------------------------------------------------------------------------- /docs/functions/comparePdfToSnapshot.html: -------------------------------------------------------------------------------- 1 | comparePdfToSnapshot | pdf-visual-diff

Function comparePdfToSnapshot

  • Compares a PDF to a persisted snapshot, with behavior for handling missing snapshots 2 | controlled by the failOnMissingSnapshot option.

    3 |

    Parameters

    • pdf: string | Buffer<ArrayBufferLike>

      Path to the PDF file or a Buffer containing the PDF.

      4 |
    • snapshotDir: string

      Path to the directory where the __snapshots__ folder will be created.

      5 |
    • snapshotName: string

      Unique name for the snapshot within the specified path.

      6 |
    • Optionaloptions: CompareOptions

      Options for comparison, including tolerance, mask regions, and behavior 7 | regarding missing snapshots. See CompareOptions for more details.

      8 |

    Returns Promise<boolean>

    A promise that resolves to true if the PDF matches the snapshot or if the behavior 9 | allows for missing snapshots. Resolves to false if the PDF differs from the snapshot 10 | or if failOnMissingSnapshot is true and no snapshot exists.

    11 |

    The function has the following side effects:

    12 |
      13 |
    • If no snapshot exists: 14 |
        15 |
      • If failOnMissingSnapshot is false (default), the PDF is converted to an image, 16 | saved as a new snapshot, and the function returns true.
      • 17 |
      • If failOnMissingSnapshot is true, the function returns false without creating a new snapshot.
      • 18 |
      19 |
    • 20 |
    • If a snapshot exists, the PDF is converted to an image and compared to the snapshot: 21 |
        22 |
      • If they differ, the function returns false and creates two additional images 23 | next to the snapshot: one with the suffix new (the current view of the PDF as an image) 24 | and one with the suffix diff (showing the difference between the snapshot and the new image).
      • 25 |
      • If they are equal, the function returns true. If new and diff versions are present, they are deleted.
      • 26 |
      27 |
    • 28 |
    29 |
30 | -------------------------------------------------------------------------------- /docs/modules.html: -------------------------------------------------------------------------------- 1 | pdf-visual-diff
2 | -------------------------------------------------------------------------------- /docs/types/CompareOptions.html: -------------------------------------------------------------------------------- 1 | CompareOptions | pdf-visual-diff

Type Alias CompareOptions

CompareOptions: {
    failOnMissingSnapshot?: boolean;
    maskRegions?: MaskRegions;
    pdf2PngOptions?: PdfToPngOptions;
    tolerance?: number;
}

The options type for comparePdfToSnapshot.

2 |

Type declaration

  • OptionalfailOnMissingSnapshot?: boolean

    Whether a missing snapshot should cause the comparison to fail.

    3 |
    false
     4 | 
    5 | 6 |
  • OptionalmaskRegions?: MaskRegions

    Defines a function for masking predefined regions per page, useful for 7 | parts of the PDF that change between tests.

    8 |
  • Optionalpdf2PngOptions?: PdfToPngOptions

    Configuration options for converting a PDF to PNG format.

    9 |
  • Optionaltolerance?: number

    Number value for error tolerance in the range [0, 1].

    10 |
    0
    11 | 
    12 | 13 |
14 | -------------------------------------------------------------------------------- /docs/types/HighlightColor.html: -------------------------------------------------------------------------------- 1 | HighlightColor | pdf-visual-diff

Type Alias HighlightColor

HighlightColor:
    | "Red"
    | "Green"
    | "Blue"
    | "White"
    | "Cyan"
    | "Magenta"
    | "Yellow"
    | "Black"
    | "Gray"

Represents the available colors for highlighting.

2 |
3 | -------------------------------------------------------------------------------- /docs/types/MaskRegions.html: -------------------------------------------------------------------------------- 1 | MaskRegions | pdf-visual-diff

Type Alias MaskRegions

MaskRegions: (page: number) => ReadonlyArray<RegionMask> | undefined

Defines a function for masking predefined regions per page, useful for 2 | parts of the PDF that change between tests.

3 |

Type declaration

    • (page: number): ReadonlyArray<RegionMask> | undefined
    • Parameters

      • page: number

        The page number of the PDF.

        4 |

      Returns ReadonlyArray<RegionMask> | undefined

      An array of region masks for the specified page, or undefined if no masks are defined.

      5 |
6 | -------------------------------------------------------------------------------- /docs/types/PdfToPngOptions.html: -------------------------------------------------------------------------------- 1 | PdfToPngOptions | pdf-visual-diff

Type Alias PdfToPngOptions

PdfToPngOptions: { dpi?: Dpi | number }

Configuration options for converting a PDF to PNG format.

2 |

Type declaration

  • Optionaldpi?: Dpi | number

    The DPI value used to calculate image resolution.

    3 |
      4 |
    • Use Dpi.Low for the default PDF viewport size at 72 DPI. This option generates an image with lower resolution, resulting in lesser quality but faster processing time. At this setting, one PDF point corresponds to one pixel.
    • 5 |
    • Use Dpi.High for twice the default PDF viewport size at 144 DPI. This option provides better image quality at the cost of longer processing time. At this setting, one PDF point corresponds to two pixels.
    • 6 |
    • You can also provide a custom DPI value as a number.
    • 7 |
    8 |
    Dpi.High
     9 | 
    10 | 11 |
12 | -------------------------------------------------------------------------------- /docs/types/RectangleMask.html: -------------------------------------------------------------------------------- 1 | RectangleMask | pdf-visual-diff

Type Alias RectangleMask

RectangleMask: Readonly<
    {
        color: HighlightColor;
        height: number;
        type: "rectangle-mask";
        width: number;
        x: number;
        y: number;
    },
>

Represents a rectangular mask applied at the PNG level, i.e., after the 2 | conversion of the PDF to an image.

3 |

The values provided for x, y, width, and height are expected to be in 4 | pixels and based on the generated image by the library. 5 | The origin (0,0) of the PNG's coordinate system is the top-left corner of the 6 | image.

7 |
8 | -------------------------------------------------------------------------------- /docs/types/RegionMask.html: -------------------------------------------------------------------------------- 1 | RegionMask | pdf-visual-diff
2 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js' 4 | import tseslint from 'typescript-eslint' 5 | import tsdoceslint from 'eslint-plugin-tsdoc' 6 | 7 | export default tseslint.config({ 8 | files: ['**/*.ts'], 9 | extends: [eslint.configs.recommended, ...tseslint.configs.strict, ...tseslint.configs.stylistic], 10 | plugins: { 11 | tsdoc: tsdoceslint, 12 | }, 13 | rules: { 14 | 'tsdoc/syntax': 'error', 15 | '@typescript-eslint/array-type': 'off', 16 | '@typescript-eslint/consistent-type-definitions': 'off', 17 | }, 18 | }) 19 | -------------------------------------------------------------------------------- /examples/cjs_sample/.gitignore: -------------------------------------------------------------------------------- 1 | __snapshots__ -------------------------------------------------------------------------------- /examples/cjs_sample/.nvmrc: -------------------------------------------------------------------------------- 1 | v22.12.0 2 | -------------------------------------------------------------------------------- /examples/cjs_sample/README.md: -------------------------------------------------------------------------------- 1 | # Example Usage of `pdf-visual-diff` Library 2 | 3 | This example demonstrates how to use the `comparePdfToSnapshot` function from the `pdf-visual-diff` library. For simplicity, this example does not include any optional parameters. 4 | 5 | ## Installation 6 | 7 | ```sh 8 | nvm use 9 | npm install 10 | ``` 11 | 12 | ## How to Run 13 | 14 | 1. Open a terminal and navigate to the directory containing this README file. 15 | 2. Ensure that the `__snapshots__` directory does not exist. 16 | 17 | ### First Run 18 | 19 | Run the following command in the terminal: 20 | 21 | ```sh 22 | node index.cjs 23 | ``` 24 | 25 | After the first run, a `__snapshots__` directory will be created with a snapshot `png` file of the PDF. 26 | 27 | ### Second Run 28 | 29 | Run the command again: 30 | 31 | ```sh 32 | node index.cjs 33 | ``` 34 | 35 | Since the snapshot already exists, the PDF will be compared to it. The program output should be: 36 | 37 | ```sh 38 | Is pdf equal to its snapshot? Answer: true 39 | ``` 40 | 41 | ### Testing with Updated Pdf 42 | 43 | 1. Open `index.js` and comment out the line: 44 | 45 | ```js 46 | `const pathToPdf = join(__dirname, 'pdf', 'test_doc_1.pdf')` 47 | ``` 48 | 49 | 2. Uncomment the following line: 50 | 51 | ```js 52 | const pathToPdf = join(__dirname, 'pdf', 'test_doc_1_changed.pdf') 53 | ``` 54 | 55 | Run the command again: 56 | 57 | ```sh 58 | node index.cjs 59 | ``` 60 | 61 | Since the snapshot already exists, the PDF will be compared to it. The program output should be: 62 | 63 | ```sh 64 | Is pdf equal to it's snapshot? Answer: false` 65 | ``` 66 | 67 | The comparison will fail. Open the `__snapshots__` directory. It will contain `.diff` and `.new` PNG files. 68 | 69 | If you want to make new snapshot the current one, run: 70 | 71 | ```sh 72 | npx pdf-visual-diff approve 73 | ``` 74 | 75 | After confirming the command, you will see that only the new snapshot is left in the directory as the current one. 76 | -------------------------------------------------------------------------------- /examples/cjs_sample/index.cjs: -------------------------------------------------------------------------------- 1 | const { comparePdfToSnapshot } = require('pdf-visual-diff') 2 | const { join } = require('path') 3 | 4 | const pathToPdf = join(__dirname, 'pdf', 'test_doc_1.pdf') 5 | // const pathToPdf = join(__dirname, 'pdf', 'test_doc_1_changed.pdf') 6 | const snapshotDir = join(__dirname) 7 | const snapshotName = 'single-page-snapshot' 8 | 9 | comparePdfToSnapshot(pathToPdf, snapshotDir, snapshotName) 10 | .then((isEqual) => { 11 | console.log(`Is pdf equal to it's snapshot? Answer: ${isEqual}`) 12 | }) 13 | .catch(console.error) 14 | -------------------------------------------------------------------------------- /examples/cjs_sample/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bare-bones", 3 | "version": "1.0.0", 4 | "description": "bare-bones example how to use pdf-visual-diff lib", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "pdf-visual-diff": "^0.14.0" 14 | }, 15 | "engines": { 16 | "node": ">=20.17.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/cjs_sample/pdf/test_doc_1.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/examples/cjs_sample/pdf/test_doc_1.pdf -------------------------------------------------------------------------------- /examples/cjs_sample/pdf/test_doc_1_changed.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/examples/cjs_sample/pdf/test_doc_1_changed.pdf -------------------------------------------------------------------------------- /examples/esm_jest_and_node_test_runners/.gitignore: -------------------------------------------------------------------------------- 1 | __snapshots__ -------------------------------------------------------------------------------- /examples/esm_jest_and_node_test_runners/.nvmrc: -------------------------------------------------------------------------------- 1 | v22.12.0 2 | -------------------------------------------------------------------------------- /examples/esm_jest_and_node_test_runners/README.md: -------------------------------------------------------------------------------- 1 | # Example Usage of `pdf-visual-diff` Library 2 | 3 | This guide demonstrates how to use the `comparePdfToSnapshot` function from the `pdf-visual-diff` library with both Jest and native Node.js test runners. 4 | 5 | ## Installation 6 | 7 | ```sh 8 | nvm use 9 | npm install 10 | ``` 11 | 12 | ## Running Tests 13 | 14 | ```sh 15 | npm run test 16 | ``` 17 | -------------------------------------------------------------------------------- /examples/esm_jest_and_node_test_runners/jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | /** @type {import('jest').Config} */ 7 | const config = { 8 | setupFilesAfterEnv: ['pdf-visual-diff/lib/toMatchPdfSnapshot'], 9 | } 10 | 11 | export default config 12 | -------------------------------------------------------------------------------- /examples/esm_jest_and_node_test_runners/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esm_jest", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "type": "module", 6 | "scripts": { 7 | "test:jest": "node --experimental-vm-modules node_modules/jest/bin/jest.js ./src/jest_test_runner.test.js", 8 | "test:node-runner": "node --test ./src/node_test_runner.test.js", 9 | "test": "npm run test:jest && npm run test:node-runner" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "description": "", 15 | "dependencies": { 16 | "pdf-visual-diff": "^0.14.0" 17 | }, 18 | "devDependencies": { 19 | "jest": "^29.7.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/esm_jest_and_node_test_runners/pdf/test_doc_1.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/examples/esm_jest_and_node_test_runners/pdf/test_doc_1.pdf -------------------------------------------------------------------------------- /examples/esm_jest_and_node_test_runners/pdf/test_doc_1_changed.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/examples/esm_jest_and_node_test_runners/pdf/test_doc_1_changed.pdf -------------------------------------------------------------------------------- /examples/esm_jest_and_node_test_runners/src/__snapshots__/PDF_visual_regression.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/examples/esm_jest_and_node_test_runners/src/__snapshots__/PDF_visual_regression.png -------------------------------------------------------------------------------- /examples/esm_jest_and_node_test_runners/src/__snapshots__/Snapshot_should_match.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/examples/esm_jest_and_node_test_runners/src/__snapshots__/Snapshot_should_match.png -------------------------------------------------------------------------------- /examples/esm_jest_and_node_test_runners/src/jest_test_runner.test.js: -------------------------------------------------------------------------------- 1 | import { pathToPdf } from './utils.js' 2 | 3 | test( 4 | 'PDF visual regression', 5 | () => expect(pathToPdf).toMatchPdfSnapshot(), 6 | 20_000, 7 | ) 8 | -------------------------------------------------------------------------------- /examples/esm_jest_and_node_test_runners/src/node_test_runner.test.js: -------------------------------------------------------------------------------- 1 | import test from 'node:test' 2 | import assert from 'node:assert' 3 | import { comparePdfToSnapshot } from 'pdf-visual-diff' 4 | import { pathToPdf, pathToChangedPdf, snapshotDir } from './utils.js' 5 | 6 | function mkSnapshotName(testContext) { 7 | return testContext.name.split(' ').join('_') 8 | } 9 | 10 | test('Snapshot should fail', async () => { 11 | const isMatchingPdf = await comparePdfToSnapshot( 12 | pathToChangedPdf, 13 | snapshotDir, 14 | 'PDF_visual_regression', 15 | { 16 | pdf2PngOptions: { dpi: 72 }, 17 | }, 18 | ) 19 | assert.strictEqual(isMatchingPdf, false) 20 | }) 21 | 22 | test('Snapshot should match', async (t) => { 23 | const isMatchingPdf = await comparePdfToSnapshot(pathToPdf, snapshotDir, mkSnapshotName(t)) 24 | assert.strictEqual(isMatchingPdf, true) 25 | }) 26 | -------------------------------------------------------------------------------- /examples/esm_jest_and_node_test_runners/src/utils.js: -------------------------------------------------------------------------------- 1 | import { join, dirname } from 'path' 2 | import { fileURLToPath } from 'url' 3 | 4 | const __filename = fileURLToPath(import.meta.url) 5 | const __dirname = dirname(__filename) 6 | const pdfDir = join(__dirname, '..', 'pdf') 7 | 8 | export const pathToPdf = join(pdfDir, 'test_doc_1.pdf') 9 | export const pathToChangedPdf = join(pdfDir, 'test_doc_1_changed.pdf') 10 | export const snapshotDir = __dirname 11 | -------------------------------------------------------------------------------- /examples/ts-sample/.eslintignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules 3 | # don't lint build output 4 | lib 5 | # don't lint coverage output 6 | coverage -------------------------------------------------------------------------------- /examples/ts-sample/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint', 'prettier'], 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:@typescript-eslint/eslint-recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'plugin:prettier/recommended', 10 | 'prettier/@typescript-eslint', 11 | ], 12 | } 13 | -------------------------------------------------------------------------------- /examples/ts-sample/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_STORE 3 | npm-debug.log* 4 | lib 5 | coverage 6 | *.tgz 7 | -------------------------------------------------------------------------------- /examples/ts-sample/.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension": ["ts"], 3 | "spec": "src/**/*.spec.ts", 4 | "require": "ts-node/register", 5 | "timeout": 10000 6 | } 7 | -------------------------------------------------------------------------------- /examples/ts-sample/.nvmrc: -------------------------------------------------------------------------------- 1 | v22.12.0 2 | -------------------------------------------------------------------------------- /examples/ts-sample/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /examples/ts-sample/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "typescript.tsdk": "node_modules/typescript/lib", 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit" 6 | }, 7 | "cSpell.words": [ 8 | ], 9 | } 10 | -------------------------------------------------------------------------------- /examples/ts-sample/README.md: -------------------------------------------------------------------------------- 1 | # TypeScript Sample 2 | 3 | `mkSamplePdf` is a function that uses [pdfmake](http://pdfmake.org/) to dynamically create PDFs. 4 | It has two tests that use `pdf-visual-diff`: 5 | 6 | - One asserts that a snapshot matches. 7 | - The other asserts that it doesn't match. 8 | 9 | ```sh 10 | nvm use 11 | npm install 12 | npm test 13 | ``` 14 | -------------------------------------------------------------------------------- /examples/ts-sample/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-sample-sing-pdf-visual-diff", 3 | "version": "0.5.0", 4 | "description": "Visual Regression Testing for PDFs in JavaScript", 5 | "scripts": { 6 | "tsc": "tsc --noEmit --pretty", 7 | "tsc:watch": "npm run tsc -- --watch", 8 | "test": "node --require ts-node/register --test src/*.test.ts" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/moshensky/pdf-visual-diff.git" 13 | }, 14 | "keywords": [ 15 | "pdf visual regression testing", 16 | "pdf compare", 17 | "pdf comparison", 18 | "javascript", 19 | "visual diff", 20 | "typescript", 21 | "diff testing", 22 | "js" 23 | ], 24 | "author": "Nikita Moshensky", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/moshensky/pdf-visual-diff/issues" 28 | }, 29 | "homepage": "https://github.com/moshensky/pdf-visual-diff#readme", 30 | "dependencies": { 31 | "pdfmake": "^0.1.68" 32 | }, 33 | "devDependencies": { 34 | "@types/node": "^22.0.0", 35 | "@types/pdfmake": "^0.1.16", 36 | "pdf-visual-diff": "^0.14.0", 37 | "ts-node": "^10.9.2", 38 | "typescript": "^5.6.2" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /examples/ts-sample/src/__snapshots__/dynamically-generated-pdf-with-text.diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/examples/ts-sample/src/__snapshots__/dynamically-generated-pdf-with-text.diff.png -------------------------------------------------------------------------------- /examples/ts-sample/src/__snapshots__/dynamically-generated-pdf-with-text.new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/examples/ts-sample/src/__snapshots__/dynamically-generated-pdf-with-text.new.png -------------------------------------------------------------------------------- /examples/ts-sample/src/__snapshots__/dynamically-generated-pdf-with-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/examples/ts-sample/src/__snapshots__/dynamically-generated-pdf-with-text.png -------------------------------------------------------------------------------- /examples/ts-sample/src/__snapshots__/dynamically-generated-pdf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/examples/ts-sample/src/__snapshots__/dynamically-generated-pdf.png -------------------------------------------------------------------------------- /examples/ts-sample/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import * as assert from 'node:assert/strict' 3 | import { comparePdfToSnapshot } from 'pdf-visual-diff' 4 | import { mkSamplePdf } from './index' 5 | 6 | describe('mkSamplePdf()', () => { 7 | it('should pass', async () => { 8 | const pdf = await mkSamplePdf() 9 | await comparePdfToSnapshot(pdf, __dirname, 'dynamically-generated-pdf').then((x) => 10 | assert.strictEqual(x, true), 11 | ) 12 | }) 13 | 14 | it('should fail, because generated pdf is different from the snapshot', async () => { 15 | const pdf = await mkSamplePdf('this string does not exist in the snapshot') 16 | await comparePdfToSnapshot(pdf, __dirname, 'dynamically-generated-pdf-with-text').then((x) => 17 | assert.strictEqual(x, false), 18 | ) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /examples/ts-sample/src/index.ts: -------------------------------------------------------------------------------- 1 | import pdfmake from 'pdfmake/build/pdfmake' 2 | import pdffonts from 'pdfmake/build/vfs_fonts' 3 | import { TDocumentDefinitions } from 'pdfmake/interfaces' 4 | 5 | pdfmake.vfs = pdffonts.pdfMake.vfs 6 | 7 | /** 8 | * Dynamically create a PDF file. 9 | * 10 | * This is a sample function to demonstrate how `pdf-visual-diff` could be used. 11 | * 12 | * @param text - optional text that will be added to the end of the PDF file. 13 | */ 14 | export const mkSamplePdf = (text?: string): Promise => { 15 | const docDefinition: TDocumentDefinitions = { 16 | content: [ 17 | { 18 | text: 'Paragraphs can also by styled without using named-styles (this one sets fontSize to 25)', 19 | fontSize: 25, 20 | }, 21 | 'Another paragraph, using default style, this time a little bit longer to make sure, this line will be divided into at least two lines\n\n', 22 | { 23 | text: 'This paragraph does not use a named-style and sets fontSize to 8 and italics to true', 24 | fontSize: 8, 25 | italics: true, 26 | }, 27 | '\n\nFor preserving leading spaces use preserveLeadingSpaces property:', 28 | { 29 | text: ' This is a paragraph with preserved leading spaces.', 30 | preserveLeadingSpaces: true, 31 | }, 32 | { text: '{', preserveLeadingSpaces: true }, 33 | { text: ' "sample": {', preserveLeadingSpaces: true }, 34 | { text: ' "json": "nested"', preserveLeadingSpaces: true }, 35 | { text: ' }', preserveLeadingSpaces: true }, 36 | { text: '}', preserveLeadingSpaces: true }, 37 | '\n\nfontFeatures property:', 38 | { text: 'Hello World 1234567890', fontFeatures: ['smcp'] }, 39 | { text: 'Hello World 1234567890', fontFeatures: ['c2sc'] }, 40 | { text: 'Hello World 1234567890', fontFeatures: ['onum'] }, 41 | { text: 'Hello World 1234567890', fontFeatures: ['onum', 'c2sc'] }, 42 | ...(text === undefined ? [] : [{ text }]), 43 | ], 44 | } 45 | 46 | return new Promise((resolve) => pdfmake.createPdf(docDefinition).getBuffer(resolve)).then( 47 | Buffer.from, 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /examples/ts-sample/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "moduleResolution": "node", 8 | "outDir": "./lib", 9 | "declaration": true, 10 | "noEmitOnError": false, 11 | "sourceMap": true, 12 | "lib": ["dom", "es2017"], 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "strictFunctionTypes": true, 17 | "noImplicitThis": true, 18 | "alwaysStrict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noImplicitReturns": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "skipLibCheck": true, 24 | "esModuleInterop": true 25 | }, 26 | "include": ["./src/**/*"] 27 | } 28 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | /** @type {import('jest').Config} */ 7 | const config = { 8 | setupFilesAfterEnv: ['./lib/toMatchPdfSnapshot'], 9 | } 10 | 11 | module.exports = config 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pdf-visual-diff", 3 | "version": "0.14.0", 4 | "description": "Visual Regression Testing for PDFs in JavaScript", 5 | "bin": "./lib/cli/index.js", 6 | "main": "./lib/index.js", 7 | "types": "./lib/index.d.ts", 8 | "scripts": { 9 | "clean": "rm -rf ./lib ./coverage", 10 | "build": "npm run clean && npm run lint && npm run format && npm run test && npm run tsc -- --noEmit false", 11 | "build:docs": "npx typedoc src/index.ts --treatWarningsAsErrors", 12 | "tsc": "tsc --noEmit --pretty", 13 | "tsc:watch": "npm run tsc -- --watch", 14 | "lint": "eslint ./src", 15 | "lint:fix": "npm run lint -- --fix", 16 | "format:other": "prettier --write .eslintrc.js .prettierrc.json tsconfig.json", 17 | "format": "prettier ./src --check", 18 | "format:fix": "prettier ./src --write", 19 | "test": "node --require ts-node/register --test src/**/*.test.ts src/*.test.ts", 20 | "test:watch": "node --require ts-node/register --test --watch src/**/*.test.ts src/*.test.ts", 21 | "test:jest": "npm run tsc -- --noEmit false && NODE_OPTIONS=--experimental-vm-modules jest test/jest.test.js", 22 | "license-checker": "npx license-checker --production --onlyAllow 'MIT; MIT OR X11; BSD; ISC; Apache-2.0; Unlicense' --excludePackages 'pdf-visual-diff'", 23 | "update-deps": "npx npm-check-updates --configFileName .ncurc.js" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/moshensky/pdf-visual-diff.git" 28 | }, 29 | "keywords": [ 30 | "pdf visual regression testing", 31 | "pdf compare", 32 | "pdf comparison", 33 | "javascript", 34 | "visual diff", 35 | "typescript", 36 | "diff testing", 37 | "js" 38 | ], 39 | "author": "Nikita Moshensky", 40 | "license": "MIT", 41 | "bugs": { 42 | "url": "https://github.com/moshensky/pdf-visual-diff/issues" 43 | }, 44 | "homepage": "https://github.com/moshensky/pdf-visual-diff#readme", 45 | "dependencies": { 46 | "@napi-rs/canvas": "^0.1.65", 47 | "glob": "^11.0.1", 48 | "jimp": "^1.6.0", 49 | "pdfjs-dist": "^4.10.38", 50 | "yargs": "^17.7.2" 51 | }, 52 | "devDependencies": { 53 | "@eslint/js": "^9.18.0", 54 | "@types/eslint__js": "^8.42.3", 55 | "@types/glob": "^8.1.0", 56 | "@types/node": "^20.14.8", 57 | "@types/yargs": "^17.0.33", 58 | "eslint": "^9.18.0", 59 | "eslint-plugin-tsdoc": "^0.4.0", 60 | "jest": "^29.7.0", 61 | "prettier": "^3.4.2", 62 | "ts-node": "^10.9.2", 63 | "typedoc": "^0.27.6", 64 | "typescript": "^5.7.3", 65 | "typescript-eslint": "^8.20.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/__snapshots__/barcodes-1-default-low-x-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/barcodes-1-default-low-x-4.png -------------------------------------------------------------------------------- /src/__snapshots__/barcodes-1-default-opts-dpi-low-x-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/barcodes-1-default-opts-dpi-low-x-4.png -------------------------------------------------------------------------------- /src/__snapshots__/barcodes-1-default-opts-dpi-low.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/barcodes-1-default-opts-dpi-low.png -------------------------------------------------------------------------------- /src/__snapshots__/barcodes-1-default-opts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/barcodes-1-default-opts.png -------------------------------------------------------------------------------- /src/__snapshots__/barcodes-1-dpi-low.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/barcodes-1-dpi-low.png -------------------------------------------------------------------------------- /src/__snapshots__/mask-different-mask-per-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/mask-different-mask-per-page.png -------------------------------------------------------------------------------- /src/__snapshots__/mask-multi-page-pdf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/mask-multi-page-pdf.png -------------------------------------------------------------------------------- /src/__snapshots__/mask-only-second-page-of-the-pdf-with-undefined.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/mask-only-second-page-of-the-pdf-with-undefined.png -------------------------------------------------------------------------------- /src/__snapshots__/mask-only-second-page-of-the-pdf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/mask-only-second-page-of-the-pdf.png -------------------------------------------------------------------------------- /src/__snapshots__/mask-rectangle-masks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/mask-rectangle-masks.png -------------------------------------------------------------------------------- /src/__snapshots__/mask-rectangle-masks_without_scaling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/mask-rectangle-masks_without_scaling.png -------------------------------------------------------------------------------- /src/__snapshots__/should-remove-diff-and-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/should-remove-diff-and-new.png -------------------------------------------------------------------------------- /src/__snapshots__/two-page-success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/two-page-success.png -------------------------------------------------------------------------------- /src/__snapshots__/two-page.diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/two-page.diff.png -------------------------------------------------------------------------------- /src/__snapshots__/two-page.new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/two-page.new.png -------------------------------------------------------------------------------- /src/__snapshots__/two-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/__snapshots__/two-page.png -------------------------------------------------------------------------------- /src/cli/approve.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { CommandModule } from 'yargs' 4 | import * as fs from 'fs/promises' 5 | import { askForConfirmation, findImages, mkCurrentSnapshotPath, mkDiffSnapshotPath } from './utils' 6 | 7 | type Arguments = { 8 | [x: string]: unknown 9 | path: string 10 | snapshotsDirName: string 11 | } 12 | 13 | export const approve: CommandModule = { 14 | command: 'approve', 15 | describe: 'Approve new snapshots', 16 | builder: { 17 | path: { 18 | alias: 'p', 19 | default: '.', 20 | }, 21 | 'snapshots-dir-name': { 22 | alias: 's', 23 | default: '__snapshots__', 24 | }, 25 | }, 26 | handler: ({ path, snapshotsDirName }) => { 27 | return findImages(path, snapshotsDirName).then((files) => { 28 | const execDirLength = process.cwd().length 29 | const filesOutput = files.map((x) => '.' + x.substring(execDirLength)).join('\n') 30 | return askForConfirmation(` 31 | New snapshots: 32 | ${filesOutput} 33 | Are you sure you want to overwrite current snapshots?`).then((overwrite) => { 34 | if (overwrite) { 35 | return Promise.all( 36 | files.map((x) => 37 | fs.rename(x, mkCurrentSnapshotPath(x)).then(() => fs.unlink(mkDiffSnapshotPath(x))), 38 | ), 39 | ).then(() => console.log('Success! Snapshots are overwritten.')) 40 | } 41 | console.log('Command was discarded! No changes were made.') 42 | return Promise.resolve() 43 | }) 44 | }) 45 | }, 46 | } 47 | -------------------------------------------------------------------------------- /src/cli/discard.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { CommandModule } from 'yargs' 4 | import { askForConfirmation, findImages } from './utils' 5 | import * as fs from 'fs/promises' 6 | 7 | type Arguments = { 8 | [x: string]: unknown 9 | path: string 10 | snapshotsDirName: string 11 | } 12 | 13 | export const discard: CommandModule = { 14 | command: 'discard', 15 | describe: 'Discard new snapshots and diffs', 16 | builder: { 17 | path: { 18 | alias: 'p', 19 | default: '.', 20 | }, 21 | 'snapshots-dir-name': { 22 | alias: 's', 23 | default: '__snapshots__', 24 | }, 25 | }, 26 | handler: ({ path, snapshotsDirName }) => { 27 | return findImages(path, snapshotsDirName, '*.@(new|diff).png').then((files) => { 28 | const execDirLength = process.cwd().length 29 | const filesOutput = files.map((x) => '.' + x.substring(execDirLength)).join('\n') 30 | return askForConfirmation(` 31 | New snapshots and diff images: 32 | ${filesOutput} 33 | Are you sure you want to remove them all?`).then((overwrite) => { 34 | if (overwrite) { 35 | return Promise.all(files.map((x) => fs.unlink(x))).then(() => 36 | console.log('Success! New snapshots and diff images removed.'), 37 | ) 38 | } 39 | console.log('Command was discarded! No changes were made.') 40 | return Promise.resolve() 41 | }) 42 | }) 43 | }, 44 | } 45 | -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import yargs from 'yargs' 4 | import { hideBin } from 'yargs/helpers' 5 | import { approve } from './approve' 6 | import { discard } from './discard' 7 | 8 | yargs(hideBin(process.argv)).command(approve).command(discard).demandCommand().parse() 9 | -------------------------------------------------------------------------------- /src/cli/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import * as assert from 'node:assert/strict' 3 | import { mkCurrentSnapshotPath, mkDiffSnapshotPath } from './utils' 4 | 5 | const filePath = '/pdf-visual-diff/src/__snapshots__/two-page.new.png' 6 | 7 | describe('cli utils', () => { 8 | it('mkCurrentSnapshotPath()', async () => 9 | assert.strictEqual( 10 | mkCurrentSnapshotPath(filePath), 11 | '/pdf-visual-diff/src/__snapshots__/two-page.png', 12 | )) 13 | 14 | it('mkDiffSnapshotPath()', async () => 15 | assert.strictEqual( 16 | mkDiffSnapshotPath(filePath), 17 | '/pdf-visual-diff/src/__snapshots__/two-page.diff.png', 18 | )) 19 | }) 20 | -------------------------------------------------------------------------------- /src/cli/utils.ts: -------------------------------------------------------------------------------- 1 | import { glob } from 'glob' 2 | import { join } from 'path' 3 | import { createInterface } from 'readline' 4 | 5 | const suffixLen = '.new.png'.length 6 | 7 | export const mkCurrentSnapshotPath = (newSnapshotPath: string): string => 8 | newSnapshotPath.substring(0, newSnapshotPath.length - suffixLen) + '.png' 9 | 10 | export const mkDiffSnapshotPath = (newSnapshotPath: string): string => 11 | newSnapshotPath.substring(0, newSnapshotPath.length - suffixLen) + '.diff.png' 12 | 13 | export const askForConfirmation = (question: string): Promise => { 14 | const readline = createInterface({ input: process.stdin, output: process.stdout }) 15 | return new Promise((res) => { 16 | readline.question(question + ' [Y/n]: ', (answer) => { 17 | readline.close() 18 | const cleaned = answer.trim().toLocaleLowerCase() 19 | if (cleaned === '' || ['yes', 'y'].indexOf(cleaned) >= 0) { 20 | res(true) 21 | } else if (['no', 'n'].indexOf(cleaned) >= 0) { 22 | res(false) 23 | } else { 24 | process.stdout.write('\nInvalid Response. Please answer with yes(y) or no(n)\n\n') 25 | askForConfirmation(question).then(res) 26 | } 27 | }) 28 | }) 29 | } 30 | 31 | export const findImages = ( 32 | startingPath = '.', 33 | snapshotsDirName = '__snapshots__', 34 | filenamePatter = '*.new.png', 35 | ): Promise> => { 36 | const pattern = join(process.cwd(), startingPath, '**', snapshotsDirName, filenamePatter) 37 | return glob(pattern, {}) 38 | } 39 | -------------------------------------------------------------------------------- /src/compare-images.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import * as assert from 'node:assert/strict' 3 | import { compareImages, mkDiffPath } from './compare-images' 4 | import { join } from 'path' 5 | import { existsSync } from 'fs' 6 | import { Jimp, JimpInstance } from 'jimp' 7 | 8 | describe('mkDiffPath()', () => { 9 | it('should mk path with extension', () => 10 | assert.strictEqual(mkDiffPath('some-path.ext'), 'some-path.diff.ext')) 11 | 12 | it('should mk path with extension when starts with .', () => 13 | assert.strictEqual(mkDiffPath('./some-path.ext'), './some-path.diff.ext')) 14 | 15 | it('should handle empty', () => assert.strictEqual(mkDiffPath(''), '.diff')) 16 | 17 | it('should mk path without extension', () => 18 | assert.strictEqual(mkDiffPath('some-path'), 'some-path.diff')) 19 | }) 20 | 21 | const expectedImageName = 'sample-image-expected.png' 22 | const sampleImage = 'sample-image.png' 23 | const sampleImage2 = 'sample-image-2.png' 24 | const testDataDir = join(__dirname, './test-data') 25 | const expectedImagePath = join(testDataDir, expectedImageName) 26 | const imagePath = join(testDataDir, sampleImage) 27 | const image2Path = join(testDataDir, sampleImage2) 28 | 29 | describe('compareImages()', () => { 30 | it('should succeed comparing', () => 31 | Jimp.read(imagePath) 32 | .then((x) => x as JimpInstance) 33 | .then((img) => compareImages(expectedImagePath, [img])) 34 | .then((x) => { 35 | assert.strictEqual(x.equal, true) 36 | assert.strictEqual( 37 | existsSync(mkDiffPath(imagePath)), 38 | false, 39 | 'should not generate diff output', 40 | ) 41 | })) 42 | 43 | it('should fail comparing and output diff', () => 44 | Jimp.read(image2Path) 45 | .then((x) => x as JimpInstance) 46 | .then((img) => compareImages(expectedImagePath, [img])) 47 | .then((x) => { 48 | assert.strictEqual(x.equal, false) 49 | assert.ok( 50 | x.diffs && x.diffs[0] && 'diff' in x.diffs[0], 51 | "Expected 'diffs[0].diff' to exist", 52 | ) 53 | assert.ok( 54 | x.diffs && x.diffs[0] && 'page' in x.diffs[0], 55 | "Expected 'diffs[0].page' to exist", 56 | ) 57 | })) 58 | }) 59 | -------------------------------------------------------------------------------- /src/compare-images.ts: -------------------------------------------------------------------------------- 1 | import { Jimp, diff as jimpDiff, JimpInstance } from 'jimp' 2 | import { mergeImages } from './imageUtils' 3 | 4 | const diffToken = '.diff' 5 | export const mkDiffPath = (path: string): string => { 6 | const dotIndex = path.lastIndexOf('.') 7 | return dotIndex === -1 8 | ? path + diffToken 9 | : path.substring(0, dotIndex) + diffToken + path.substring(dotIndex) 10 | } 11 | 12 | /** The options type for {@link compareImages}. */ 13 | export type CompareImagesOpts = { 14 | tolerance?: number 15 | } 16 | 17 | const defaultOpts: Required = { 18 | tolerance: 0, 19 | } 20 | 21 | type CompareOK = { 22 | equal: true 23 | } 24 | 25 | type CompareKO = { 26 | equal: false 27 | diffs: ReadonlyArray<{ 28 | page: number 29 | diff: JimpInstance 30 | }> 31 | } 32 | 33 | type CompareImagesResult = CompareOK | CompareKO 34 | 35 | export const compareImages = async ( 36 | expectedImagePath: string, 37 | images: ReadonlyArray, 38 | options?: CompareImagesOpts, 39 | ): Promise => { 40 | const { tolerance } = { 41 | ...defaultOpts, 42 | ...options, 43 | } 44 | // @ts-expect-error it is a Jimp 45 | const expectedImg: JimpInstance = await Jimp.read(expectedImagePath) 46 | // Multi image comparison not implemented! 47 | const img = mergeImages(images) 48 | const diff = jimpDiff(expectedImg, img, tolerance) 49 | if (diff.percent > 0) { 50 | return { 51 | equal: false, 52 | diffs: [{ page: 1, diff: diff.image }], 53 | } 54 | } 55 | 56 | return { equal: true } 57 | } 58 | -------------------------------------------------------------------------------- /src/compare-pdf-to-snapshot.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import * as assert from 'node:assert/strict' 3 | import { join } from 'node:path' 4 | import { access, unlink, readFile } from 'node:fs/promises' 5 | import { Jimp, JimpInstance } from 'jimp' 6 | import { 7 | comparePdfToSnapshot, 8 | SNAPSHOTS_DIR_NAME, 9 | CompareOptions, 10 | RegionMask, 11 | } from './compare-pdf-to-snapshot' 12 | import { compareImages } from './compare-images' 13 | import { Dpi } from './types' 14 | 15 | const testDataDir = join(__dirname, './test-data') 16 | const pdfs = join(testDataDir, 'pdfs') 17 | 18 | const singlePageSmallPdfPath = join(pdfs, 'single-page-small.pdf') 19 | const singlePagePdfPath = join(pdfs, 'single-page.pdf') 20 | const barcodes1PdfPath = join(pdfs, 'barcodes-1.pdf') 21 | const twoPagePdfPath = join(pdfs, 'two-page.pdf') 22 | 23 | async function removeIfExists(filePath: string): Promise { 24 | try { 25 | await unlink(filePath) 26 | } catch { 27 | // File doesn't exist, no need to remove 28 | } 29 | } 30 | 31 | async function fileExists(filePath: string): Promise { 32 | try { 33 | await access(filePath) 34 | return true 35 | } catch { 36 | return false 37 | } 38 | } 39 | 40 | describe('comparePdfToSnapshot()', () => { 41 | it('should create new snapshot, when one does not exists', async () => { 42 | const snapshotName = 'single-page-small' 43 | const snapshotPath = join(__dirname, SNAPSHOTS_DIR_NAME, `${snapshotName}.png`) 44 | 45 | await removeIfExists(snapshotPath) 46 | 47 | const isEqual = await comparePdfToSnapshot(singlePageSmallPdfPath, __dirname, snapshotName) 48 | assert.strictEqual(isEqual, true) 49 | assert.strictEqual(await fileExists(snapshotPath), true) 50 | await removeIfExists(snapshotPath) 51 | }) 52 | 53 | it('should fail and create diff with new version', async () => { 54 | const isEqual = await comparePdfToSnapshot(singlePagePdfPath, __dirname, 'two-page') 55 | // Should not match 56 | assert.strictEqual(isEqual, false) 57 | 58 | const snapshotDiffPath = join(__dirname, SNAPSHOTS_DIR_NAME, 'two-page.diff.png') 59 | assert.strictEqual(await fileExists(snapshotDiffPath), true, 'diff is not created') 60 | const snapshotNewPath = join(__dirname, SNAPSHOTS_DIR_NAME, 'two-page.new.png') 61 | assert.strictEqual(await fileExists(snapshotNewPath), true, 'new is not created') 62 | }) 63 | 64 | it('should remove diff and new snapshots when matches with reference snapshot', async () => { 65 | const snapshotName = 'should-remove-diff-and-new' 66 | const snapshotBase = join(__dirname, SNAPSHOTS_DIR_NAME, snapshotName) 67 | const snapshotPathDiff: `${string}.${string}` = `${snapshotBase}.diff.png` 68 | const snapshotPathNew: `${string}.${string}` = `${snapshotBase}.new.png` 69 | 70 | await removeIfExists(snapshotPathDiff) 71 | await removeIfExists(snapshotPathNew) 72 | await new Jimp({ width: 100, height: 100 }).write(snapshotPathDiff) 73 | await new Jimp({ width: 100, height: 100 }).write(snapshotPathNew) 74 | 75 | const isEqual = await comparePdfToSnapshot(singlePageSmallPdfPath, __dirname, snapshotName) 76 | assert.strictEqual(isEqual, true) 77 | assert.strictEqual( 78 | await fileExists(snapshotPathDiff), 79 | false, 80 | 'Snapshot diff should not exists.', 81 | ) 82 | assert.strictEqual(await fileExists(snapshotPathNew), false, 'Snapshot new should not exists.') 83 | }) 84 | 85 | describe('should pass', () => { 86 | it('should pass', () => 87 | comparePdfToSnapshot(twoPagePdfPath, __dirname, 'two-page-success').then((x) => 88 | assert.strictEqual(x, true), 89 | )) 90 | 91 | const testDataDir = join(__dirname, './test-data') 92 | const pdfs = join(testDataDir, 'pdfs') 93 | const singlePageSmall = join(pdfs, 'single-page-small.pdf') 94 | const singlePage = join(pdfs, 'single-page.pdf') 95 | const tamReview = join(pdfs, 'TAMReview.pdf') 96 | const twoPage = join(pdfs, 'two-page.pdf') 97 | const expectedDir = join(testDataDir, 'pdf2png-expected') 98 | 99 | const testPdf2png = ( 100 | pdf: string | Buffer, 101 | expectedImageName: string, 102 | options?: CompareOptions, 103 | ): Promise => { 104 | return comparePdfToSnapshot(pdf, expectedDir, expectedImageName, options).then((x) => { 105 | assert.strictEqual(x, true) 106 | }) 107 | } 108 | 109 | it('single-page-small.pdf', () => testPdf2png(singlePageSmall, 'single-page-small')) 110 | it('single-page.pdf', () => testPdf2png(singlePage, 'single-page')) 111 | it('TAMReview.pdf', () => testPdf2png(tamReview, 'TAMReview')) 112 | it('TAMReview.pdf without scaling', () => 113 | testPdf2png(tamReview, 'TAMReview_without_scaling', { 114 | pdf2PngOptions: { dpi: Dpi.Low }, 115 | })) 116 | it('two-page.pdf', () => testPdf2png(twoPage, 'two-page')) 117 | it('two-page.pdf buffer', () => readFile(twoPage).then((x) => testPdf2png(x, 'two-page'))) 118 | }) 119 | 120 | describe('mask regions', () => { 121 | const blueMask: RegionMask = { 122 | type: 'rectangle-mask', 123 | x: 50, 124 | y: 75, 125 | width: 140, 126 | height: 100, 127 | color: 'Blue', 128 | } 129 | const greenMask: RegionMask = { 130 | type: 'rectangle-mask', 131 | x: 110, 132 | y: 200, 133 | width: 90, 134 | height: 50, 135 | color: 'Green', 136 | } 137 | const opts: CompareOptions = { 138 | maskRegions: () => [blueMask, greenMask], 139 | } 140 | 141 | it('should succeed comparing masked pdf', () => 142 | comparePdfToSnapshot(singlePagePdfPath, __dirname, 'mask-rectangle-masks', opts).then((x) => 143 | assert.strictEqual(x, true), 144 | )) 145 | 146 | it('should succeed comparing masked pdf without scaling', () => { 147 | const blueMaskSmall: RegionMask = { 148 | type: 'rectangle-mask', 149 | x: 25, 150 | y: 37, 151 | width: 70, 152 | height: 50, 153 | color: 'Blue', 154 | } 155 | const greenMaskSmall: RegionMask = { 156 | type: 'rectangle-mask', 157 | x: 55, 158 | y: 100, 159 | width: 45, 160 | height: 25, 161 | color: 'Green', 162 | } 163 | return comparePdfToSnapshot( 164 | singlePagePdfPath, 165 | __dirname, 166 | 'mask-rectangle-masks_without_scaling', 167 | { 168 | pdf2PngOptions: { dpi: 72 }, 169 | maskRegions: () => [blueMaskSmall, greenMaskSmall], 170 | }, 171 | ).then((x) => assert.strictEqual(x, true)) 172 | }) 173 | 174 | it('should mask multi page pdf', () => 175 | comparePdfToSnapshot(twoPagePdfPath, __dirname, 'mask-multi-page-pdf', opts).then((x) => 176 | assert.strictEqual(x, true), 177 | )) 178 | 179 | it('should have different mask per page', () => 180 | comparePdfToSnapshot(twoPagePdfPath, __dirname, 'mask-different-mask-per-page', { 181 | maskRegions: (page) => { 182 | switch (page) { 183 | case 1: 184 | return [blueMask] 185 | case 2: 186 | return [greenMask] 187 | default: 188 | return [] 189 | } 190 | }, 191 | }).then((x) => assert.strictEqual(x, true))) 192 | 193 | it('should mask only second page of the pdf', () => 194 | comparePdfToSnapshot(twoPagePdfPath, __dirname, 'mask-only-second-page-of-the-pdf', { 195 | maskRegions: (page) => (page === 2 ? [blueMask, greenMask] : []), 196 | }).then((x) => assert.strictEqual(x, true))) 197 | 198 | it('should mask only second page of the pdf and handle undefined masks', () => 199 | comparePdfToSnapshot( 200 | twoPagePdfPath, 201 | __dirname, 202 | 'mask-only-second-page-of-the-pdf-with-undefined', 203 | { 204 | maskRegions: (page) => (page === 2 ? [blueMask, greenMask] : undefined), 205 | }, 206 | ).then((x) => assert.strictEqual(x, true))) 207 | 208 | it('should create initial masked image', async () => { 209 | const snapshotName = 'initial-rectangle-masks' 210 | const snapshotPath = join(__dirname, SNAPSHOTS_DIR_NAME, snapshotName + '.png') 211 | const expectedImagePath = join( 212 | __dirname, 213 | './test-data', 214 | 'expected-initial-rectangle-masks.png', 215 | ) 216 | await removeIfExists(snapshotPath) 217 | 218 | const isEqual = await comparePdfToSnapshot(singlePagePdfPath, __dirname, snapshotName, opts) 219 | assert.strictEqual(isEqual, true) 220 | 221 | const img = (await Jimp.read(snapshotPath)) as JimpInstance 222 | const { equal } = await compareImages(expectedImagePath, [img], { tolerance: 0 }) 223 | assert.strictEqual(equal, true, 'Rectangle masks does not match expected one') 224 | 225 | await removeIfExists(snapshotPath) 226 | }) 227 | }) 228 | 229 | describe('when reference snapshot does not exist', () => { 230 | it('should be created when `failOnMissingSnapshot` is not set', async () => { 231 | const snapshotName = 'allow-create-snapshot-when-failOnMissingSnapshot-is-not-set' 232 | const snapshotPath = join(__dirname, SNAPSHOTS_DIR_NAME, snapshotName + '.png') 233 | await removeIfExists(snapshotPath) 234 | 235 | const isEqual = await comparePdfToSnapshot(singlePageSmallPdfPath, __dirname, snapshotName) 236 | assert.strictEqual(isEqual, true) 237 | assert.strictEqual(await fileExists(snapshotPath), true, 'Snapshot should be created') 238 | removeIfExists(snapshotPath) 239 | }) 240 | 241 | it('should be created when `failOnMissingSnapshot` is set to `false`', async () => { 242 | const snapshotName = 'allow-create-snapshot-when-failOnMissingSnapshot-is-set-to-false' 243 | const snapshotPath = join(__dirname, SNAPSHOTS_DIR_NAME, snapshotName + '.png') 244 | await removeIfExists(snapshotPath) 245 | 246 | const isEqual = await comparePdfToSnapshot(singlePageSmallPdfPath, __dirname, snapshotName, { 247 | failOnMissingSnapshot: false, 248 | }) 249 | assert.strictEqual(isEqual, true) 250 | assert.strictEqual(await fileExists(snapshotPath), true, 'Snapshot should be created') 251 | await removeIfExists(snapshotPath) 252 | }) 253 | 254 | it('should not be created and return `false` when `failOnMissingSnapshot` is set to `true`', async () => { 255 | const snapshotName = 'fail-on-missing-snapshot-when-failOnMissingSnapshot-is-set-to-true' 256 | const snapshotPath = join(__dirname, SNAPSHOTS_DIR_NAME, snapshotName + '.png') 257 | await removeIfExists(snapshotPath) 258 | 259 | const isEqual = await comparePdfToSnapshot(singlePageSmallPdfPath, __dirname, snapshotName, { 260 | failOnMissingSnapshot: true, 261 | }) 262 | assert.strictEqual(isEqual, false) 263 | assert.strictEqual(await fileExists(snapshotPath), false, 'Snapshot should not be created') 264 | }) 265 | }) 266 | 267 | describe('github issue', () => { 268 | it('#89 discrepancy between windows and linux/mac using v0.14.0', async () => { 269 | await comparePdfToSnapshot(barcodes1PdfPath, __dirname, 'barcodes-1-default-opts').then((x) => 270 | assert.strictEqual(x, true), 271 | ) 272 | 273 | await comparePdfToSnapshot(barcodes1PdfPath, __dirname, 'barcodes-1-dpi-low', { 274 | pdf2PngOptions: { dpi: Dpi.Low }, 275 | }).then((x) => assert.strictEqual(x, true)) 276 | 277 | await comparePdfToSnapshot(barcodes1PdfPath, __dirname, 'barcodes-1-default-low-x-4', { 278 | pdf2PngOptions: { dpi: Dpi.Low * 4 }, 279 | }).then((x) => assert.strictEqual(x, true)) 280 | }) 281 | }) 282 | }) 283 | -------------------------------------------------------------------------------- /src/compare-pdf-to-snapshot.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path' 2 | import { access, mkdir, unlink } from 'node:fs/promises' 3 | import { pdf2png } from './pdf2png/pdf2png' 4 | import { compareImages } from './compare-images' 5 | import { Jimp, JimpInstance } from 'jimp' 6 | import { Dpi, PdfToPngOptions } from './types' 7 | import { writeImages } from './imageUtils' 8 | 9 | /** 10 | * Represents the available colors for highlighting. 11 | */ 12 | export type HighlightColor = 13 | | 'Red' 14 | | 'Green' 15 | | 'Blue' 16 | | 'White' 17 | | 'Cyan' 18 | | 'Magenta' 19 | | 'Yellow' 20 | | 'Black' 21 | | 'Gray' 22 | 23 | /** 24 | * Represents a rectangular mask applied at the PNG level, i.e., after the 25 | * conversion of the PDF to an image. 26 | * 27 | * @remarks 28 | * The values provided for `x`, `y`, `width`, and `height` are expected to be in 29 | * pixels and based on the generated image by the library. 30 | * The origin (0,0) of the PNG's coordinate system is the top-left corner of the 31 | * image. 32 | */ 33 | export type RectangleMask = Readonly<{ 34 | type: 'rectangle-mask' 35 | /** The x-coordinate of the top-left corner of the rectangle in pixels. */ 36 | x: number 37 | /** The y-coordinate of the top-left corner of the rectangle in pixels. */ 38 | y: number 39 | /** The width of the rectangle in pixels. */ 40 | width: number 41 | /** The height of the rectangle in pixels. */ 42 | height: number 43 | /** The color used for the mask. */ 44 | color: HighlightColor 45 | }> 46 | 47 | export type RegionMask = RectangleMask 48 | 49 | /** 50 | * Defines a function for masking predefined regions per page, useful for 51 | * parts of the PDF that change between tests. 52 | * 53 | * @param page - The page number of the PDF. 54 | * @returns An array of region masks for the specified page, or undefined if no masks are defined. 55 | */ 56 | export type MaskRegions = (page: number) => ReadonlyArray | undefined 57 | 58 | const colorToNum: Record = { 59 | Red: 0xff0000ff, 60 | Green: 0x00ff00ff, 61 | Blue: 0x0000ffff, 62 | White: 0x00000000, 63 | Cyan: 0x00ffffff, 64 | Magenta: 0xff00ffff, 65 | Yellow: 0xffff00ff, 66 | Black: 0x000000ff, 67 | Gray: 0xbfbfbfff, 68 | } 69 | 70 | const maskImgWithRegions = 71 | (maskRegions: MaskRegions) => 72 | (images: ReadonlyArray): ReadonlyArray => { 73 | images.forEach((img, idx) => { 74 | ;(maskRegions(idx + 1) || []).forEach(({ type, x, y, width, height, color }) => { 75 | if (type === 'rectangle-mask') { 76 | img.composite(new Jimp({ width, height, color: colorToNum[color] }), x, y) 77 | } 78 | }) 79 | }) 80 | 81 | return images 82 | } 83 | 84 | /** 85 | * The options type for {@link comparePdfToSnapshot}. 86 | * 87 | * @privateRemarks 88 | * Explicitly not using `Partial`. It doesn't play nice with TypeDoc. 89 | * Instead of showing the type name in the docs a Partial with all the 90 | * fields is inlined. 91 | */ 92 | export type CompareOptions = { 93 | /** 94 | * Number value for error tolerance in the range [0, 1]. 95 | * 96 | * @defaultValue 0 97 | */ 98 | tolerance?: number 99 | /** {@inheritDoc MaskRegions} */ 100 | maskRegions?: MaskRegions 101 | /** {@inheritDoc PdfToPngOptions} */ 102 | pdf2PngOptions?: PdfToPngOptions 103 | /** 104 | * Whether a missing snapshot should cause the comparison to fail. 105 | * 106 | * @defaultValue false 107 | */ 108 | failOnMissingSnapshot?: boolean 109 | } 110 | 111 | /** 112 | * Compares a PDF to a persisted snapshot, with behavior for handling missing snapshots 113 | * controlled by the `failOnMissingSnapshot` option. 114 | * 115 | * @remarks 116 | * The function has the following **side effects**: 117 | * - If no snapshot exists: 118 | * - If `failOnMissingSnapshot` is `false` (default), the PDF is converted to an image, 119 | * saved as a new snapshot, and the function returns `true`. 120 | * - If `failOnMissingSnapshot` is `true`, the function returns `false` without creating a new snapshot. 121 | * - If a snapshot exists, the PDF is converted to an image and compared to the snapshot: 122 | * - If they differ, the function returns `false` and creates two additional images 123 | * next to the snapshot: one with the suffix `new` (the current view of the PDF as an image) 124 | * and one with the suffix `diff` (showing the difference between the snapshot and the `new` image). 125 | * - If they are equal, the function returns `true`. If `new` and `diff` versions are present, they are deleted. 126 | * 127 | * @param pdf - Path to the PDF file or a Buffer containing the PDF. 128 | * @param snapshotDir - Path to the directory where the `__snapshots__` folder will be created. 129 | * @param snapshotName - Unique name for the snapshot within the specified path. 130 | * @param options - Options for comparison, including tolerance, mask regions, and behavior 131 | * regarding missing snapshots. See {@link CompareOptions} for more details. 132 | * 133 | * @returns 134 | * A promise that resolves to `true` if the PDF matches the snapshot or if the behavior 135 | * allows for missing snapshots. Resolves to `false` if the PDF differs from the snapshot 136 | * or if `failOnMissingSnapshot` is `true` and no snapshot exists. 137 | */ 138 | export async function comparePdfToSnapshot( 139 | pdf: string | Buffer, 140 | snapshotDir: string, 141 | snapshotName: string, 142 | options?: CompareOptions, 143 | ): Promise { 144 | const mergedOptions = mergeOptionsWithDefaults(options) 145 | const snapshotContext = await createSnapshotContext(snapshotDir, snapshotName) 146 | 147 | try { 148 | // Check if snapshot exits and handle accordingly 149 | await access(snapshotContext.path) 150 | return compareWithSnapshot(pdf, snapshotContext, mergedOptions) 151 | } catch { 152 | return handleMissingSnapshot(pdf, snapshotContext, mergedOptions) 153 | } 154 | } 155 | 156 | type SnapshotContext = { 157 | name: string 158 | dirPath: string 159 | path: string 160 | diffPath: string 161 | newPath: string 162 | } 163 | 164 | function mergeOptionsWithDefaults(options?: CompareOptions): Required { 165 | return { 166 | maskRegions: options?.maskRegions ?? (() => []), 167 | pdf2PngOptions: options?.pdf2PngOptions ?? { dpi: Dpi.High }, 168 | failOnMissingSnapshot: options?.failOnMissingSnapshot ?? false, 169 | tolerance: options?.tolerance ?? 0, 170 | } 171 | } 172 | 173 | export const SNAPSHOTS_DIR_NAME = '__snapshots__' 174 | /** 175 | * @deprecated Use SNAPSHOTS_DIR_NAME instead. 176 | */ 177 | export const snapshotsDirName = SNAPSHOTS_DIR_NAME 178 | 179 | /** Generates the snapshot context and creates the folder if it doesn’t already exist. */ 180 | async function createSnapshotContext( 181 | snapshotDir: string, 182 | snapshotName: string, 183 | ): Promise { 184 | const dirPath = path.join(snapshotDir, SNAPSHOTS_DIR_NAME) 185 | try { 186 | await access(dirPath) 187 | } catch { 188 | await mkdir(dirPath, { recursive: true }) 189 | } 190 | 191 | const basePath = path.join(dirPath, snapshotName) 192 | 193 | return { 194 | name: snapshotName, 195 | dirPath, 196 | path: `${basePath}.png`, 197 | diffPath: `${basePath}.diff.png`, 198 | newPath: `${basePath}.new.png`, 199 | } 200 | } 201 | 202 | async function handleMissingSnapshot( 203 | pdf: string | Buffer, 204 | snapshotContext: SnapshotContext, 205 | { failOnMissingSnapshot, maskRegions, pdf2PngOptions }: Required, 206 | ): Promise { 207 | if (failOnMissingSnapshot) { 208 | return false 209 | } 210 | 211 | // Generate snapshot if missing 212 | const images = await pdf2png(pdf, pdf2PngOptions).then(maskImgWithRegions(maskRegions)) 213 | await writeImages(snapshotContext.path)(images) 214 | 215 | return true 216 | } 217 | 218 | async function compareWithSnapshot( 219 | pdf: string | Buffer, 220 | snapshotContext: SnapshotContext, 221 | { maskRegions, pdf2PngOptions, tolerance }: Required, 222 | ): Promise { 223 | const images = await pdf2png(pdf, pdf2PngOptions).then(maskImgWithRegions(maskRegions)) 224 | const result = await compareImages(snapshotContext.path, images, { tolerance }) 225 | 226 | if (result.equal) { 227 | await removeIfExists(snapshotContext.diffPath) 228 | await removeIfExists(snapshotContext.newPath) 229 | 230 | return true 231 | } 232 | 233 | await writeImages(snapshotContext.newPath)(images) 234 | await writeImages(snapshotContext.diffPath)(result.diffs.map((x) => x.diff)) 235 | 236 | return false 237 | } 238 | 239 | async function removeIfExists(filePath: string): Promise { 240 | try { 241 | await unlink(filePath) 242 | } catch { 243 | // File doesn't exist, no need to remove 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/conversions/conversions.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import * as assert from 'node:assert/strict' 3 | import { convertFromMmToPx, convertFromPxToMm } from './conversions' 4 | 5 | describe('conversions', () => { 6 | describe('convertFromMmToPx', () => { 7 | it('should convert millimeters to pixels correctly', () => { 8 | assert.strictEqual(convertFromMmToPx(10, 300), 118) 9 | assert.strictEqual(convertFromMmToPx(25.4, 300), 300) 10 | assert.strictEqual(convertFromMmToPx(0, 300), 0) 11 | assert.strictEqual(convertFromMmToPx(10, 0), 0) 12 | assert.strictEqual(convertFromMmToPx(-10, 300), 0) 13 | assert.strictEqual(convertFromMmToPx(10, -300), 0) 14 | }) 15 | }) 16 | 17 | describe('convertFromPxToMm', () => { 18 | it('should convert pixels to millimeters correctly', () => { 19 | assert.strictEqual(convertFromPxToMm(300, 300), 25) 20 | assert.strictEqual(convertFromPxToMm(118, 300), 10) 21 | assert.strictEqual(convertFromPxToMm(0, 300), 0) 22 | assert.strictEqual(convertFromPxToMm(300, 0), 0) 23 | assert.strictEqual(convertFromPxToMm(-300, 300), 0) 24 | assert.strictEqual(convertFromPxToMm(300, -300), 0) 25 | }) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /src/conversions/conversions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts a size from millimeters to pixels based on the provided DPI (dots per inch). 3 | * 4 | * @param sizeMm - The size in millimeters. 5 | * @param dpi - The dots per inch (DPI) for the conversion. 6 | * @returns The size in pixels. 7 | */ 8 | export function convertFromMmToPx(sizeMm: number, dpi: number): number { 9 | if (sizeMm <= 0 || dpi <= 0) { 10 | return 0 11 | } 12 | const sizeInch = sizeMm / 25.4 13 | return Math.round(sizeInch * dpi) 14 | } 15 | 16 | /** 17 | * Converts a size from pixels to millimeters based on the provided DPI (dots per inch). 18 | * 19 | * @param sizePx - The size in pixels. 20 | * @param dpi - The dots per inch (DPI) for the conversion. 21 | * @returns The size in millimeters. 22 | */ 23 | export function convertFromPxToMm(sizePx: number, dpi: number): number { 24 | if (sizePx <= 0 || dpi <= 0) { 25 | return 0 26 | } 27 | const sizeInch = sizePx / dpi 28 | return Math.round(sizeInch * 25.4) 29 | } 30 | -------------------------------------------------------------------------------- /src/conversions/index.ts: -------------------------------------------------------------------------------- 1 | export { convertFromMmToPx, convertFromPxToMm } from './conversions' 2 | -------------------------------------------------------------------------------- /src/imageUtils/index.ts: -------------------------------------------------------------------------------- 1 | export { mergeImages } from './mergeImages' 2 | export { writeImages } from './writeImages' 3 | -------------------------------------------------------------------------------- /src/imageUtils/mergeImages.ts: -------------------------------------------------------------------------------- 1 | import { Jimp, JimpInstance } from 'jimp' 2 | 3 | type ImgData = Readonly<{ 4 | img: JimpInstance 5 | y: number 6 | }> 7 | 8 | /** 9 | * Merges an array of Jimp images into a single image. 10 | * 11 | * @param images - An array of Jimp images to be merged. 12 | * @returns A Jimp image that is the result of merging all input images. 13 | */ 14 | export function mergeImages(images: ReadonlyArray): JimpInstance { 15 | let imgHeight = 0 16 | const imgData: ImgData[] = images.map((img) => { 17 | const res = { img, y: imgHeight } 18 | imgHeight += img.height 19 | return res 20 | }) 21 | 22 | const imgWidth = Math.max(...imgData.map(({ img }) => img.width)) 23 | const baseImage = new Jimp({ width: imgWidth, height: imgHeight, color: 0x00000000 }) 24 | 25 | imgData.forEach(({ img, y }) => baseImage.composite(img, 0, y)) 26 | 27 | return baseImage 28 | } 29 | -------------------------------------------------------------------------------- /src/imageUtils/writeImages.ts: -------------------------------------------------------------------------------- 1 | import { JimpInstance } from 'jimp' 2 | import * as path from 'path' 3 | import { mergeImages } from './mergeImages' 4 | 5 | /** 6 | * Writes images to the specified output path. 7 | * 8 | * @returns A function that takes an array of Jimp images and returns a promise that resolves to void. 9 | */ 10 | export const writeImages = 11 | ( 12 | /** The path where the images will be saved. */ 13 | outputImagePath: string, 14 | /** 15 | * Whether to combine all images into a single image. 16 | * @defaultValue true 17 | */ 18 | combinePages = true, 19 | ) => 20 | (images: ReadonlyArray): Promise => { 21 | if (combinePages === true) { 22 | // @ts-expect-error too smart types 23 | const outputImagePath0: `${string}.${string}` = outputImagePath 24 | return mergeImages(images).write(outputImagePath0) 25 | } 26 | 27 | const parsedPath = path.parse(outputImagePath) 28 | const partialName = path.join(parsedPath.dir, parsedPath.name) 29 | const padMaxLen = images.length.toString().length 30 | return Promise.all( 31 | images.map((img, idx) => 32 | img.write(`${partialName}_${String(idx + 1).padStart(padMaxLen, '0')}.png`), 33 | ), 34 | ).then(() => undefined) 35 | } 36 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | comparePdfToSnapshot, 3 | MaskRegions, 4 | RegionMask, 5 | RectangleMask, 6 | CompareOptions, 7 | HighlightColor, 8 | } from './compare-pdf-to-snapshot' 9 | export { PdfToPngOptions, Dpi } from './types' 10 | -------------------------------------------------------------------------------- /src/pdf2png/index.ts: -------------------------------------------------------------------------------- 1 | export { pdf2png } from './pdf2png' 2 | -------------------------------------------------------------------------------- /src/pdf2png/pdf2png.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import * as assert from 'node:assert/strict' 3 | import { join } from 'path' 4 | import { pdf2png } from './pdf2png' 5 | import { compareImages } from '../compare-images' 6 | import { Dpi } from '../types' 7 | 8 | const testDataDir = join(__dirname, '../test-data') 9 | const pdfs = join(testDataDir, 'pdfs') 10 | const singlePage = join(pdfs, 'single-page.pdf') 11 | const twoPage = join(pdfs, 'two-page.pdf') 12 | const cmaps = join(pdfs, 'cmaps.pdf') 13 | 14 | const expectedDir = join(testDataDir, 'pdf2png-expected') 15 | 16 | describe('pdf2png()', () => { 17 | it('two-page.pdf png per page with scaling', () => { 18 | const expectedImage1Path = join(expectedDir, 'two-page_png_per_page_scaled_1.png') 19 | const expectedImage2Path = join(expectedDir, 'two-page_png_per_page_scaled_2.png') 20 | return pdf2png(twoPage, { dpi: Dpi.High }) 21 | .then((imgs) => { 22 | return Promise.all([ 23 | compareImages(expectedImage1Path, [imgs[0]]), 24 | compareImages(expectedImage2Path, [imgs[1]]), 25 | ]) 26 | }) 27 | .then((results) => { 28 | results.forEach((x) => assert.strictEqual(x.equal, true)) 29 | }) 30 | }) 31 | 32 | it('two-page.pdf png per page and without scaling', () => { 33 | const expectedImage1Path = join(expectedDir, 'two-page_png_per_page_1.png') 34 | const expectedImage2Path = join(expectedDir, 'two-page_png_per_page_2.png') 35 | return pdf2png(twoPage, { dpi: Dpi.Low }) 36 | .then((imgs) => { 37 | return Promise.all([ 38 | compareImages(expectedImage1Path, [imgs[0]]), 39 | compareImages(expectedImage2Path, [imgs[1]]), 40 | ]) 41 | }) 42 | .then((results) => { 43 | results.forEach((x) => assert.strictEqual(x.equal, true)) 44 | }) 45 | }) 46 | 47 | it('should scale using custom DPI', () => { 48 | const expectedImagePath = join(expectedDir, 'should_scale_using_custom_DPI.png') 49 | return pdf2png(singlePage, { dpi: 200 }) 50 | .then((imgs) => compareImages(expectedImagePath, imgs)) 51 | .then((result) => assert.strictEqual(result.equal, true)) 52 | }) 53 | 54 | it('pdf that requires cmaps', () => { 55 | const expectedImagePath = join(expectedDir, 'cmaps.png') 56 | return pdf2png(cmaps) 57 | .then((imgs) => compareImages(expectedImagePath, imgs)) 58 | .then((result) => assert.strictEqual(result.equal, true)) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /src/pdf2png/pdf2png.ts: -------------------------------------------------------------------------------- 1 | import * as Canvas from '@napi-rs/canvas' 2 | import * as fs from 'node:fs/promises' 3 | import * as path from 'node:path' 4 | import { Jimp, JimpInstance } from 'jimp' 5 | import type { PDFDocumentProxy, PDFPageProxy, PageViewport } from 'pdfjs-dist' 6 | import { DocumentInitParameters, RenderParameters } from 'pdfjs-dist/types/src/display/api' 7 | import { PdfToPngOptions, Dpi } from '../types' 8 | import { convertFromMmToPx, convertFromPxToMm } from '../conversions' 9 | 10 | // pdfjs location 11 | const PDFJS_DIR = path.join(path.dirname(require.resolve('pdfjs-dist')), '..') 12 | 13 | const DOCUMENT_INIT_PARAMS_DEFAULTS: DocumentInitParameters = { 14 | // Where the standard fonts are located. 15 | standardFontDataUrl: path.join(PDFJS_DIR, 'standard_fonts/'), 16 | // Some PDFs need external cmaps. 17 | cMapUrl: path.join(PDFJS_DIR, 'cmaps/'), 18 | cMapPacked: true, 19 | } 20 | 21 | const pdf2PngDefOpts: Required = { 22 | dpi: Dpi.High, 23 | } 24 | 25 | const PDF_DPI = 72 26 | function getPageViewPort(page: PDFPageProxy, dpi: Dpi | number): PageViewport { 27 | const dpiNum = dpi === Dpi.Low ? PDF_DPI : dpi === Dpi.High ? 144 : dpi 28 | const viewport = page.getViewport({ scale: 1.0 }) 29 | if (dpiNum === PDF_DPI) { 30 | return viewport 31 | } 32 | 33 | // Increase resolution 34 | const horizontalMm = convertFromPxToMm(viewport.width, PDF_DPI) 35 | const verticalMm = convertFromPxToMm(viewport.height, PDF_DPI) 36 | const actualWidth = convertFromMmToPx(horizontalMm, dpiNum) 37 | const actualHeight = convertFromMmToPx(verticalMm, dpiNum) 38 | const scale = Math.min(actualWidth / viewport.width, actualHeight / viewport.height) 39 | 40 | return page.getViewport({ scale }) 41 | } 42 | 43 | function mkPdfPagesRenderer(pdfDocument: PDFDocumentProxy, dpi: Dpi | number) { 44 | return async function ( 45 | toImage: (canvas: Canvas.Canvas) => T, 46 | toJimpInstances: (images: Array) => Promise>, 47 | ): Promise> { 48 | const images: Array = [] 49 | const totalPages = pdfDocument.numPages 50 | 51 | for (let idx = 1; idx <= totalPages; idx += 1) { 52 | const page = await pdfDocument.getPage(idx) 53 | const canvasFactory = pdfDocument.canvasFactory 54 | const viewport = getPageViewPort(page, dpi) 55 | // @ts-expect-error unknown method on Object 56 | const canvasAndContext = canvasFactory.create(viewport.width, viewport.height) 57 | const renderParameters: RenderParameters = { 58 | canvasContext: canvasAndContext.context, 59 | viewport, 60 | } 61 | await page.render(renderParameters).promise 62 | images.push(toImage(canvasAndContext.canvas)) 63 | page.cleanup() 64 | } 65 | 66 | return toJimpInstances(images) 67 | } 68 | } 69 | 70 | export async function pdf2png( 71 | pdf: string | Buffer, 72 | options: PdfToPngOptions = {}, 73 | ): Promise> { 74 | const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs') 75 | 76 | const opts = { 77 | ...pdf2PngDefOpts, 78 | ...options, 79 | } 80 | 81 | // Load PDF 82 | const pdfBuffer = Buffer.isBuffer(pdf) ? pdf : await fs.readFile(pdf) 83 | const loadingTask = getDocument({ 84 | ...DOCUMENT_INIT_PARAMS_DEFAULTS, 85 | data: new Uint8Array(pdfBuffer), 86 | }) 87 | 88 | const pdfDocument = await loadingTask.promise 89 | const renderPdfPages = mkPdfPagesRenderer(pdfDocument, opts.dpi) 90 | 91 | return renderPdfPages( 92 | (canvas) => canvas.toBuffer('image/png'), 93 | (images) => Promise.all(images.map((x) => Jimp.read(x).then((x) => x as JimpInstance))), 94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /src/test-data/expected-initial-rectangle-masks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/expected-initial-rectangle-masks.png -------------------------------------------------------------------------------- /src/test-data/pdf2png-expected/__snapshots__/TAMReview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdf2png-expected/__snapshots__/TAMReview.png -------------------------------------------------------------------------------- /src/test-data/pdf2png-expected/__snapshots__/TAMReview_without_scaling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdf2png-expected/__snapshots__/TAMReview_without_scaling.png -------------------------------------------------------------------------------- /src/test-data/pdf2png-expected/__snapshots__/single-page-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdf2png-expected/__snapshots__/single-page-small.png -------------------------------------------------------------------------------- /src/test-data/pdf2png-expected/__snapshots__/single-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdf2png-expected/__snapshots__/single-page.png -------------------------------------------------------------------------------- /src/test-data/pdf2png-expected/__snapshots__/two-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdf2png-expected/__snapshots__/two-page.png -------------------------------------------------------------------------------- /src/test-data/pdf2png-expected/cmaps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdf2png-expected/cmaps.png -------------------------------------------------------------------------------- /src/test-data/pdf2png-expected/should_scale_using_custom_DPI.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdf2png-expected/should_scale_using_custom_DPI.png -------------------------------------------------------------------------------- /src/test-data/pdf2png-expected/two-page_png_per_page_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdf2png-expected/two-page_png_per_page_1.png -------------------------------------------------------------------------------- /src/test-data/pdf2png-expected/two-page_png_per_page_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdf2png-expected/two-page_png_per_page_2.png -------------------------------------------------------------------------------- /src/test-data/pdf2png-expected/two-page_png_per_page_scaled_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdf2png-expected/two-page_png_per_page_scaled_1.png -------------------------------------------------------------------------------- /src/test-data/pdf2png-expected/two-page_png_per_page_scaled_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdf2png-expected/two-page_png_per_page_scaled_2.png -------------------------------------------------------------------------------- /src/test-data/pdfs/TAMReview.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdfs/TAMReview.pdf -------------------------------------------------------------------------------- /src/test-data/pdfs/barcodes-1.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdfs/barcodes-1.pdf -------------------------------------------------------------------------------- /src/test-data/pdfs/cmaps.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdfs/cmaps.pdf -------------------------------------------------------------------------------- /src/test-data/pdfs/single-page-small.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdfs/single-page-small.pdf -------------------------------------------------------------------------------- /src/test-data/pdfs/single-page.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdfs/single-page.pdf -------------------------------------------------------------------------------- /src/test-data/pdfs/two-page.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/pdfs/two-page.pdf -------------------------------------------------------------------------------- /src/test-data/sample-image-2.diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/sample-image-2.diff.png -------------------------------------------------------------------------------- /src/test-data/sample-image-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/sample-image-2.png -------------------------------------------------------------------------------- /src/test-data/sample-image-expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/sample-image-expected.png -------------------------------------------------------------------------------- /src/test-data/sample-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/sample-image.png -------------------------------------------------------------------------------- /src/test-data/single-page-expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/single-page-expected.png -------------------------------------------------------------------------------- /src/test-data/single-page-small-expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/single-page-small-expected.png -------------------------------------------------------------------------------- /src/test-data/two-page-expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/src/test-data/two-page-expected.png -------------------------------------------------------------------------------- /src/toMatchPdfSnapshot.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path' 2 | import { comparePdfToSnapshot, CompareOptions } from './compare-pdf-to-snapshot' 3 | 4 | declare global { 5 | // eslint-disable-next-line @typescript-eslint/no-namespace 6 | namespace jest { 7 | interface Matchers { 8 | toMatchPdfSnapshot(options?: CompareOptions): R 9 | } 10 | } 11 | } 12 | 13 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 14 | // @ts-ignore 15 | const jestExpect = global.expect 16 | 17 | if (jestExpect !== undefined) { 18 | jestExpect.extend({ 19 | // TODO: use jest snapshot functionality 20 | toMatchPdfSnapshot(pdf: string | Buffer, options?: CompareOptions) { 21 | const { isNot, testPath, currentTestName } = this 22 | if (isNot) { 23 | throw new Error('Jest: `.not` cannot be used with `.toMatchPdfSnapshot()`.') 24 | } 25 | 26 | const currentDirectory = dirname(testPath) 27 | const snapshotName = currentTestName.split(' ').join('_') 28 | 29 | return comparePdfToSnapshot(pdf, currentDirectory, snapshotName, options).then((pass) => ({ 30 | pass, 31 | message: () => 'Does not match with snapshot.', 32 | })) 33 | }, 34 | }) 35 | } else { 36 | console.error( 37 | "Unable to find Jest's global expect." + 38 | '\nPlease check you have added toMatchPdfSnapshot correctly to your jest configuration.', 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Enum representing predefined DPI (Dots Per Inch) values. 3 | */ 4 | export enum Dpi { 5 | Low = 72, 6 | High = 144, 7 | } 8 | 9 | /** 10 | * Configuration options for converting a PDF to PNG format. 11 | */ 12 | export type PdfToPngOptions = { 13 | /** 14 | * The DPI value used to calculate image resolution. 15 | * 16 | * @remarks 17 | * - Use `Dpi.Low` for the default PDF viewport size at 72 DPI. This option generates an image with lower resolution, resulting in lesser quality but faster processing time. At this setting, one PDF point corresponds to one pixel. 18 | * - Use `Dpi.High` for twice the default PDF viewport size at 144 DPI. This option provides better image quality at the cost of longer processing time. At this setting, one PDF point corresponds to two pixels. 19 | * - You can also provide a custom DPI value as a number. 20 | * 21 | * @defaultValue Dpi.High 22 | */ 23 | dpi?: Dpi | number 24 | } 25 | -------------------------------------------------------------------------------- /test/__snapshots__/jest_smoke_tests_custom_matcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/test/__snapshots__/jest_smoke_tests_custom_matcher.png -------------------------------------------------------------------------------- /test/__snapshots__/jest_smoke_tests_plain_fn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshensky/pdf-visual-diff/6b21912a02b1274b042c81dc36e79c4e6f8e870f/test/__snapshots__/jest_smoke_tests_plain_fn.png -------------------------------------------------------------------------------- /test/jest.test.js: -------------------------------------------------------------------------------- 1 | const { join } = require('node:path') 2 | const { comparePdfToSnapshot } = require('../lib') 3 | 4 | const twoPagePdfPath = join(__dirname, '../src/test-data/pdfs/', 'two-page.pdf') 5 | const snapshotDir = join(__dirname) 6 | 7 | /** @type {import('../lib').CompareOptions} */ 8 | const opts = { 9 | pdf2PngOptions: { dpi: 72 }, 10 | } 11 | 12 | describe('jest smoke tests', () => { 13 | test( 14 | 'plain fn', 15 | () => 16 | comparePdfToSnapshot(twoPagePdfPath, snapshotDir, 'jest_smoke_tests_plain_fn', opts).then( 17 | (isEqual) => { 18 | expect(isEqual).toEqual(true) 19 | }, 20 | ), 21 | 10_000, 22 | ) 23 | 24 | test('custom matcher', () => expect(twoPagePdfPath).toMatchPdfSnapshot(opts), 10_000) 25 | }) 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "Node16", 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "moduleResolution": "Node16", 8 | "outDir": "./lib", 9 | "declaration": true, 10 | "noEmitOnError": false, 11 | "sourceMap": true, 12 | "lib": ["dom", "es2017"], 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "strictFunctionTypes": true, 17 | "noImplicitThis": true, 18 | "alwaysStrict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noImplicitReturns": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "skipLibCheck": true, 24 | "esModuleInterop": false, 25 | }, 26 | "include": ["./src/**/*"] 27 | } 28 | --------------------------------------------------------------------------------