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; }