diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index a66eeee..da7776c 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -53,6 +53,9 @@ jobs: - name: Run Playwright tests run: npm run test:e2e + - name: Check README screenshot + run: npm run screenshots:check + - name: Upload Playwright report if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/README.md b/README.md index b69aa8a..c0df9e2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A Vue 3 component for rendering PDFs with draggable and resizable element overla **[Demo](https://libresign.github.io/pdf-elements/)** ยท [Examples](examples/) -image +![The pdf-elements demo with a sample PDF loaded and a signature element placed on the first page](img/screenshot/demo.png) ## Development diff --git a/REUSE.toml b/REUSE.toml index 90cb2c5..988e4e3 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -22,6 +22,7 @@ path = [ "examples/**", "tests/**", "dist/**", + "img/**", ] precedence = "aggregate" SPDX-FileCopyrightText = "2025 LibreCode coop and contributors" diff --git a/img/screenshot/demo.png b/img/screenshot/demo.png new file mode 100644 index 0000000..f2c2cbd Binary files /dev/null and b/img/screenshot/demo.png differ diff --git a/package-lock.json b/package-lock.json index 2c2a62f..0d240d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,8 @@ "eslint-plugin-vue": "^10.8.0", "globals": "^17.4.0", "happy-dom": "^20.7.0", + "pixelmatch": "^7.2.0", + "pngjs": "^7.0.0", "postcss": "^8.5.6", "rollup-plugin-visualizer": "^7.0.0", "typescript": "^6.0.2", @@ -786,9 +788,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -806,9 +805,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -826,9 +822,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -846,9 +839,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -866,9 +856,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -886,9 +873,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1051,6 +1035,7 @@ "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.70.0", @@ -1080,6 +1065,7 @@ "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/types": "8.70.0", @@ -1561,6 +1547,7 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz", "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-core": "3.5.42", "@vue/shared": "3.5.42" @@ -1599,6 +1586,7 @@ "integrity": "sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", @@ -1694,6 +1682,7 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1797,7 +1786,6 @@ "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "baseline-browser-mapping": "dist/cli.cjs" }, @@ -1922,8 +1910,7 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "CC-BY-4.0", - "peer": true + "license": "CC-BY-4.0" }, "node_modules/chai": { "version": "6.2.2", @@ -2164,8 +2151,7 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.413.tgz", "integrity": "sha512-F1XPKvt7HVfly5WND90ec16nFsdr4g5x/cVUP3EqjeyXynupabGDqpMa84wwvuYGDnldXLBz6DLXyZXWO9TPvw==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/entities": { "version": "7.0.1", @@ -2215,6 +2201,7 @@ "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -2635,6 +2622,7 @@ "integrity": "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -2838,6 +2826,7 @@ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -3280,7 +3269,6 @@ "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3481,6 +3469,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixelmatch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.2.0.tgz", + "integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, "node_modules/pkg-types": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", @@ -3515,6 +3516,7 @@ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -3522,6 +3524,16 @@ "node": ">=20" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -3647,6 +3659,7 @@ "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" @@ -3880,6 +3893,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3993,7 +4007,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -4028,6 +4041,7 @@ "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", @@ -4132,6 +4146,7 @@ "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.0", @@ -4231,6 +4246,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz", "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/compiler-sfc": "3.5.42", @@ -4260,6 +4276,7 @@ "integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", diff --git a/package.json b/package.json index cf599ac..83700f0 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "test:watch": "vitest", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", + "screenshots:update": "node scripts/generate-screenshots.mjs", + "screenshots:check": "node scripts/check-screenshots.mjs", "lint": "eslint . --ext .vue,.ts,.js --max-warnings=0", "lint:fix": "eslint . --ext .vue,.ts,.js --fix" }, @@ -71,6 +73,8 @@ "eslint-plugin-vue": "^10.8.0", "globals": "^17.4.0", "happy-dom": "^20.7.0", + "pixelmatch": "^7.2.0", + "pngjs": "^7.0.0", "postcss": "^8.5.6", "rollup-plugin-visualizer": "^7.0.0", "typescript": "^6.0.2", diff --git a/scripts/check-screenshots.mjs b/scripts/check-screenshots.mjs new file mode 100644 index 0000000..f6f8e2c --- /dev/null +++ b/scripts/check-screenshots.mjs @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import pixelmatch from 'pixelmatch' +import { PNG } from 'pngjs' +import { generateScreenshot, packageRoot, screenshotPath } from './generate-screenshots.mjs' + + +const maxDiffRatio = 0.001 + +const diffPath = path.join(packageRoot, 'test-results', 'demo-screenshot-diff.png') +const currentPath = path.join(packageRoot, 'test-results', 'demo-screenshot-current.png') + +async function main() { + const committed = PNG.sync.read(await readFile(screenshotPath)) + const currentImage = await generateScreenshot() + const current = PNG.sync.read(currentImage) + + if (committed.width !== current.width || committed.height !== current.height) { + throw new Error( + `Screenshot size changed: committed ${committed.width}x${committed.height}, ` + + `generated ${current.width}x${current.height}. ` + + 'Run "npm run screenshots:update" and commit the result.' + ) + } + + const {width, height} = committed + const diff = new PNG({width, height}) + const changedPixels = pixelmatch(committed.data, current.data, diff.data, width, height, { + threshold: 0.1, + }) + + const totalPixels = width * height + const ratio = changedPixels / totalPixels + const report = `${changedPixels} of ${totalPixels} pixels differ (${(ratio * 100).toFixed(4)}%)` + + if (ratio <= maxDiffRatio) { + globalThis.console.log(`Screenshot is up to date: ${report}.`) + return + } + + await mkdir(path.dirname(diffPath), {recursive: true}) + await writeFile(currentPath, currentImage) + await writeFile(diffPath, PNG.sync.write(diff)) + + throw new Error( + `${report}, above the allowed ${(maxDiffRatio * 100).toFixed(4)}%.\n` + + `Generated screenshot written to ${path.relative(packageRoot, currentPath)}.\n` + + `Visual diff written to ${path.relative(packageRoot, diffPath)}.\n` + + 'If the change is expected, run "npm run screenshots:update" and commit the result.' + ) +} + +main().catch((error) => { + globalThis.console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) diff --git a/scripts/generate-screenshots.mjs b/scripts/generate-screenshots.mjs new file mode 100644 index 0000000..e15fa06 --- /dev/null +++ b/scripts/generate-screenshots.mjs @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { writeFile } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { chromium, devices } from '@playwright/test' +import { createServer } from 'vite' + +export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +export const screenshotPath = path.join(packageRoot, 'img', 'screenshot', 'demo.png') + +export async function generateScreenshot() { + const server = await createServer() + await server.listen() + + const browser = await chromium.launch() + try { + const context = await browser.newContext({ + ...devices['Desktop Chrome'], + locale: 'en-US', + viewport: { width: 1280, height: 900 }, + }) + const page = await context.newPage() + + await page.goto(server.resolvedUrls.local[0]) + await page.getByRole('button', { name: 'Load sample PDF' }).click() + await page.locator('canvas').first().waitFor() + await page.getByRole('button', { name: 'Add Signature' }).click() + await page.getByRole('button', { name: 'Click to place' }).waitFor() + await page.locator('.overlay').first().click({ position: { x: 100, y: 300 } }) + await page.locator('.signature-box').first().waitFor() + + return await page.screenshot() + } finally { + await browser.close() + await server.close() + } +} + +async function main() { + await writeFile(screenshotPath, await generateScreenshot()) + globalThis.console.log(`Updated ${path.relative(packageRoot, screenshotPath)}`) +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null + +if (invokedPath === import.meta.url) { + main().catch((error) => { + globalThis.console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +}