From b63d2eb5d33d91391bb85b5be7b0d3ba41a36f39 Mon Sep 17 00:00:00 2001 From: Josh McLeod Date: Tue, 10 Mar 2026 22:02:10 -0400 Subject: [PATCH 1/3] Fix PDF viewer link annotations Render PDF link annotations in rm-pdf-page so links are clickable, and add a regression test for external links. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/components/pdf-viewer/page/pdf-page.js | 62 +++++++++++++++++++ .../pdf-viewer/page/pdf-page.styles.js | 15 +++++ test/components/pdf-viewer.test.js | 38 +++++++++++- 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/components/pdf-viewer/page/pdf-page.js b/src/components/pdf-viewer/page/pdf-page.js index d776d37..ac5466d 100644 --- a/src/components/pdf-viewer/page/pdf-page.js +++ b/src/components/pdf-viewer/page/pdf-page.js @@ -80,6 +80,13 @@ export default class PDFPage extends PDFViewerComponent { textLayerDiv.innerHTML = '' await this.renderTextLayer(viewport, textLayerDiv) + + const annotationLayerDiv = this.shadowRoot.querySelector('.annotation-layer') + annotationLayerDiv.style.width = `${viewport.width}px` + annotationLayerDiv.style.height = `${viewport.height}px` + annotationLayerDiv.innerHTML = '' + + await this.renderAnnotationLayer(viewport, annotationLayerDiv) } catch (error) { if (error.name !== 'RenderingCancelledException') { console.error('Error rendering page:', error) @@ -143,6 +150,60 @@ export default class PDFPage extends PDFViewerComponent { } } + async renderAnnotationLayer(viewport, annotationLayerDiv) { + if (!this.page?.getAnnotations) return + + try { + const annotations = await this.page.getAnnotations({ intent: 'display' }) + const linkAnnotations = annotations.filter(annotation => + annotation?.subtype === 'Link' && + Array.isArray(annotation.rect) && + (annotation.url || annotation.unsafeUrl) + ) + + linkAnnotations.forEach(annotation => { + const linkUrl = annotation.url || annotation.unsafeUrl + const viewportRect = this._getViewportRect(annotation.rect, viewport) + if (!viewportRect) return + + const [x1, y1, x2, y2] = viewportRect + const link = document.createElement('a') + + link.href = linkUrl + link.target = '_blank' + link.rel = 'noopener noreferrer' + link.style.position = 'absolute' + link.style.left = `${Math.min(x1, x2)}px` + link.style.top = `${Math.min(y1, y2)}px` + link.style.width = `${Math.abs(x2 - x1)}px` + link.style.height = `${Math.abs(y2 - y1)}px` + link.style.display = 'block' + link.style.pointerEvents = 'auto' + link.setAttribute('aria-label', `Open link: ${linkUrl}`) + + annotationLayerDiv.appendChild(link) + }) + } catch (error) { + console.error('Error rendering annotation layer:', error) + } + } + + _getViewportRect(annotationRect, viewport) { + if (typeof viewport.convertToViewportRectangle === 'function') { + return viewport.convertToViewportRectangle(annotationRect) + } + + const [x1, y1, x2, y2] = annotationRect + const scale = viewport.scale || 1 + + return [ + Math.min(x1, x2) * scale, + viewport.height - (Math.max(y1, y2) * scale), + Math.max(x1, x2) * scale, + viewport.height - (Math.min(y1, y2) * scale) + ] + } + highlightText(element, text, itemStart, matches) { element.innerHTML = '' @@ -197,6 +258,7 @@ export default class PDFPage extends PDFViewerComponent {
+
` } diff --git a/src/components/pdf-viewer/page/pdf-page.styles.js b/src/components/pdf-viewer/page/pdf-page.styles.js index dd9401b..be12b4f 100644 --- a/src/components/pdf-viewer/page/pdf-page.styles.js +++ b/src/components/pdf-viewer/page/pdf-page.styles.js @@ -13,6 +13,8 @@ export default css` canvas { display: block; box-shadow: 0 var(--theme-spacing-xs, 0.25rem) var(--theme-spacing-sm, 0.5rem) var(--theme-shadow, rgba(0, 0, 0, 0.1)); + position: relative; + z-index: 0; } .text-layer { @@ -24,6 +26,7 @@ export default css` line-height: 1; text-align: initial; pointer-events: auto; + z-index: 1; } .text-layer > div { @@ -38,4 +41,16 @@ export default css` .text-layer > div::selection { background-color: var(--theme-primary-transparent); } + + .annotation-layer { + position: absolute; + left: 0; + top: 0; + z-index: 2; + pointer-events: auto; + } + + .annotation-layer a { + text-decoration: none; + } ` diff --git a/test/components/pdf-viewer.test.js b/test/components/pdf-viewer.test.js index 41db421..8fd2325 100644 --- a/test/components/pdf-viewer.test.js +++ b/test/components/pdf-viewer.test.js @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import * as pdfjsLib from 'pdfjs-dist' import '../../src/components/pdf-viewer/pdf-viewer.js' -import { createMockPDFDocument, waitForCondition } from '../helpers/test-utils.js' +import { createMockPDFDocument, createMockPage, waitForCondition } from '../helpers/test-utils.js' vi.mock('pdfjs-dist', async () => { const actual = await vi.importActual('pdfjs-dist') @@ -254,6 +254,42 @@ describe('PDFViewer Component', () => { }) }) + + describe('Link annotations', () => { + beforeEach(async () => { + const pageWithLink = createMockPage(1) + pageWithLink.getAnnotations = vi.fn().mockResolvedValue([ + { + subtype: 'Link', + url: 'https://example.com/docs', + rect: [100, 100, 220, 140] + } + ]) + + pdfjsLib.getDocument.mockReturnValue({ + promise: Promise.resolve({ + numPages: 1, + getPage: vi.fn().mockResolvedValue(pageWithLink) + }) + }) + + element = await createViewer({ src: '/test.pdf', open: true }) + await waitForCondition(() => element.pdfDoc !== null) + }) + + it('should render clickable links from PDF annotations', async () => { + const canvas = element.shadowRoot.querySelector('rm-pdf-canvas') + await waitForCondition(() => canvas?.shadowRoot?.querySelector('rm-pdf-page')) + + const page = canvas.shadowRoot.querySelector('rm-pdf-page') + await waitForCondition(() => page?.shadowRoot?.querySelector('.annotation-layer a')) + + const link = page.shadowRoot.querySelector('.annotation-layer a') + expect(link).toBeDefined() + expect(link.getAttribute('href')).toBe('https://example.com/docs') + }) + }) + describe('Toolbar', () => { beforeEach(async () => { element = await createViewer({ src: '/test.pdf', open: true }) From bdd46546b68c8d583fd67531a31cd620cb823853 Mon Sep 17 00:00:00 2001 From: Josh McLeod Date: Wed, 11 Mar 2026 13:48:17 -0400 Subject: [PATCH 2/3] Code review response --- src/components/pdf-viewer/page/pdf-page.js | 65 +++++++++---------- .../pdf-viewer/page/pdf-page.styles.js | 6 ++ 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/src/components/pdf-viewer/page/pdf-page.js b/src/components/pdf-viewer/page/pdf-page.js index ac5466d..ba77ad5 100644 --- a/src/components/pdf-viewer/page/pdf-page.js +++ b/src/components/pdf-viewer/page/pdf-page.js @@ -1,4 +1,4 @@ -import { html } from 'lit' +import { html, render } from 'lit' import * as pdfjsLib from 'pdfjs-dist' import { PDFViewerComponent } from '../pdf-viewer-component.js' import { normalizeText } from '../helpers/text-helper.js' @@ -86,7 +86,7 @@ export default class PDFPage extends PDFViewerComponent { annotationLayerDiv.style.height = `${viewport.height}px` annotationLayerDiv.innerHTML = '' - await this.renderAnnotationLayer(viewport, annotationLayerDiv) + await this.renderLinkAnnotationLayer(viewport, annotationLayerDiv) } catch (error) { if (error.name !== 'RenderingCancelledException') { console.error('Error rendering page:', error) @@ -150,9 +150,7 @@ export default class PDFPage extends PDFViewerComponent { } } - async renderAnnotationLayer(viewport, annotationLayerDiv) { - if (!this.page?.getAnnotations) return - + async renderLinkAnnotationLayer(viewport, annotationLayerDiv) { try { const annotations = await this.page.getAnnotations({ intent: 'display' }) const linkAnnotations = annotations.filter(annotation => @@ -161,47 +159,46 @@ export default class PDFPage extends PDFViewerComponent { (annotation.url || annotation.unsafeUrl) ) - linkAnnotations.forEach(annotation => { + const links = linkAnnotations.map(annotation => { const linkUrl = annotation.url || annotation.unsafeUrl const viewportRect = this._getViewportRect(annotation.rect, viewport) - if (!viewportRect) return - - const [x1, y1, x2, y2] = viewportRect - const link = document.createElement('a') - - link.href = linkUrl - link.target = '_blank' - link.rel = 'noopener noreferrer' - link.style.position = 'absolute' - link.style.left = `${Math.min(x1, x2)}px` - link.style.top = `${Math.min(y1, y2)}px` - link.style.width = `${Math.abs(x2 - x1)}px` - link.style.height = `${Math.abs(y2 - y1)}px` - link.style.display = 'block' - link.style.pointerEvents = 'auto' - link.setAttribute('aria-label', `Open link: ${linkUrl}`) - - annotationLayerDiv.appendChild(link) + if (!viewportRect) return html`` + + const [left, top, width, height] = viewportRect + return html` + + ` }) + render(html`${links}`, annotationLayerDiv) } catch (error) { console.error('Error rendering annotation layer:', error) } } _getViewportRect(annotationRect, viewport) { + let coords if (typeof viewport.convertToViewportRectangle === 'function') { - return viewport.convertToViewportRectangle(annotationRect) + coords = viewport.convertToViewportRectangle(annotationRect) + } else { + const [x1, y1, x2, y2] = annotationRect + const scale = viewport.scale || 1 + coords = [ + Math.min(x1, x2) * scale, + viewport.height - (Math.max(y1, y2) * scale), + Math.max(x1, x2) * scale, + viewport.height - (Math.min(y1, y2) * scale) + ] } - const [x1, y1, x2, y2] = annotationRect - const scale = viewport.scale || 1 - - return [ - Math.min(x1, x2) * scale, - viewport.height - (Math.max(y1, y2) * scale), - Math.max(x1, x2) * scale, - viewport.height - (Math.min(y1, y2) * scale) - ] + const [x1, y1, x2, y2] = coords + return [Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1)] } highlightText(element, text, itemStart, matches) { diff --git a/src/components/pdf-viewer/page/pdf-page.styles.js b/src/components/pdf-viewer/page/pdf-page.styles.js index be12b4f..814b086 100644 --- a/src/components/pdf-viewer/page/pdf-page.styles.js +++ b/src/components/pdf-viewer/page/pdf-page.styles.js @@ -53,4 +53,10 @@ export default css` .annotation-layer a { text-decoration: none; } + + .annotation-link { + position: absolute; + display: block; + pointer-events: auto; + } ` From 30fa607a056ca411848d8a0fee1b52b8bafd4f3e Mon Sep 17 00:00:00 2001 From: Josh McLeod Date: Wed, 11 Mar 2026 17:00:08 -0400 Subject: [PATCH 3/3] Update version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 34d5ed2..ca3bb2c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@rolemodel/spider", "description": "Shared high level web components for RoleModel Software and beyond", "packageManager": "yarn@4.12.0", - "version": "0.0.7", + "version": "0.0.8", "author": "RoleModel Software", "license": "MIT", "type": "module",