Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ Samples showing how to get started with WebViewer in different environments.
### 3rd Party Integrations
Samples showing how to integrate and use WebViewer in 3rd party platforms.

- [webviewer-ask-ai](./webviewer-ask-ai) - Integrate WebViewer with Artificial Intelligence
- [webviewer-salesforce](./webviewer-salesforce) - Integrate WebViewer in Salesforce
- [webviewer-salesforce-attachments](./webviewer-salesforce-attachments) - View Salesforce record attachments in WebViewer
- [webviewer-mendix](./webviewer-mendix) - Integrate WebViewer into a Mendix low-code app
Expand Down Expand Up @@ -80,3 +79,9 @@ Samples showing how to use various WebViewer features.
- [webviewer-range-request](./webviewer-range-request) - Setup range requests on the backend server for loading linearized PDFs in the WebViewer
- [webviewer-react-canvasToPDF](./webviewer-react-canvasToPDF) - Export a canvas to PDF with WebViewer

### Artificial Intelligence
Samples showing how to integrate WebViewer with Artificial Intelligence.

- [webviewer-ask-ai](./webviewer-ask-ai) - Enable chat-based Q&A, document and selected-text summarization, keyword extraction, and contextual prompts that lets users ask questions about their PDFs
- [webviewer-redaction-ai](./webviewer-redaction-ai) - Identify and apply redaction of the personal information in the provided PDF

1 change: 1 addition & 0 deletions lerna.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"webviewer-react",
"webviewer-react-canvasToPDF",
"webviewer-realtime-collaboration-sqlite3",
"webviewer-redaction-ai",
"webviewer-server-side-search",
"webviewer-svelte",
"webviewer-tomcat-java",
Expand Down
10 changes: 10 additions & 0 deletions webviewer-redaction-ai/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
DOTENV_CONFIG_QUIET=true

# OpenAI Configuration
OPENAI_API_KEY=your-openai-api-key-here
OPENAI_MODEL=your-openai-model-here
OPENAI_MAX_TOKENS=your-openai-max-tokens-here
OPENAI_TEMPERATURE=your-openai-temperature-here

# Server Configuration
PORT=4040
7 changes: 7 additions & 0 deletions webviewer-redaction-ai/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Misc
.DS_Store
node_modules

