Skip to content
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

feat: added Form Annotation support #2845

Open
wants to merge 32 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
eee292a
Adding Form Annotation
axel7083 Sep 8, 2022
e39acab
Adding types
axel7083 Sep 8, 2022
51b4493
Fixing FormField scope issue and adding Unit Test
axel7083 Sep 12, 2022
687aa06
HotFix: Preventing problem in renderFormCombo for accessing node.box
axel7083 Sep 12, 2022
b562369
fix: lint
diegomura Nov 7, 2022
824d05c
Merge remote-tracking branch 'axel7083/master'
runelk Jun 13, 2023
f20153a
Change FormText to TextInput for more consistent naming.
runelk Jun 13, 2023
33ef422
Change FormCombo to Picker for more consistent naming.
runelk Jun 13, 2023
c36d8c9
Fix spelling error.
runelk Jun 13, 2023
24183a0
Merge remote-tracking branch 'runelk/master'
natterstefan Aug 14, 2024
6264795
chore: format code
natterstefan Aug 18, 2024
e6fa36b
test: fixed tests
natterstefan Aug 18, 2024
9f68eaa
fix: fixed types
natterstefan Aug 18, 2024
c6f86f5
fix: fixed issue when using <Form/> multiple times
natterstefan Aug 18, 2024
49322f8
feat: added form example
natterstefan Aug 18, 2024
81b75d2
feat: updated example
natterstefan Aug 18, 2024
8aa7a0a
test: fixed tests
natterstefan Aug 18, 2024
d05e812
feat: updated example
natterstefan Aug 18, 2024
a08b945
feat: added checkbox
natterstefan Aug 19, 2024
c7fb5e2
fix: fixed types
natterstefan Aug 19, 2024
d9a1e84
feat: added multiline example
natterstefan Aug 21, 2024
96e4d7d
feat: added support for checked appearance (Checkbox)
natterstefan Aug 28, 2024
7162e6f
feat: removed Form component, check AcroForm in form components
natterstefan Aug 28, 2024
3dac42b
fixup! fixed obsolete !!this._root.data.AcroForm check
natterstefan Aug 28, 2024
ef4fee3
feat: removed FormPushButton
natterstefan Aug 28, 2024
3e6c660
fixup! removed P.Form
natterstefan Aug 28, 2024
de0c19d
fixup! once more remove leftover code
natterstefan Aug 28, 2024
fd6e5f9
feat: introduced cleanup feature, suggested by @PhilippBloss
natterstefan Aug 28, 2024
43bd666
fix: revert changes in acroform
natterstefan Aug 28, 2024
39cb88a
feat: improved macos appearance
natterstefan Aug 30, 2024
81dc6fa
feat: removed requirement to render TextInput and Checkbox within For…
natterstefan Sep 1, 2024
e1e3726
fix: fixed PDF appearance on macOS (e.g. checked Checkbox)
natterstefan Sep 1, 2024
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
100 changes: 100 additions & 0 deletions packages/examples/src/form/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React from 'react';
import {
Document,
Page,
View,
Text,
Form,
FormField,
TextInput,
Picker,
FormPushButton,
FormList,
} from '@react-pdf/renderer';

const FormPdf = () => (
<Document>
<Page>
<View
style={{
backgroundColor: 'rgba(182,28,28,0.62)',
width: '30%',
height: '100%',
}}
>
<Form>
<FormField name="user-info" style={{ flexDirection: 'column' }}>
<Text>TextInput</Text>
<TextInput
name="username"
value="foo"
align="center"
style={{ height: '50px' }}
/>

{/* Nested works as well */}
<View>
<Text>TextInput</Text>
<TextInput
name="password"
value="bar"
align="center"
style={{ height: '50px' }}
password
/>
</View>

<Text>Picker</Text>
<Picker
name="combo"
select={['', 'option 1', 'option 2']}
value=""
defaultValue=""
style={{ height: '20px' }}
/>

<Text>FormList</Text>
<FormList
name="list"
select={['', 'option 1', 'option 2']}
value=""
defaultValue=""
style={{ height: '50px' }}
/>

<Text>FormPushButton</Text>
<FormPushButton
name="bouton"
label="push button"
style={{ height: '50px' }}
/>
</FormField>
</Form>
</View>
</Page>

<Page>
<View
style={{
backgroundColor: 'rgba(182,28,28,0.62)',
width: '30%',
height: '100%',
}}
>
<Form>
<FormField name="user-details" style={{ flexDirection: 'column' }}>
<Text>TextInput</Text>
<TextInput
name="details"
value="hello"
align="center"
style={{ height: '50px' }}
/>
</FormField>
</Form>
</View>
</Page>
</Document>
);

