diff --git a/packages/examples/next-14/app/form/page.js b/packages/examples/next-14/app/form/page.js
new file mode 100644
index 000000000..d6fa1b6a1
--- /dev/null
+++ b/packages/examples/next-14/app/form/page.js
@@ -0,0 +1,136 @@
+'use client';
+
+import dynamic from 'next/dynamic';
+import {
+ Document,
+ Page,
+ View,
+ Text,
+ Checkbox,
+ FormField,
+ TextInput,
+ Picker,
+ FormList,
+} from '@react-pdf/renderer';
+
+const PDFViewer = dynamic(
+ () => import('@react-pdf/renderer').then((mod) => mod.PDFViewer),
+ {
+ ssr: false,
+ loading: () =>
Loading...
,
+ },
+);
+
+export default function Form() {
+ const doc = (
+
+
+
+
+ TextInput
+
+
+ {/* Nested works as well */}
+
+ TextInput
+
+
+
+ Checkbox (not checked)
+
+
+ Checkbox (checked)
+
+
+ Picker
+
+
+ FormList
+
+
+
+
+
+
+
+
+ TextInput (multiline)
+
+
+
+
+
+
+
+ TextInput (no FormField)
+
+
+ Checkbox (checked, no FormField)
+
+
+
+
+ );
+
+ return {doc};
+}
diff --git a/packages/examples/next-15/app/form/page.js b/packages/examples/next-15/app/form/page.js
new file mode 100644
index 000000000..d6fa1b6a1
--- /dev/null
+++ b/packages/examples/next-15/app/form/page.js
@@ -0,0 +1,136 @@
+'use client';
+
+import dynamic from 'next/dynamic';
+import {
+ Document,
+ Page,
+ View,
+ Text,
+ Checkbox,
+ FormField,
+ TextInput,
+ Picker,
+ FormList,
+} from '@react-pdf/renderer';
+
+const PDFViewer = dynamic(
+ () => import('@react-pdf/renderer').then((mod) => mod.PDFViewer),
+ {
+ ssr: false,
+ loading: () => Loading...
,
+ },
+);
+
+export default function Form() {
+ const doc = (
+
+
+
+
+ TextInput
+
+
+ {/* Nested works as well */}
+
+ TextInput
+
+
+
+ Checkbox (not checked)
+
+
+ Checkbox (checked)
+
+
+ Picker
+
+
+ FormList
+
+
+
+
+
+
+
+
+ TextInput (multiline)
+
+
+
+
+
+
+
+ TextInput (no FormField)
+
+
+ Checkbox (checked, no FormField)
+
+
+
+
+ );
+
+ return {doc};
+}
diff --git a/packages/examples/src/form/index.jsx b/packages/examples/src/form/index.jsx
new file mode 100644
index 000000000..c83ba34fd
--- /dev/null
+++ b/packages/examples/src/form/index.jsx
@@ -0,0 +1,124 @@
+import React from 'react';
+import {
+ Document,
+ Page,
+ View,
+ Text,
+ Checkbox,
+ FormField,
+ TextInput,
+ Picker,
+ FormList,
+} from '@react-pdf/renderer';
+
+const FormPdf = () => (
+
+
+
+
+ TextInput
+
+
+ {/* Nested works as well */}
+
+ TextInput
+
+
+
+ Checkbox (not checked)
+
+
+ Checkbox (checked)
+
+
+ Picker
+
+
+ FormList
+
+
+
+
+
+
+
+
+ TextInput (multiline)
+
+
+
+
+
+
+
+ TextInput (no FormField)
+
+
+ Checkbox (checked, no FormField)
+
+
+
+
+);
+
+export default FormPdf;
diff --git a/packages/primitives/src/index.js b/packages/primitives/src/index.js
index d1bc5fb5a..6cd195099 100644
--- a/packages/primitives/src/index.js
+++ b/packages/primitives/src/index.js
@@ -8,6 +8,11 @@ export const Note = 'NOTE';
export const Path = 'PATH';
export const Rect = 'RECT';
export const Line = 'LINE';
+export const FormField = 'FORM_FIELD';
+export const TextInput = 'TEXT_INPUT';
+export const Picker = 'PICKER';
+export const Checkbox = 'CHECKBOX';
+export const FormList = 'FORM_LIST';
export const Stop = 'STOP';
export const Defs = 'DEFS';
export const Image = 'IMAGE';
diff --git a/packages/primitives/tests/index.test.js b/packages/primitives/tests/index.test.js
index bd772d521..d4c038783 100644
--- a/packages/primitives/tests/index.test.js
+++ b/packages/primitives/tests/index.test.js
@@ -39,6 +39,22 @@ describe('primitives', () => {
expect(primitives.Line).toBeTruthy();
});
+ test('should export form field', () => {
+ expect(primitives.FormField).toBeTruthy();
+ });
+
+ test('should export text input', () => {
+ expect(primitives.TextInput).toBeTruthy();
+ });
+
+ test('should export form list', () => {
+ expect(primitives.FormList).toBeTruthy();
+ });
+
+ test('should export picker', () => {
+ expect(primitives.Picker).toBeTruthy();
+ });
+
test('should export stop', () => {
expect(primitives.Stop).toBeTruthy();
});
diff --git a/packages/render/src/primitives/form/renderCheckbox.js b/packages/render/src/primitives/form/renderCheckbox.js
new file mode 100644
index 000000000..ea1fb6378
--- /dev/null
+++ b/packages/render/src/primitives/form/renderCheckbox.js
@@ -0,0 +1,24 @@
+import { parseCheckboxOptions } from '../../utils/parseFormOptions';
+
+const renderCheckbox = (ctx, node, options = {}) => {
+ const { top, left, width, height } = node.box || {};
+
+ // Element's name
+ const name = node.props?.name || '';
+ const formFieldOptions = options.formFields?.at(0);
+
+ if (!ctx._root.data.AcroForm) {
+ ctx.initForm();
+ }
+
+ ctx.formCheckbox(
+ name,
+ left,
+ top,
+ width,
+ height,
+ parseCheckboxOptions(ctx, node, formFieldOptions),
+ );
+};
+
+export default renderCheckbox;
diff --git a/packages/render/src/primitives/form/renderFormField.js b/packages/render/src/primitives/form/renderFormField.js
new file mode 100644
index 000000000..64734a207
--- /dev/null
+++ b/packages/render/src/primitives/form/renderFormField.js
@@ -0,0 +1,18 @@
+const renderFormField = (ctx, node, options = {}) => {
+ const name = node.props?.name || '';
+
+ if (!ctx._root.data.AcroForm) {
+ ctx.initForm();
+ }
+
+ const formField = ctx.formField(name);
+ const option = options;
+ if (!option.formFields) option.formFields = [formField];
+ else option.formFields.push(formField);
+};
+
+export const cleanUpFormField = (_ctx, _node, options) => {
+ options.formFields.pop();
+};
+
+export default renderFormField;
diff --git a/packages/render/src/primitives/form/renderFormList.js b/packages/render/src/primitives/form/renderFormList.js
new file mode 100644
index 000000000..56045b26e
--- /dev/null
+++ b/packages/render/src/primitives/form/renderFormList.js
@@ -0,0 +1,23 @@
+import { parsePickerAndListFieldOptions } from '../../utils/parseFormOptions';
+
+const renderFormList = (ctx, node) => {
+ const { top, left, width, height } = node.box || {};
+
+ // Element's name
+ const name = node.props?.name || '';
+
+ if (!ctx._root.data.AcroForm) {
+ ctx.initForm();
+ }
+
+ ctx.formList(
+ name,
+ left,
+ top,
+ width,
+ height,
+ parsePickerAndListFieldOptions(node),
+ );
+};
+
+export default renderFormList;
diff --git a/packages/render/src/primitives/form/renderPicker.js b/packages/render/src/primitives/form/renderPicker.js
new file mode 100644
index 000000000..b18efbfeb
--- /dev/null
+++ b/packages/render/src/primitives/form/renderPicker.js
@@ -0,0 +1,23 @@
+import { parsePickerAndListFieldOptions } from '../../utils/parseFormOptions';
+
+const renderPicker = (ctx, node) => {
+ const { top, left, width, height } = node.box || {};
+
+ // Element's name
+ const name = node.props?.name || '';
+
+ if (!ctx._root.data.AcroForm) {
+ ctx.initForm();
+ }
+
+ ctx.formCombo(
+ name,
+ left,
+ top,
+ width,
+ height,
+ parsePickerAndListFieldOptions(node),
+ );
+};
+
+export default renderPicker;
diff --git a/packages/render/src/primitives/form/renderTextInput.js b/packages/render/src/primitives/form/renderTextInput.js
new file mode 100644
index 000000000..1d5d0b06e
--- /dev/null
+++ b/packages/render/src/primitives/form/renderTextInput.js
@@ -0,0 +1,24 @@
+import { parseTextFieldOptions } from '../../utils/parseFormOptions';
+
+const renderTextInput = (ctx, node, options = {}) => {
+ const { top, left, width, height } = node.box || {};
+
+ // Element's name
+ const name = node.props?.name || '';
+ const formFieldOptions = options.formFields?.at(0);
+
+ if (!ctx._root.data.AcroForm) {
+ ctx.initForm();
+ }
+
+ ctx.formText(
+ name,
+ left,
+ top,
+ width,
+ height,
+ parseTextFieldOptions(node, formFieldOptions),
+ );
+};
+
+export default renderTextInput;
diff --git a/packages/render/src/primitives/renderNode.js b/packages/render/src/primitives/renderNode.js
index 12abdb332..cae0ed059 100644
--- a/packages/render/src/primitives/renderNode.js
+++ b/packages/render/src/primitives/renderNode.js
@@ -12,6 +12,11 @@ import setLink from '../operations/setLink';
import clipNode from '../operations/clipNode';
import transform from '../operations/transform';
import setDestination from '../operations/setDestination';
+import renderTextInput from './form/renderTextInput';
+import renderPicker from './form/renderPicker';
+import renderFormField, { cleanUpFormField } from './form/renderFormField';
+import renderFormList from './form/renderFormList';
+import renderCheckbox from './form/renderCheckbox';
const isRecursiveNode = (node) => node.type !== P.Text && node.type !== P.Svg;
@@ -23,6 +28,7 @@ const renderChildren = (ctx, node, options) => {
}
const children = node.children || [];
+ // eslint-disable-next-line no-use-before-define
const renderChild = (child) => renderNode(ctx, child, options);
children.forEach(renderChild);
@@ -34,11 +40,20 @@ const renderFns = {
[P.Text]: renderText,
[P.Note]: renderNote,
[P.Image]: renderImage,
+ [P.FormField]: renderFormField,
+ [P.TextInput]: renderTextInput,
+ [P.Picker]: renderPicker,
+ [P.Checkbox]: renderCheckbox,
+ [P.FormList]: renderFormList,
[P.Canvas]: renderCanvas,
[P.Svg]: renderSvg,
[P.Link]: setLink,
};
+const cleanUpFns = {
+ [P.FormField]: cleanUpFormField,
+};
+
const renderNode = (ctx, node, options) => {
const overflowHidden = node.style?.overflow === 'hidden';
const shouldRenderChildren = isRecursiveNode(node);
@@ -59,6 +74,10 @@ const renderNode = (ctx, node, options) => {
if (shouldRenderChildren) renderChildren(ctx, node, options);
+ const cleanUpFn = cleanUpFns[node.type];
+
+ if (cleanUpFn) cleanUpFn(ctx, node, options);
+
setDestination(ctx, node);
renderDebug(ctx, node);
diff --git a/packages/render/src/utils/parseFormOptions.js b/packages/render/src/utils/parseFormOptions.js
new file mode 100644
index 000000000..e8d5a0dcf
--- /dev/null
+++ b/packages/render/src/utils/parseFormOptions.js
@@ -0,0 +1,116 @@
+const clean = (options) => {
+ const opt = { ...options };
+
+ // We need to ensure the elements are no present if not true
+ Object.entries(opt).forEach((pair) => {
+ if (!pair[1]) {
+ delete opt[pair[0]];
+ }
+ });
+
+ return opt;
+};
+
+const parseCommonFormOptions = (node) => {
+ // Common Options
+ return {
+ required: node.props?.required || false,
+ noExport: node.props?.noExport || false,
+ readOnly: node.props?.readOnly || false,
+ value: node.props?.value || undefined,
+ defaultValue: node.props?.defaultValue || undefined,
+ };
+};
+
+const parseTextFieldOptions = (node, formField) => {
+ return clean({
+ ...parseCommonFormOptions(node),
+ parent: formField || undefined,
+ align: node.props?.align || 'left',
+ multiline: node.props?.multiline || undefined,
+ password: node.props?.password || false,
+ noSpell: node.props?.noSpell || false,
+ format: node.props?.format || undefined,
+ });
+};
+
+const parsePickerAndListFieldOptions = (node) => {
+ return clean({
+ ...parseCommonFormOptions(node),
+ sort: node.props?.sort || false,
+ edit: node.props?.edit || false,
+ multiSelect: node.props?.multiSelect || false,
+ noSpell: node.props?.noSpell || false,
+ select: node.props?.select || [''],
+ });
+};
+
+const getAppearance = (ctx, codepoint, width, height) => {
+ const appearance = ctx.ref({
+ Type: 'XObject',
+ Subtype: 'Form',
+ BBox: [0, 0, width, height],
+ Resources: {
+ ProcSet: ['PDF', 'Text', 'ImageB', 'ImageC', 'ImageI'],
+ Font: {
+ ZaDi: ctx._acroform.fonts.ZaDi,
+ },
+ },
+ });
+
+ appearance.initDeflate();
+ appearance.write(
+ `/Tx BMC\nq\n/ZaDi ${height * 0.8} Tf\nBT\n${width * 0.45} ${height / 4} Td (${codepoint}) Tj\nET\nQ\nEMC`,
+ );
+ appearance.end();
+ return appearance;
+};
+
+const parseCheckboxOptions = (ctx, node, formField) => {
+ const { width, height } = node.box || {};
+
+ const onOption = node.props?.onState || 'Yes';
+ const offOption = node.props?.offState || 'Off';
+ const xMark = node.props?.xMark || false;
+
+ if (!Object.prototype.hasOwnProperty.call(ctx._acroform.fonts, 'ZaDi')) {
+ const ref = ctx.ref({
+ Type: 'Font',
+ Subtype: 'Type1',
+ BaseFont: 'ZapfDingbats',
+ });
+ ctx._acroform.fonts.ZaDi = ref;
+ ref.end();
+ }
+
+ const normalAppearance = {};
+ normalAppearance[onOption] = getAppearance(
+ ctx,
+ xMark ? '8' : '4',
+ width,
+ height,
+ );
+ normalAppearance[offOption] = getAppearance(
+ ctx,
+ xMark ? ' ' : '8',
+ width,
+ height,
+ );
+
+ return clean({
+ ...parseCommonFormOptions(node),
+ backgroundColor: node.props?.backgroundColor || undefined,
+ borderColor: node.props?.borderColor || undefined,
+ parent: formField || undefined,
+ value: `/${node.props?.checked === true ? onOption : offOption}`,
+ defaultValue: `/${node.props?.checked === true ? onOption : offOption}`,
+ AS: node.props?.checked === true ? onOption : offOption,
+ AP: { N: normalAppearance, D: normalAppearance },
+ });
+};
+
+export {
+ parseTextFieldOptions,
+ parsePickerAndListFieldOptions,
+ parseCheckboxOptions,
+};
diff --git a/packages/render/tests/ctx.js b/packages/render/tests/ctx.js
index daf5e2598..9796e5264 100644
--- a/packages/render/tests/ctx.js
+++ b/packages/render/tests/ctx.js
@@ -48,6 +48,9 @@ const createCTX = () => {
instance.lineCap = vi.fn().mockReturnValue(instance);
instance.text = vi.fn().mockReturnValue(instance);
instance.font = vi.fn().mockReturnValue(instance);
+ instance._root = { data: { AcroForm: {} } };
+ instance.textInput = vi.fn().mockReturnValue(instance);
+ instance.formField = vi.fn().mockReturnValue(instance);
return instance;
};
diff --git a/packages/render/tests/primitives/renderForm.test.js b/packages/render/tests/primitives/renderForm.test.js
new file mode 100644
index 000000000..b6e99019b
--- /dev/null
+++ b/packages/render/tests/primitives/renderForm.test.js
@@ -0,0 +1,50 @@
+import { describe, expect, test } from 'vitest';
+import * as P from '@react-pdf/primitives';
+
+import createCTX from '../ctx';
+import renderFormField from '../../src/primitives/form/renderFormField';
+
+describe('primitive renderFormField', () => {
+ test('should render FormField correctly', () => {
+ const ctx = createCTX();
+ const args = 'example';
+ const props = { name: args };
+ const node = { type: P.FormField, props };
+
+ renderFormField(ctx, node);
+
+ expect(ctx.formField.mock.calls).toHaveLength(1);
+ expect(ctx.formField.mock.calls[0]).toHaveLength(1);
+ expect(ctx.formField.mock.calls[0][0]).toBe(args);
+ });
+
+ test.todo('FormField with one textInput direct child', () => {
+ const ctx = createCTX();
+ const node = { type: P.FormField, children: [{ type: P.TextInput }] };
+
+ renderFormField(ctx, node);
+
+ expect(ctx.textInput.mock.calls).toHaveLength(1);
+ });
+
+ test.todo('FormField with one TextInput indirect child', () => {
+ const ctx = createCTX();
+ const node = {
+ type: P.TextInput,
+ children: [
+ {
+ type: P.View,
+ children: [
+ {
+ type: P.TextInput,
+ },
+ ],
+ },
+ ],
+ };
+
+ renderFormField(ctx, node);
+
+ expect(ctx.textInput.mock.calls).toHaveLength(1);
+ });
+});
diff --git a/packages/renderer/index.d.ts b/packages/renderer/index.d.ts
index bd8058011..08f4ced17 100644
--- a/packages/renderer/index.d.ts
+++ b/packages/renderer/index.d.ts
@@ -235,6 +235,90 @@ declare namespace ReactPDF {
React.PropsWithChildren
> {}
+ interface FormCommonProps extends NodeProps {
+ name?: string;
+ required?: boolean;
+ noExport?: boolean;
+ readOnly?: boolean;
+ value?: number | string;
+ defaultValue?: number | string;
+ }
+
+ interface FormFieldProps extends NodeProps {
+ name: string;
+ }
+
+ export class FormField extends React.Component<
+ React.PropsWithChildren
+ > {}
+
+ // see http://pdfkit.org/docs/forms.html#text_field_formatting
+ interface TextInputFormatting {
+ type:
+ | 'date'
+ | 'time'
+ | 'percent'
+ | 'number'
+ | 'zip'
+ | 'zipPlus4'
+ | 'phone'
+ | 'ssn';
+ param?: string;
+ nDec?: number;
+ sepComma?: boolean;
+ negStyle?: 'MinusBlack' | 'Red' | 'ParensBlack' | 'ParensRed';
+ currency?: string;
+ currencyPrepend?: boolean;
+ }
+
+ // see http://pdfkit.org/docs/forms.html#text_field_formatting
+ interface TextInputProps extends FormCommonProps {
+ align?: 'left' | 'center' | 'right';
+ multiline?: boolean;
+ password?: boolean;
+ noSpell?: boolean;
+ format?: TextInputFormatting;
+ }
+
+ export class TextInput extends React.Component {}
+
+ interface CheckboxProps extends FormCommonProps {
+ backgroundColor?: string;
+ borderColor?: string;
+ checked?: boolean;
+ onState?: string;
+ offState?: string;
+ xMark?: boolean;
+ }
+
+ export class Checkbox extends React.Component {}
+
+ interface PickerAndListPropsBase extends FormCommonProps {
+ sort?: boolean;
+ edit?: boolean;
+ multiSelect?: boolean;
+ noSpell?: boolean;
+ select?: string[];
+ }
+
+ type PickerAndListPropsWithEdit = PickerAndListPropsBase & {
+ edit: true | false;
+ noSpell: boolean;
+ };
+
+ type PickerAndListPropsWithNoSpell = PickerAndListPropsBase & {
+ edit: boolean;
+ noSpell: true | false;
+ };
+
+ type PickerAndListProps =
+ | PickerAndListPropsWithEdit
+ | PickerAndListPropsWithNoSpell;
+
+ export class Picker extends React.Component {}
+
+ export class FormList extends React.Component {}
+
interface NoteProps extends NodeProps {
children: string;
}