# WebViewer
client/lib
client/license-key.js
2 changes: 2 additions & 0 deletions webviewer-redaction-ai/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Copyright (c) 2026 Apryse Software Inc. All Rights Reserved.
WebViewer UI project/codebase or any derived works is only permitted in solutions with an active commercial Apryse WebViewer license. For exact licensing terms refer to the commercial WebViewer license. For licensing, pricing, or product questions, Contact [Sales](https://apryse.com/form/contact-sales).
49 changes: 49 additions & 0 deletions webviewer-redaction-ai/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# WebViewer - Redaction AI sample

Add an AI-powered assistant to WebViewer, detect Personally Identifiable Information (PII) in the provided PDF, and apply redaction to the identified information.

[WebViewer](https://apryse.com/products/webviewer) is a powerful JavaScript-based PDF Library that is part of the [Apryse SDK](https://apryse.com/).

- [WebViewer Documentation](https://docs.apryse.com/web/guides/get-started)
- [WebViewer Demo](https://showcase.apryse.com/)

<video width="100%" autoplay loop muted playsinline>
<source src="./sample.mp4" type="video/mp4">
</video>

## Get Started

A license key is required to run WebViewer. You can obtain a trial key in our [get started guides](https://docs.apryse.com/web/guides/get-started), or by signing-up on our [developer portal](https://dev.apryse.com/).

## Initial setup

Before you begin, make sure the development environment includes [Node.js](https://nodejs.org/en/).

## Install

```
git clone --depth=1 https://github.com/ApryseSDK/webviewer-samples.git
cd webviewer-samples/webviewer-redaction-ai
npm install
```

## Configuration

OpenAI is the default backend for this sample. To get started, rename `.env.example` file into `.env` and fill the following:

```
OPENAI_API_KEY=your-openai-api-key-here
OPENAI_MODEL=your-openai-model-here
OPENAI_MAX_TOKENS=your-openai-max-tokens-here
OPENAI_TEMPERATURE=your-openai-temperature-here
```

To use another model, replace the LangChain provider in [server/llmManager.js](https://github.com/ApryseSDK/webviewer-samples/blob/main/webviewer-redaction-ai/server/llmManager.js#L23), install the corresponding provider package, and update the .env variables for that model provider.

## Run

```
npm start
```

This will start a server that you can access the WebViewer client at http://localhost:4040/client/index.html, and manage the connection to the OpenAI on backend.
72 changes: 72 additions & 0 deletions webviewer-redaction-ai/__mocks__/webviewer-redaction-ai.mock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// This file is used to mock the server responses for document analysis in testing scenarios.

export const MOCK_DATA = {
documentText: 'Peady, Eff, & Wright Exporting\n18th Floor, 2822 Glenoaks St.\nMargaritaville, QJ\nPhone 291 555 5555\nINVOICE\nINVOICE #398\nDATE: 4/1/2018\nTO:\nHarry Styles\nAce Deekay Shipping\n8710 Oakarum Bay\nDotjayess, EH\n427 555 5555\nCOMMENTS OR SPECIAL INSTRUCTIONS:\nPLEASE PAY ON RECEIPT OF INVOICE\nSALESPERSON\nP.O. NUMBER\nMary B. Dey\nQUANTITY\n1\n2\n20 boxes\n1\nLightweight Lawn Chair\nHelium Canister (2 cubic meters)\nHigh-strength Balloons\nSelfie-stick\n837626\nREQUISITIONER\nDamian Nowpleese\nDESCRIPTION\nSHIPPED VIA\nSpeedy Delivery\nF.O.B. POINT\nN/A\nUNIT PRICE\n89.99\n199.99\n5.00\n27.99\nTERMS\nDue on receipt\nTOTAL\n89.99\n399.98\n100.00\n27.99\nSUBTOTAL\nSALES TAX\nSHIPPING & HANDLING\nTOTAL DUE\nMake all checks payable to Peady, Eff, & Wright Exporting.\nIf you have any questions concerning this invoice, contact: us at 291 555 5555 or info@peadyeffwrighters.com.\n617.96\n30.90\n45.00\n693.86\nTHANK YOU FOR YOUR BUSINESS!\nCarrie Underwood: 1234-5678-3456-8494\nJohn Smith: 8374-3938-3198-0989\nEdward Harrison: 8937-1834-0934-1789\nMary Wang: 2233-7987-1235-9087\nLisa Fay: 6753-2654-0988-1123\nBen Franklin: 4455-346543-31003',
analysisText: '18th Floor, 2822 Glenoaks St.\nMargaritaville, QJ\nHarry Styles\n291 555 5555\n8710 Oakarum Bay\nDotjayess, EH\n427 555 5555\nMary B. Dey\nDamian Nowpleese\ninfo@peadyeffwrighters.com\nCarrie Underwood\n1234-5678-3456-8494\nJohn Smith\n8374-3938-3198-0989\nEdward Harrison\n8937-1834-0934-1789\nMary Wang\n2233-7987-1235-9087\nLisa Fay\n6753-2654-0988-1123\nBen Franklin\n4455-346543-31003'
};

// Utility function to safely parse JSON
// returning an empty object on failure.
const safeJsonParse = (value) => {
try {
return JSON.parse(value || '{}');
} catch {
return {};
}
};

// Helper function to create a JSON
// response for route fulfillment.
const jsonResponse = (payload) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(payload)
});

// Registers API route mocks for the AI PII redaction tool tests.
export const registerApiRouteMocks = async (page) => {
const calls = {
sendText: 0,
analyzePII: 0,
getResults: 0,
sendTextPayload: null,
analyzePIIPayload: null
};

// Mocking the send-text endpoint to capture document text and return a success response.
await page.route('**/api/send-text', async (route) => {
calls.sendText += 1;
calls.sendTextPayload = safeJsonParse(route.request().postData());

await route.fulfill(jsonResponse({
success: true,
message: 'Document text received successfully',
textLength: calls.sendTextPayload.documentText?.length || 0,
pageCount: calls.sendTextPayload.documentText?.pageCount || 0
}));
});

// Mocking the analyze-pii endpoint to capture analysis requests and return a success response.
await page.route('**/api/analyze-pii', async (route) => {
calls.analyzePII += 1;
calls.analyzePIIPayload = safeJsonParse(route.request().postData());

await route.fulfill(jsonResponse({
success: true,
message: 'PII analysis completed'
}));
});

// Mocking the get-results endpoint to return the analysis results.
await page.route('**/api/get-results', async (route) => {
calls.getResults += 1;
await route.fulfill(jsonResponse({
success: true,
message: 'Document analyzed successfully with AI',
aiProcessing: true,
analysis: MOCK_DATA.analysisText
}));
});

return calls;
};
174 changes: 174 additions & 0 deletions webviewer-redaction-ai/__tests__/e2e/webviewer-redaction-ai.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// This file contains end-to-end tests for the AI PII redaction tool in WebViewer.
// It uses Playwright for testing the UI interactions and validating the functionality
// of the AI PII redaction tool.

import { test, expect } from '@playwright/test';
import { MOCK_DATA, registerApiRouteMocks } from '../../__mocks__/webviewer-redaction-ai.mock.js';

let mockCalls = null;

// Before each test, we set up the API route mocks to intercept and
// simulate backend responses for the AI PII redaction workflow.
// This allows us to test the UI interactions and logic without
// relying on actual backend services.
test.beforeEach(async ({ page }) => {
mockCalls = await registerApiRouteMocks(page);
});

const normalizeMultilineText = (value) =>
(value || '')
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line !== '')
.join('\n');

// Validating that the AI PII redaction tool button exists in the DOM.
test('PII redaction tool button visibility', async ({ page }) => {
// Go to the app page (adjust the URL if needed)
await page.goto('/client/index.html');

// Click the Redact tab to activate the Redact toolbar group.
await page.locator('button[data-element="toolbarGroup-Redact"]').click();

// Locate the AIPIIRedactionToolButton button in ModularHeader
let component = page.locator('button[data-element="AIPIIRedactionToolButton"]');
await expect(component).toHaveCount(1);
await expect(component).toBeVisible();
});

// Applying AI PII redaction with mocked endpoint responses.
test('Perform AI PII redaction for document text size and page count within limits', async ({ page }) => {
await page.goto('/client/index.html');

// Click the Redact tab to activate the Redact toolbar group.
await page.locator('button[data-element="toolbarGroup-Redact"]').click();

// Ensure full document text is loaded before triggering analysis requests.
await expect.poll(
() => page.evaluate(() => globalThis.loadedDocument?.text || '')
).toContain('Peady, Eff, & Wright Exporting');

// Ensure full document text length/size is within limits before triggering analysis requests.
await expect.poll(
() => page.evaluate(() => globalThis.loadedDocument?.text.length || 0)
).toBeLessThanOrEqual(30000);

// Ensure page count is within limits before triggering analysis requests.
await expect.poll(
() => page.evaluate(() => globalThis.loadedDocument?.pageCount || 0)
).toBeLessThanOrEqual(20);

// Trigger analyzeDocumentForPII + applyRedactions through the UI button.
const pIIBtn = page.locator('button[data-element="AIPIIRedactionToolButton"]');
await pIIBtn.click();

// Verify mocked endpoint workflow for send -> analyze -> get result.
await expect.poll(() => mockCalls.sendText).toBe(1);
await expect.poll(() => mockCalls.analyzePII).toBe(1);
await expect.poll(() => mockCalls.getResults).toBe(1);
await expect.poll(() => normalizeMultilineText(mockCalls.sendTextPayload?.documentText)).toBe(
normalizeMultilineText(MOCK_DATA.documentText)
);

// Complete the redaction flow and assert it closes cleanly.
const redactAllBtn = page.locator('button[data-element="redactAllMarkedButton"]');
await expect(redactAllBtn).toBeVisible();
await redactAllBtn.click();

// Confirm the apply redaction modal and ensure it disappears after confirming.
const applyRedactionBtn = page.locator('button[data-element="WarningModalSignButton"]');
await expect(applyRedactionBtn).toBeVisible();
await applyRedactionBtn.click();
await expect(applyRedactionBtn).toBeHidden();

// Ensure mocked analysis text is the value used by the UI workflow.
await expect.poll(() => page.evaluate(() => globalThis.aiAnalysisResult?.analysis)).toBe(MOCK_DATA.analysisText);
});

// Expect an alert when analysis completes but /api/get-results returns no PII findings.
test('Expect an alert when loaded document has no identifiable PII', async ({ page }) => {
await page.goto('/client/index.html');

// Click the Redact tab to activate the Redact toolbar group.
await page.locator('button[data-element="toolbarGroup-Redact"]').click();

// Wait until document wrapper is initialized before replacing the loaded text.
await expect.poll(() => page.evaluate(() => Boolean(globalThis.loadedDocument))).toBe(true);

const noPIIText = 'Quarterly operations summary for warehouse inventory and packaging materials.';
await page.evaluate((text) => {
globalThis.loadedDocument.text = text;
globalThis.loadedDocument.pageCount = 1;
}, noPIIText);

let getResultsCalls = 0;
await page.unroute('**/api/get-results');
await page.route('**/api/get-results', async (route) => {
getResultsCalls += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: false,
error: 'No analysis results found. Please analyze the document first.'
})
});
});

// Trigger analyzeDocumentForPII through the UI button and verify the alert content.
const pIIBtn = page.locator('button[data-element="AIPIIRedactionToolButton"]');
const alertPromise = page.waitForEvent('dialog');
await pIIBtn.click();
const alertDialog = await alertPromise;
expect(alertDialog.message()).toBe('No PII result found.');
await alertDialog.accept();

// Ensure the no-PII document text was submitted and the flow reached /api/get-results.
await expect.poll(() => mockCalls.sendText).toBe(1);
await expect.poll(() => mockCalls.analyzePII).toBe(1);
await expect.poll(() => getResultsCalls).toBe(1);
await expect.poll(() => normalizeMultilineText(mockCalls.sendTextPayload?.documentText)).toBe(noPIIText);
});