export default FormPdf;
8 changes: 6 additions & 2 deletions packages/pdfkit/src/mixins/acroform.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export default {
* this method to set the default font.
*/
initForm() {
if (!!this._root.data.AcroForm) {
// Form is already initialized
return this;
}
natterstefan marked this conversation as resolved.
Show resolved Hide resolved
if (!this._font) {
throw new Error('Must set a font before calling initForm method');
}
Expand Down Expand Up @@ -141,15 +145,15 @@ export default {
return this._addToParent(annotRef);
},

formText(name, x, y, w, h, options = {}) {
textInput(name, x, y, w, h, options = {}) {
return this.formAnnotation(name, 'text', x, y, w, h, options);
},

formPushButton(name, x, y, w, h, options = {}) {
return this.formAnnotation(name, 'pushButton', x, y, w, h, options);
},

formCombo(name, x, y, w, h, options = {}) {
picker(name, x, y, w, h, options = {}) {
return this.formAnnotation(name, 'combo', x, y, w, h, options);
},

Expand Down
6 changes: 6 additions & 0 deletions packages/primitives/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ export const Note = 'NOTE';
export const Path = 'PATH';
export const Rect = 'RECT';
export const Line = 'LINE';
export const Form = 'FORM';
export const FormField = 'FORM_FIELD';
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a strong opinion, but would Form be more semantic? For Field I imagine like an input, but this is actually a form wrapper

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @diegomura,

thanks for your response. Let's see what I suggested here in the past: #2845 (comment). 🤔

My initial thought was to align the names with the ones used in pdfkit (here). So TextInput becomes FormText, Picker becomes FormPicker, and so on. But you made a valid point back in 2022 suggesting we should stick to the native web primitives. I got used to the current names while preparing the PR.

Regarding FormField: I chose the same name as pdfkit mainly to align our (react-pdf) and their (pdfkit) docs and keep them similar. I don't know if that's practical.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about something like FormGroup? Form rather sounds like the single and complete set of inputs, but they rather are a subsection. E.g., you could separate billing address and shipping address into two groups.

Copy link
Author

@natterstefan natterstefan Oct 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @PhilippBloss,

your suggestion makes sense to me, considering what pdfkit states in their docs as well:

Using the formField method you might create a shipping field that is added to the root of the document, an address field that refers to the shipping field as it's parent, and a street Form Annotation that would refer to the address field as it's parent.

What do you think of fieldset(https://developer.mozilla.org/en-US/docs/Web/HTML/Element/fieldset):

The <fieldset> HTML element is used to group several controls as well as labels (<label>) within a web form.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good as well. Would give the decision to @diegomura

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would take over the docs, except @natterstefan has already started with those

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would take over the docs, except @natterstefan has already started with those

Hi @PhilippBloss, I haven't taken care of the docs yet. You can take over the docs if you want to or we can share the task. It's fine for me.

Also thanks for the review. I will try to apply your suggestions tomorrow or at least in the next few days.

Thanks for the ping @diegomura.

export const TextInput = 'TEXT_INPUT';
export const FormPushButton = 'FORM_PUSH_BUTTON';
export const Picker = 'PICKER';
export const FormList = 'FORM_LIST';
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think of keeping the initial names of these components to keep them aligned with the form annotation methods of PDFKit?

formText( name, x, y, width, height, options)
formPushButton( name, x, y, width, height, name, options)
formCombo( name, x, y, width, height, options)
formList( name, x, y, width, height, options)

(src)

export const Stop = 'STOP';
export const Defs = 'DEFS';
export const Image = 'IMAGE';
Expand Down
24 changes: 24 additions & 0 deletions packages/primitives/tests/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,30 @@ describe('primitives', () => {
expect(primitives.Line).toBeTruthy();
});

test('should export form', () => {
expect(primitives.Form).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 form push button', () => {
expect(primitives.FormPushButton).toBeTruthy();
});

test('should export stop', () => {
expect(primitives.Stop).toBeTruthy();
});
Expand Down
13 changes: 13 additions & 0 deletions packages/render/src/primitives/form/renderForm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import renderNode from '../renderNode';

const renderForm = (ctx, node, options) => {
ctx.save();
ctx.initForm();

const children = node.children || [];
children.forEach((child) => renderNode(ctx, child, options));

ctx.restore();
};

export default renderForm;
14 changes: 14 additions & 0 deletions packages/render/src/primitives/form/renderFormField.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import renderNode from '../renderNode';

const renderFormField = (ctx, node, options) => {
const name = node.props?.name || '';

const formField = ctx.formField(name);

const children = node.children || [];
children.forEach((child) =>
renderNode(ctx, child, { ...options, formField }),
);
};

export default renderFormField;
19 changes: 19 additions & 0 deletions packages/render/src/primitives/form/renderFormList.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { parsePickerAndListFieldOptions } from '../../utils/parseFormOptions';

const renderFormList = (ctx, node) => {
const { top, left, width, height } = node.box || {};

// Element's name
const name = node.props?.name || '';

ctx.formList(
name,
left,
top,
width,
height,
parsePickerAndListFieldOptions(node),
);
};

export default renderFormList;
19 changes: 19 additions & 0 deletions packages/render/src/primitives/form/renderFormPushButton.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { parseButtonFieldOptions } from '../../utils/parseFormOptions';

const renderFormPushButton = (ctx, node) => {
const { top, left, width, height } = node.box || {};

// Element's name
const name = node.props?.name || '';

ctx.formPushButton(
name,
left,
top,
width,
height,
parseButtonFieldOptions(node),
);
};

export default renderFormPushButton;
19 changes: 19 additions & 0 deletions packages/render/src/primitives/form/renderPicker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { parsePickerAndListFieldOptions } from '../../utils/parseFormOptions';

const renderPicker = (ctx, node) => {
const { top, left, width, height } = node.box || {};

// Element's name
const name = node.props?.name || '';

ctx.picker(
name,
left,
top,
width,
height,
parsePickerAndListFieldOptions(node),
);
};

export default renderPicker;
24 changes: 24 additions & 0 deletions packages/render/src/primitives/form/renderTextInput.js
Original file line number Diff line number Diff line change
@@ -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 || '';

if (!options.formField)
throw new Error(
'The TextInput element must be a children of a FormField element.',
);

ctx.textInput(
name,
left,
top,
width,
height,
parseTextFieldOptions(node, options.formField),
);
};

export default renderTextInput;
20 changes: 18 additions & 2 deletions packages/render/src/primitives/renderNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,18 @@ import setLink from '../operations/setLink';
import clipNode from '../operations/clipNode';
import transform from '../operations/transform';
import setDestination from '../operations/setDestination';

const isRecursiveNode = (node) => node.type !== P.Text && node.type !== P.Svg;
import renderForm from './form/renderForm';
import renderFormField from './form/renderFormField';
natterstefan marked this conversation as resolved.
Show resolved Hide resolved
import renderTextInput from './form/renderTextInput';
import renderFormPushButton from './form/renderFormPushButton';
import renderPicker from './form/renderPicker';
import renderFormList from './form/renderFormList';

const isRecursiveNode = (node) =>
node.type !== P.Text &&
node.type !== P.Svg &&
node.type !== P.Form &&
node.type !== P.FormField;
natterstefan marked this conversation as resolved.
Show resolved Hide resolved

const renderChildren = (ctx, node, options) => {
ctx.save();
Expand All @@ -34,6 +44,12 @@ const renderFns = {
[P.Text]: renderText,
[P.Note]: renderNote,
[P.Image]: renderImage,
[P.Form]: renderForm,
[P.FormField]: renderFormField,
[P.TextInput]: renderTextInput,
[P.FormPushButton]: renderFormPushButton,
[P.Picker]: renderPicker,
[P.FormList]: renderFormList,
[P.Canvas]: renderCanvas,
[P.Svg]: renderSvg,
[P.Link]: setLink,
Expand Down
59 changes: 59 additions & 0 deletions packages/render/src/utils/parseFormOptions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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 parseButtonFieldOptions = node => {
return clean({
...parseCommonFormOptions(node),
label: node.props?.label || '???',
});
};

export {
parseTextFieldOptions,
parsePickerAndListFieldOptions,
parseButtonFieldOptions,
};
2 changes: 2 additions & 0 deletions packages/render/tests/ctx.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const createCTX = () => {
instance.lineCap = vi.fn().mockReturnValue(instance);
instance.text = vi.fn().mockReturnValue(instance);
instance.font = vi.fn().mockReturnValue(instance);
instance.formField = vi.fn().mockReturnValue(instance);
instance.textInput = vi.fn().mockReturnValue(instance);

return instance;
};
Expand Down
Loading