Skip to content

feat: Support PDF annotation #67

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 30 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2d9857a
Merge changes
personalizedrefrigerator Aug 29, 2023
b7d9ff0
Merge branch 'main' into work/pdf-support
personalizedrefrigerator Dec 11, 2023
d9783db
Update package-lock.json
personalizedrefrigerator Dec 11, 2023
e3882a6
Update to PdfJs 4
personalizedrefrigerator Dec 11, 2023
ab38a5b
Merge branch 'main' into work/pdf-support
personalizedrefrigerator Mar 24, 2024
f005714
Dependency upgrades
personalizedrefrigerator Mar 24, 2024
d42234c
Use MuPDF for annotation
personalizedrefrigerator Mar 28, 2024
fe92a1e
Merge branch 'main' into work/pdf-support
personalizedrefrigerator Nov 21, 2024
9d73736
Fixing issues caused by merge
personalizedrefrigerator Nov 21, 2024
70668f9
PDF example: Migrate worker to TypeScript, fix build
personalizedrefrigerator Nov 21, 2024
b23ab7e
Lint and format
personalizedrefrigerator Nov 21, 2024
f2655d1
Fixing build and attempt to show PDF library license in about dialog
personalizedrefrigerator Nov 21, 2024
15b5867
Merge branch 'main' into work/pdf-support
personalizedrefrigerator Nov 21, 2024
e42294d
Remove dist-test script key from packages/pdf-support
personalizedrefrigerator Nov 21, 2024
50377b5
Trying to fix PDF example build
personalizedrefrigerator Nov 21, 2024
06eca49
PDF example: Fix missing build command
personalizedrefrigerator Nov 21, 2024
62ddc82
fix: Fix text annotations move on save/load
personalizedrefrigerator Nov 24, 2024
396f87a
Merge branch 'main' into work/pdf-support
personalizedrefrigerator Nov 26, 2024
b45baaf
Post-merge cleanup
personalizedrefrigerator Nov 26, 2024
7002f1b
Merge branch 'main' into work/pdf-support
personalizedrefrigerator Dec 29, 2024
148467b
Merge branch 'main' into work/pdf-support
personalizedrefrigerator Jan 1, 2025
5c4d529
chore: Update lockfile
personalizedrefrigerator Jan 1, 2025
c2cbfb4
Merge branch 'main' into work/pdf-support
personalizedrefrigerator Jan 1, 2025
ac21a14
Update mupdf, fix build
personalizedrefrigerator Jan 1, 2025
a324286
chore: Fix linter errors
personalizedrefrigerator Jan 1, 2025
ed729ef
Merge branch 'main' into work/pdf-support
personalizedrefrigerator Jan 6, 2025
0033bb7
Update lockfiles
personalizedrefrigerator Jan 6, 2025
0cd538d
Fix missing DOM error
personalizedrefrigerator Jan 6, 2025
30fdff8
Give pages a white background
personalizedrefrigerator Jan 6, 2025
5661b19
Fix highlight and other filled annotations stroked when saved
personalizedrefrigerator Jan 6, 2025
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
6 changes: 6 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
The majority of this project is licensed under the MIT. However, packages/material-icons
and docs/examples/example-pdf may have different licesnes. See the relevant subdirectories
for details.

----

MIT License

Copyright (c) 2023-2025 Henry Heino
Expand Down
4 changes: 2 additions & 2 deletions docs/doc-pages/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"version": "0.0.0",
"dependencies": {
"@js-draw/math": "^1.22.0",
"js-draw": "^1.22.0"
"@js-draw/math": "^1.23.1",
"js-draw": "^1.23.1"
}
}
1 change: 1 addition & 0 deletions docs/examples/example-pdf/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
661 changes: 661 additions & 0 deletions docs/examples/example-pdf/LICENSE

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions docs/examples/example-pdf/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# example-custom-tools

[example.html](./example.html) | [example.ts](./example.ts)

This example shows how to create custom tools and add them to the toolbar.
Binary file added docs/examples/example-pdf/annotated-2.pdf
Binary file not shown.
Binary file added docs/examples/example-pdf/annotated.pdf
Binary file not shown.
11 changes: 11 additions & 0 deletions docs/examples/example-pdf/build-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"bundledFiles": [
{
"inPath": "./example.ts"
},
{
"inPath": "./worker.ts"
}
],
"prebuild": { "scriptPath": "./tools/prebuild.cjs" }
}
663 changes: 663 additions & 0 deletions docs/examples/example-pdf/constants.ts

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions docs/examples/example-pdf/example.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>PDF editing | js-draw examples</title>
<style>
:root .imageEditorContainer {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;

width: 100vw;
height: 100vh;
}
</style>
</head>
<body>
Loading...
</body>
<script src="./example.bundle.js"></script>
</html>
Binary file added docs/examples/example-pdf/example.pdf
Binary file not shown.
215 changes: 215 additions & 0 deletions docs/examples/example-pdf/example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// This file is part of an example licensed under the GNU AGPLv3.
// You should have received a copy of the license along with this program.
// If not, see <https://www.gnu.org/licenses/agpl-3.0.en.html>.