// Expect an alert when document text/pages values exceed limits.
test('Expect an alert when document text exceeds 30000 characters with page count above 20', async ({ page }) => {
await page.goto('/client/index.html');

// Click the Redact tab to activate the Redact toolbar group.
await page.locator('button[data-element="toolbarGroup-Redact"]').click();

// Wait until document wrapper is initialized before mutating fields.
await expect.poll(() => page.evaluate(() => Boolean(globalThis.loadedDocument))).toBe(true);

await page.evaluate(() => {
globalThis.loadedDocument.text = 'A'.repeat(30001);
globalThis.loadedDocument.pageCount = 21;
});

let sendTextCalls = 0;
await page.unroute('**/api/send-text');
await page.route('**/api/send-text', async (route) => {
sendTextCalls += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: false,
error: 'Document text size exceeds 30000 characters limit.'
})
});
});

// Trigger analyzeDocumentForPII through the UI button and verify the alert content.
const pIIBtn = page.locator('button[data-element="AIPIIRedactionToolButton"]');
const alertPromise = page.waitForEvent('dialog');
await pIIBtn.click();
const alertDialog = await alertPromise;
expect(alertDialog.message()).toBe('Document text size exceeds 30000 characters limit.');
await alertDialog.accept();

// Ensure request flow stops at /api/send-text when request validation fails.
await expect.poll(() => sendTextCalls).toBe(1);
await expect.poll(() => mockCalls.analyzePII).toBe(0);
await expect.poll(() => mockCalls.getResults).toBe(0);
});
8 changes: 8 additions & 0 deletions webviewer-redaction-ai/__tests__/package.playwright.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.43.1"
}
}
1 change: 1 addition & 0 deletions webviewer-redaction-ai/client/assets/ai-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions webviewer-redaction-ai/client/assets/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading