Skip to content
Open
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
49 changes: 49 additions & 0 deletions packages/examples/vite/src/examples/endnote/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React from 'react';
import { Page, Document, Link, Text, View } from '@react-pdf/renderer';

/**
* Demonstrates bidirectional endnote links.
*
* Key insight: The destination `id` should be on a block-level element (View),
* NOT on inline text. Links should wrap text directly for proper clickable areas.
*/
const Endnote = () => (
<Document>
<Page size="A4" style={{ padding: 30 }}>
<View id="fn-src-1">
<Text style={{ fontSize: 12, marginBottom: 15 }}>
This demonstrates bidirectional endnote links. Click the superscript
below to jump to the endnote, then click the back link to return.
</Text>

<Text style={{ fontSize: 12 }}>
Here is some body text with a reference to an endnote{' '}
<Link href="#fn-note-1" style={{ fontSize: 10, verticalAlign: 'super', color: '#0066cc' }}>¹</Link>
{' '}that provides additional context. This demonstrates the "first
occurrence wins" fix for wrapped text destinations.
</Text>
</View>
</Page>

<Page size="A4" style={{ padding: 30 }}>
<View id="fn-note-1">
<Text style={{ fontSize: 12, marginBottom: 10 }}>
<Text style={{ fontWeight: 'bold' }}>1. </Text>
This is the endnote explaining the reference. The link below should
take you back to the paragraph on page 1.
</Text>

<Link href="#fn-src-1">
<Text style={{ fontSize: 11, color: '#0066cc' }}>← Back to reference ¹</Text>
</Link>
</View>
</Page>
</Document>
);

export default {
id: 'endnote',
name: 'Endnote',
description: 'Bidirectional endnote links',
Document: Endnote,
};
2 changes: 2 additions & 0 deletions packages/examples/vite/src/examples/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import duplicatedImages from './duplicated-images';
import ellipsis from './ellipsis';
import emoji from './emoji';
import endnote from './endnote';
import fontFamilyFallback from './font-family-fallback';
import fontWeight from './font-weight';
import fractals from './fractals';
Expand All @@ -25,6 +26,7 @@ const EXAMPLES = [
duplicatedImages,
ellipsis,
emoji,
endnote,
fontFamilyFallback,
fontWeight,
fractals,
Expand Down
6 changes: 5 additions & 1 deletion packages/render/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { Context } from './types';

const render = (ctx: Context, doc: SafeDocumentNode) => {
const pages = doc.children || [];
const options = { imageCache: new Map(), fieldSets: [] };
const options = {
imageCache: new Map(),
fieldSets: [],
registeredDestinations: new Set<string>(),
};

pages.forEach((page) => renderNode(ctx, page, options));

Expand Down
15 changes: 11 additions & 4 deletions packages/render/src/operations/setDestination.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { SafeNode } from '@react-pdf/layout';

import { Context } from '../types';
import { Context, RenderOptions } from '../types';

const setDestination = (ctx: Context, node: SafeNode) => {
const setDestination = (ctx: Context, node: SafeNode, options: RenderOptions) => {
if (!node.box) return;
if (!node.props) return;

if ('id' in node.props) {
ctx.addNamedDestination(node.props.id!, 'XYZ', null, node.box.top, null);
if ('id' in node.props && node.props.id) {
const id = node.props.id;

// Only register the first occurrence of each ID to prevent
// wrapped text fragments from overwriting the destination
if (!options.registeredDestinations.has(id)) {
options.registeredDestinations.add(id);
ctx.addNamedDestination(id, 'XYZ', null, node.box.top, null);
}
}
};

Expand Down
2 changes: 1 addition & 1 deletion packages/render/src/primitives/renderNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const renderNode = (ctx: Context, node: SafeNode, options: RenderOptions) => {

if (cleanUpFn) cleanUpFn(ctx, node, options);

setDestination(ctx, node);
setDestination(ctx, node, options);
renderDebug(ctx, node);

ctx.restore();
Expand Down
1 change: 1 addition & 0 deletions packages/render/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ export type Context = typeof PDFKitDocument & {
export interface RenderOptions {
imageCache: Map<string | undefined, any>;
fieldSets: (typeof PDFKitReference)[];
registeredDestinations: Set<string>;
}
66 changes: 64 additions & 2 deletions packages/render/tests/operations/setDestination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ describe('operations setDestination', () => {
const box = { top: 20 };
const props = { id: 'test' };
const doc = { type: P.View, style: {}, props, box } as SafeNode;
const options = { registeredDestinations: new Set<string>() };

setDestination(ctx, doc);
setDestination(ctx, doc, options);

expect(ctx.addNamedDestination.mock.calls).toHaveLength(1);
expect(ctx.addNamedDestination.mock.calls[0][0]).toBe('test');
Expand All @@ -23,9 +24,70 @@ describe('operations setDestination', () => {
test('should not call addNamedDestination method to passed context if id missed', () => {
const ctx = createCTX();
const doc = { type: P.View, style: {}, props: {} } as SafeNode;
const options = { registeredDestinations: new Set<string>() };

setDestination(ctx, doc);
setDestination(ctx, doc, options);

expect(ctx.addNamedDestination.mock.calls).toHaveLength(0);
});

test('should only register the first occurrence of duplicate IDs (first wins)', () => {
const ctx = createCTX();
const options = { registeredDestinations: new Set<string>() };

// First fragment (page 1)
const fragment1 = {
type: P.Text,
style: {},
props: { id: 'wrapped-text' },
box: { top: 100 },
} as SafeNode;

// Second fragment (page 2) - should be ignored
const fragment2 = {
type: P.Text,
style: {},
props: { id: 'wrapped-text' },
box: { top: 200 },
} as SafeNode;

setDestination(ctx, fragment1, options);
setDestination(ctx, fragment2, options);

// Should only be called once for the first fragment
expect(ctx.addNamedDestination.mock.calls).toHaveLength(1);
expect(ctx.addNamedDestination.mock.calls[0][0]).toBe('wrapped-text');
expect(ctx.addNamedDestination.mock.calls[0][3]).toBe(100); // First fragment's y position

// Verify the ID was tracked
expect(options.registeredDestinations.has('wrapped-text')).toBe(true);
});

test('should allow different IDs to be registered', () => {
const ctx = createCTX();
const options = { registeredDestinations: new Set<string>() };

const node1 = {
type: P.Text,
style: {},
props: { id: 'destination-1' },
box: { top: 100 },
} as SafeNode;

const node2 = {
type: P.Text,
style: {},
props: { id: 'destination-2' },
box: { top: 200 },
} as SafeNode;

setDestination(ctx, node1, options);
setDestination(ctx, node2, options);

// Both should be registered
expect(ctx.addNamedDestination.mock.calls).toHaveLength(2);
expect(ctx.addNamedDestination.mock.calls[0][0]).toBe('destination-1');
expect(ctx.addNamedDestination.mock.calls[1][0]).toBe('destination-2');
expect(options.registeredDestinations.size).toBe(2);
});
});