import * as jsdraw from 'js-draw';
import {
APIWrapper,
AnnotationAPIWrapper,
PDFBackground,
PDFDocumentWrapper,
PageAPIWrapper,
} from '@js-draw/pdf-support';
import 'js-draw/styles';
import { Color4, Rect2 } from 'js-draw';
import { ColorArray, TransferrableAnnotation } from './types';
import { agplv3License } from './constants';

let workerRequest = (_method: string, ..._args: any[]): Promise<any> => {
throw new Error('Worker not initialized');
};
const initializeWorker = () => {
return new Promise<void>((resolve) => {
const worker = new Worker('./worker.mjs', { type: 'module' });
const workerRequests = new Map<string, (arg: any) => void>();

worker.onmessage = (event) => {
const message = event.data;

if (message.type === 'Respond') {
workerRequests.get(message.id)!(message.response);
workerRequests.delete(message.id);
} else if (message.type === 'Initialized') {
resolve();
}
};

let nextId = 0;
workerRequest = (method: string, ...args: any[]) => {
return new Promise<any>((resolve) => {
const id = `request-${nextId++}`;
worker.postMessage({ method, args, id });
workerRequests.set(id, (arg) => {
resolve(arg);
});
});
};
});
};

const annotationToTransferrable = (annotation: AnnotationAPIWrapper): TransferrableAnnotation => {
const colorArray = annotation.color?.asRGBAArray();
const mainColor = annotation.color ?? annotation.fontAppearance?.color ?? Color4.black;
return {
type: annotation.type,
bbox: annotation.bbox.xywh(),
inkList: annotation.inkList.map((l) => l.map((p) => [p.x, p.y])),
vertices: annotation.vertices?.map((v) => [v.x, v.y]),
color: colorArray?.slice(0, 3) as ColorArray | undefined,
opacity: mainColor.a,
borderWidth: annotation.borderWidth,
contents: annotation.contents,
rotate: annotation.rotate,
fontAppearance: annotation.fontAppearance
? {
size: annotation.fontAppearance.size,
color: annotation.fontAppearance.color.asRGBAArray().slice(0, 3) as ColorArray,
family: annotation.fontAppearance.family,
}
: undefined,
id: annotation.id,
};
};

const annotationFromTransferrable = (annotation: TransferrableAnnotation) => {
const result: AnnotationAPIWrapper = {
type: annotation.type,
bbox: Rect2.of(annotation.bbox),
inkList: annotation.inkList.map((l: number[][]) => l.map((p) => jsdraw.Vec2.of(p[0], p[1]))),
vertices: annotation.vertices?.map((v) => jsdraw.Vec2.of(v[0], v[1])) ?? [],
color: jsdraw.Color4.fromRGBArray(annotation.color ?? [0], annotation.opacity ?? 1),
borderWidth: annotation.borderWidth,
contents: {
text: annotation.contents?.text ?? 'no',
direction: annotation.contents?.direction ?? 'ltr',
},
rotate: annotation.rotate,
fontAppearance: annotation.fontAppearance
? {
size: annotation.fontAppearance.size,
color: jsdraw.Color4.fromRGBArray(annotation.fontAppearance.color, 1),
family: annotation.fontAppearance.family ?? 'sans',
}
: undefined,
id: annotation.id,
};
return result;
};

(async () => {
await initializeWorker();

const requestFile = () => {
return new Promise<ArrayBuffer>((resolve, reject) => {
const container = document.createElement('dialog');
const input = document.createElement('input');
input.type = 'file';
input.onchange = async (_event) => {
if (input.files?.length) {
container.remove();

const buffer = await input.files.item(0)?.arrayBuffer();
if (buffer) {
resolve(buffer);
} else {
reject(new Error('No buffer found.'));
}
}
};
container.appendChild(input);
document.body.appendChild(container);
container.showModal();
});
};

const editor = new jsdraw.Editor(document.body, {
notices: [
{
heading: 'PDF rendering',
text: [
`This editor's PDF editing functionality is powered by MuPDF, which is licensed under the GNU AGPLv3. A copy of this license is included below:`,
'',
'',
agplv3License,
].join('\n'),
minimized: true,
},
],
});
const toolbar = editor.addToolbar();
editor.dispatchNoAnnounce(editor.image.setAutoresizeEnabled(true));

const pdf = await requestFile();
const docHandle = await workerRequest('openDoc', pdf, 'example.pdf');
const pageCount = await workerRequest('pageCount', docHandle);
const docWrapper: APIWrapper = {
pageCount: () => pageCount,
async loadPage(idx) {
const pageHandle = await workerRequest('loadPage', docHandle, idx);
const result: PageAPIWrapper = {
async getBBox() {
const bboxCoords = await workerRequest('page.getBBox', pageHandle);
return Rect2.of(bboxCoords);
},
async toImagelike(visibleRect: Rect2, scale: number, _showAnnotations: boolean) {
const bitmap = await workerRequest(
'page.toImagelike',
pageHandle,
visibleRect.xywh(),
scale,
);
return bitmap;
},
async getAnnotations() {
const annotationData = await workerRequest('page.getAnnotations', pageHandle);
return annotationData.map((annotation: TransferrableAnnotation) => {
return annotationFromTransferrable(annotation);
});
},
async replaceAnnotations(newAnnotations) {
const transferableAnnotatons = newAnnotations.map(annotationToTransferrable);
await workerRequest('page.setAnnotations', pageHandle, transferableAnnotatons);
},
};
return result;
},
};

const doc = PDFDocumentWrapper.fromPDF(docWrapper);
const pdfBackground = new PDFBackground(doc);
const command = editor.image.addElement(pdfBackground);
editor.dispatchNoAnnounce(command);

// TODO: Move to library
await doc.awaitPagesLoaded();
for (let i = 0; i < doc.numPages; i++) {
const annotations = await doc.getPage(i).getAnnotations();
for (const annotation of annotations) {
editor.dispatchNoAnnounce(editor.image.addElement(annotation));
}
}

let lastObjectURL: string | undefined = undefined;
const saveButton = toolbar.addSaveButton(async () => {
saveButton.setDisabled(true);
try {
await doc.applyChanges(editor.image);

if (lastObjectURL) {
URL.revokeObjectURL(lastObjectURL);
}

const buffer = await workerRequest('doc.saveToBuffer', docHandle);
const url = URL.createObjectURL(new Blob([buffer], { type: 'application/pdf' }));
const downloadLink = document.createElement('a');
downloadLink.target = '_blank';
downloadLink.href = url;
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
lastObjectURL = url;
} finally {
saveButton.setDisabled(false);
}
});
})();
21 changes: 21 additions & 0 deletions docs/examples/example-pdf/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@js-draw/example-pdf",
"version": "1.2.2",
"type": "module",
"description": "Shows loading a PDF",
"license": "AGPL-3.0-only",
"private": true,
"scripts": {
"build": "build-tool build",
"watch": "build-tool watch"
},
"dependencies": {
"@js-draw/material-icons": "^1.26.0",
"@js-draw/pdf-support": "^1.26.0",
"js-draw": "^1.26.0",
"mupdf": "1.1.0"
},
"devDependencies": {
"@js-draw/build-tool": "^1.26.0"
}
}
34 changes: 34 additions & 0 deletions docs/examples/example-pdf/tools/prebuild.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = require('node:fs/promises');

// eslint-disable-next-line @typescript-eslint/no-require-imports
const { dirname, join } = require('node:path');

const patchMuPDF = async (targetDir) => {
const missingTypeDefsFile = join(targetDir, 'mupdf-wasm.d.ts');
await fs.writeFile(
missingTypeDefsFile,
`
// PATCH:
// This change allows mupdf to build with the current TypeScript settings.
export type Pointer<T> = any;
`,
'utf-8',
);
};

const copyMuPDF = async () => {
let sourceDir = require.resolve('mupdf');
if (sourceDir.endsWith('.js')) {
sourceDir = dirname(sourceDir);
}
const targetDir = join(dirname(__dirname), 'dist', 'mupdf/');
await fs.mkdir(targetDir, { recursive: true });

console.log('cp', sourceDir, targetDir);
await fs.cp(sourceDir, targetDir, { recursive: true });

await patchMuPDF(targetDir);
};

module.exports = { default: copyMuPDF() };
9 changes: 9 additions & 0 deletions docs/examples/example-pdf/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler"
},

"include": ["**/*.ts"]
}
23 changes: 23 additions & 0 deletions docs/examples/example-pdf/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { AnnotationType } from '@js-draw/pdf-support';

export type ColorArray = [number] | [number, number, number] | [number, number, number, number];

export interface TransferrableAnnotation {
type: AnnotationType;
bbox: { x: number; y: number; w: number; h: number };
inkList: [number, number][][];
vertices: [number, number][] | undefined;
color: ColorArray | undefined;
opacity: number;
borderWidth: number;
rotate: number;
contents: { text: string; direction: 'ltr' | 'rtl' } | undefined;
fontAppearance:
| {
size: number;
color: ColorArray;
family: string;
}
| undefined;
id: string | undefined;
}
3 changes: 3 additions & 0 deletions docs/examples/example-pdf/worker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import * as mupdf from './dist/mupdf/mupdf.js';
import './worker.bundle.js';
self.mupdf = mupdf;
Loading
Loading