pdf-lib
Version:
Create and modify PDF files with JavaScript
666 lines (582 loc) • 19.2 kB
text/typescript
import { PDFOperator, PDFWidgetAnnotation } from 'src/core';
import PDFFont from 'src/api/PDFFont';
import PDFButton from 'src/api/form/PDFButton';
import PDFCheckBox from 'src/api/form/PDFCheckBox';
import PDFDropdown from 'src/api/form/PDFDropdown';
import PDFField from 'src/api/form/PDFField';
import PDFOptionList from 'src/api/form/PDFOptionList';
import PDFRadioGroup from 'src/api/form/PDFRadioGroup';
import PDFSignature from 'src/api/form/PDFSignature';
import PDFTextField from 'src/api/form/PDFTextField';
import {
drawCheckBox,
rotateInPlace,
drawRadioButton,
drawButton,
drawTextField,
drawOptionList,
} from 'src/api/operations';
import {
rgb,
componentsToColor,
setFillingColor,
grayscale,
cmyk,
Color,
} from 'src/api/colors';
import { reduceRotation, adjustDimsForRotation } from 'src/api/rotations';
import {
layoutMultilineText,
layoutCombedText,
TextPosition,
layoutSinglelineText,
} from 'src/api/text/layout';
import { TextAlignment } from 'src/api/text/alignment';
import { setFontAndSize } from 'src/api/operators';
import { findLastMatch } from 'src/utils';
/*********************** Appearance Provider Types ****************************/
type CheckBoxAppearanceProvider = (
checkBox: PDFCheckBox,
widget: PDFWidgetAnnotation,
) => AppearanceOrMapping<{
on: PDFOperator[];
off: PDFOperator[];
}>;
type RadioGroupAppearanceProvider = (
radioGroup: PDFRadioGroup,
widget: PDFWidgetAnnotation,
) => AppearanceOrMapping<{
on: PDFOperator[];
off: PDFOperator[];
}>;
type ButtonAppearanceProvider = (
button: PDFButton,
widget: PDFWidgetAnnotation,
font: PDFFont,
) => AppearanceOrMapping<PDFOperator[]>;
type DropdownAppearanceProvider = (
dropdown: PDFDropdown,
widget: PDFWidgetAnnotation,
font: PDFFont,
) => AppearanceOrMapping<PDFOperator[]>;
type OptionListAppearanceProvider = (
optionList: PDFOptionList,
widget: PDFWidgetAnnotation,
font: PDFFont,
) => AppearanceOrMapping<PDFOperator[]>;
type TextFieldAppearanceProvider = (
textField: PDFTextField,
widget: PDFWidgetAnnotation,
font: PDFFont,
) => AppearanceOrMapping<PDFOperator[]>;
type SignatureAppearanceProvider = (
signature: PDFSignature,
widget: PDFWidgetAnnotation,
font: PDFFont,
) => AppearanceOrMapping<PDFOperator[]>;
/******************* Appearance Provider Utility Types ************************/
export type AppearanceMapping<T> = { normal: T; rollover?: T; down?: T };
type AppearanceOrMapping<T> = T | AppearanceMapping<T>;
// prettier-ignore
export type AppearanceProviderFor<T extends PDFField> =
T extends PDFCheckBox ? CheckBoxAppearanceProvider
: T extends PDFRadioGroup ? RadioGroupAppearanceProvider
: T extends PDFButton ? ButtonAppearanceProvider
: T extends PDFDropdown ? DropdownAppearanceProvider
: T extends PDFOptionList ? OptionListAppearanceProvider
: T extends PDFTextField ? TextFieldAppearanceProvider
: T extends PDFSignature ? SignatureAppearanceProvider
: never;
/********************* Appearance Provider Functions **************************/
export const normalizeAppearance = <T>(
appearance: T | AppearanceMapping<T>,
): AppearanceMapping<T> => {
if ('normal' in appearance) return appearance;
return { normal: appearance };
};
// Examples:
// `/Helv 12 Tf` -> ['/Helv 12 Tf', 'Helv', '12']
// `/HeBo 8.00 Tf` -> ['/HeBo 8 Tf', 'HeBo', '8.00']
const tfRegex = /\/([^\0\t\n\f\r\ ]+)[\0\t\n\f\r\ ]+(\d*\.\d+|\d+)[\0\t\n\f\r\ ]+Tf/;
const getDefaultFontSize = (field: {
getDefaultAppearance(): string | undefined;
}) => {
const da = field.getDefaultAppearance() ?? '';
const daMatch = findLastMatch(da, tfRegex).match ?? [];
const defaultFontSize = Number(daMatch[2]);
return isFinite(defaultFontSize) ? defaultFontSize : undefined;
};
// Examples:
// `0.3 g` -> ['0.3', 'g']
// `0.3 1 .3 rg` -> ['0.3', '1', '.3', 'rg']
// `0.3 1 .3 0 k` -> ['0.3', '1', '.3', '0', 'k']
const colorRegex = /(\d*\.\d+|\d+)[\0\t\n\f\r\ ]*(\d*\.\d+|\d+)?[\0\t\n\f\r\ ]*(\d*\.\d+|\d+)?[\0\t\n\f\r\ ]*(\d*\.\d+|\d+)?[\0\t\n\f\r\ ]+(g|rg|k)/;
const getDefaultColor = (field: {
getDefaultAppearance(): string | undefined;
}) => {
const da = field.getDefaultAppearance() ?? '';
const daMatch = findLastMatch(da, colorRegex).match;
const [, c1, c2, c3, c4, colorSpace] = daMatch ?? [];
if (colorSpace === 'g' && c1) {
return grayscale(Number(c1));
}
if (colorSpace === 'rg' && c1 && c2 && c3) {
return rgb(Number(c1), Number(c2), Number(c3));
}
if (colorSpace === 'k' && c1 && c2 && c3 && c4) {
return cmyk(Number(c1), Number(c2), Number(c3), Number(c4));
}
return undefined;
};
const updateDefaultAppearance = (
field: { setDefaultAppearance(appearance: string): void },
color: Color,
font?: PDFFont,
fontSize: number = 0,
) => {
const da = [
setFillingColor(color).toString(),
setFontAndSize(font?.name ?? 'dummy__noop', fontSize).toString(),
].join('\n');
field.setDefaultAppearance(da);
};
export const defaultCheckBoxAppearanceProvider: AppearanceProviderFor<PDFCheckBox> = (
checkBox,
widget,
) => {
// The `/DA` entry can be at the widget or field level - so we handle both
const widgetColor = getDefaultColor(widget);
const fieldColor = getDefaultColor(checkBox.acroField);
const rectangle = widget.getRectangle();
const ap = widget.getAppearanceCharacteristics();
const bs = widget.getBorderStyle();
const borderWidth = bs?.getWidth() ?? 0;
const rotation = reduceRotation(ap?.getRotation());
const { width, height } = adjustDimsForRotation(rectangle, rotation);
const rotate = rotateInPlace({ ...rectangle, rotation });
const black = rgb(0, 0, 0);
const borderColor = componentsToColor(ap?.getBorderColor()) ?? black;
const normalBackgroundColor = componentsToColor(ap?.getBackgroundColor());
const downBackgroundColor = componentsToColor(ap?.getBackgroundColor(), 0.8);
// Update color
const textColor = widgetColor ?? fieldColor ?? black;
if (widgetColor) {
updateDefaultAppearance(widget, textColor);
} else {
updateDefaultAppearance(checkBox.acroField, textColor);
}
const options = {
x: 0 + borderWidth / 2,
y: 0 + borderWidth / 2,
width: width - borderWidth,
height: height - borderWidth,
thickness: 1.5,
borderWidth,
borderColor,
markColor: textColor,
};
return {
normal: {
on: [
...rotate,
...drawCheckBox({
...options,
color: normalBackgroundColor,
filled: true,
}),
],
off: [
...rotate,
...drawCheckBox({
...options,
color: normalBackgroundColor,
filled: false,
}),
],
},
down: {
on: [
...rotate,
...drawCheckBox({
...options,
color: downBackgroundColor,
filled: true,
}),
],
off: [
...rotate,
...drawCheckBox({
...options,
color: downBackgroundColor,
filled: false,
}),
],
},
};
};
export const defaultRadioGroupAppearanceProvider: AppearanceProviderFor<PDFRadioGroup> = (
radioGroup,
widget,
) => {
// The `/DA` entry can be at the widget or field level - so we handle both
const widgetColor = getDefaultColor(widget);
const fieldColor = getDefaultColor(radioGroup.acroField);
const rectangle = widget.getRectangle();
const ap = widget.getAppearanceCharacteristics();
const bs = widget.getBorderStyle();
const borderWidth = bs?.getWidth() ?? 0;
const rotation = reduceRotation(ap?.getRotation());
const { width, height } = adjustDimsForRotation(rectangle, rotation);
const rotate = rotateInPlace({ ...rectangle, rotation });
const black = rgb(0, 0, 0);
const borderColor = componentsToColor(ap?.getBorderColor()) ?? black;
const normalBackgroundColor = componentsToColor(ap?.getBackgroundColor());
const downBackgroundColor = componentsToColor(ap?.getBackgroundColor(), 0.8);
// Update color
const textColor = widgetColor ?? fieldColor ?? black;
if (widgetColor) {
updateDefaultAppearance(widget, textColor);
} else {
updateDefaultAppearance(radioGroup.acroField, textColor);
}
const options = {
x: width / 2,
y: height / 2,
width: width - borderWidth,
height: height - borderWidth,
borderWidth,
borderColor,
dotColor: textColor,
};
return {
normal: {
on: [
...rotate,
...drawRadioButton({
...options,
color: normalBackgroundColor,
filled: true,
}),
],
off: [
...rotate,
...drawRadioButton({
...options,
color: normalBackgroundColor,
filled: false,
}),
],
},
down: {
on: [
...rotate,
...drawRadioButton({
...options,
color: downBackgroundColor,
filled: true,
}),
],
off: [
...rotate,
...drawRadioButton({
...options,
color: downBackgroundColor,
filled: false,
}),
],
},
};
};
export const defaultButtonAppearanceProvider: AppearanceProviderFor<PDFButton> = (
button,
widget,
font,
) => {
// The `/DA` entry can be at the widget or field level - so we handle both
const widgetColor = getDefaultColor(widget);
const fieldColor = getDefaultColor(button.acroField);
const widgetFontSize = getDefaultFontSize(widget);
const fieldFontSize = getDefaultFontSize(button.acroField);
const rectangle = widget.getRectangle();
const ap = widget.getAppearanceCharacteristics();
const bs = widget.getBorderStyle();
const captions = ap?.getCaptions();
const normalText = captions?.normal ?? '';
const downText = captions?.down ?? normalText ?? '';
const borderWidth = bs?.getWidth() ?? 0;
const rotation = reduceRotation(ap?.getRotation());
const { width, height } = adjustDimsForRotation(rectangle, rotation);
const rotate = rotateInPlace({ ...rectangle, rotation });
const black = rgb(0, 0, 0);
const borderColor = componentsToColor(ap?.getBorderColor());
const normalBackgroundColor = componentsToColor(ap?.getBackgroundColor());
const downBackgroundColor = componentsToColor(ap?.getBackgroundColor(), 0.8);
const bounds = {
x: borderWidth,
y: borderWidth,
width: width - borderWidth * 2,
height: height - borderWidth * 2,
};
const normalLayout = layoutSinglelineText(normalText, {
alignment: TextAlignment.Center,
fontSize: widgetFontSize ?? fieldFontSize,
font,
bounds,
});
const downLayout = layoutSinglelineText(downText, {
alignment: TextAlignment.Center,
fontSize: widgetFontSize ?? fieldFontSize,
font,
bounds,
});
// Update font size and color
const fontSize = Math.min(normalLayout.fontSize, downLayout.fontSize);
const textColor = widgetColor ?? fieldColor ?? black;
if (widgetColor || widgetFontSize !== undefined) {
updateDefaultAppearance(widget, textColor, font, fontSize);
} else {
updateDefaultAppearance(button.acroField, textColor, font, fontSize);
}
const options = {
x: 0 + borderWidth / 2,
y: 0 + borderWidth / 2,
width: width - borderWidth,
height: height - borderWidth,
borderWidth,
borderColor,
textColor,
font: font.name,
fontSize,
};
return {
normal: [
...rotate,
...drawButton({
...options,
color: normalBackgroundColor,
textLines: [normalLayout.line],
}),
],
down: [
...rotate,
...drawButton({
...options,
color: downBackgroundColor,
textLines: [downLayout.line],
}),
],
};
};
export const defaultTextFieldAppearanceProvider: AppearanceProviderFor<PDFTextField> = (
textField,
widget,
font,
) => {
// The `/DA` entry can be at the widget or field level - so we handle both
const widgetColor = getDefaultColor(widget);
const fieldColor = getDefaultColor(textField.acroField);
const widgetFontSize = getDefaultFontSize(widget);
const fieldFontSize = getDefaultFontSize(textField.acroField);
const rectangle = widget.getRectangle();
const ap = widget.getAppearanceCharacteristics();
const bs = widget.getBorderStyle();
const text = textField.getText() ?? '';
const borderWidth = bs?.getWidth() ?? 0;
const rotation = reduceRotation(ap?.getRotation());
const { width, height } = adjustDimsForRotation(rectangle, rotation);
const rotate = rotateInPlace({ ...rectangle, rotation });
const black = rgb(0, 0, 0);
const borderColor = componentsToColor(ap?.getBorderColor());
const normalBackgroundColor = componentsToColor(ap?.getBackgroundColor());
let textLines: TextPosition[];
let fontSize: number;
const padding = textField.isCombed() ? 0 : 1;
const bounds = {
x: borderWidth + padding,
y: borderWidth + padding,
width: width - (borderWidth + padding) * 2,
height: height - (borderWidth + padding) * 2,
};
if (textField.isMultiline()) {
const layout = layoutMultilineText(text, {
alignment: textField.getAlignment(),
fontSize: widgetFontSize ?? fieldFontSize,
font,
bounds,
});
textLines = layout.lines;
fontSize = layout.fontSize;
} else if (textField.isCombed()) {
const layout = layoutCombedText(text, {
fontSize: widgetFontSize ?? fieldFontSize,
font,
bounds,
cellCount: textField.getMaxLength() ?? 0,
});
textLines = layout.cells;
fontSize = layout.fontSize;
} else {
const layout = layoutSinglelineText(text, {
alignment: textField.getAlignment(),
fontSize: widgetFontSize ?? fieldFontSize,
font,
bounds,
});
textLines = [layout.line];
fontSize = layout.fontSize;
}
// Update font size and color
const textColor = widgetColor ?? fieldColor ?? black;
if (widgetColor || widgetFontSize !== undefined) {
updateDefaultAppearance(widget, textColor, font, fontSize);
} else {
updateDefaultAppearance(textField.acroField, textColor, font, fontSize);
}
const options = {
x: 0 + borderWidth / 2,
y: 0 + borderWidth / 2,
width: width - borderWidth,
height: height - borderWidth,
borderWidth: borderWidth ?? 0,
borderColor,
textColor,
font: font.name,
fontSize,
color: normalBackgroundColor,
textLines,
padding,
};
return [...rotate, ...drawTextField(options)];
};
export const defaultDropdownAppearanceProvider: AppearanceProviderFor<PDFDropdown> = (
dropdown,
widget,
font,
) => {
// The `/DA` entry can be at the widget or field level - so we handle both
const widgetColor = getDefaultColor(widget);
const fieldColor = getDefaultColor(dropdown.acroField);
const widgetFontSize = getDefaultFontSize(widget);
const fieldFontSize = getDefaultFontSize(dropdown.acroField);
const rectangle = widget.getRectangle();
const ap = widget.getAppearanceCharacteristics();
const bs = widget.getBorderStyle();
const text = dropdown.getSelected()[0] ?? '';
const borderWidth = bs?.getWidth() ?? 0;
const rotation = reduceRotation(ap?.getRotation());
const { width, height } = adjustDimsForRotation(rectangle, rotation);
const rotate = rotateInPlace({ ...rectangle, rotation });
const black = rgb(0, 0, 0);
const borderColor = componentsToColor(ap?.getBorderColor());
const normalBackgroundColor = componentsToColor(ap?.getBackgroundColor());
const padding = 1;
const bounds = {
x: borderWidth + padding,
y: borderWidth + padding,
width: width - (borderWidth + padding) * 2,
height: height - (borderWidth + padding) * 2,
};
const { line, fontSize } = layoutSinglelineText(text, {
alignment: TextAlignment.Left,
fontSize: widgetFontSize ?? fieldFontSize,
font,
bounds,
});
// Update font size and color
const textColor = widgetColor ?? fieldColor ?? black;
if (widgetColor || widgetFontSize !== undefined) {
updateDefaultAppearance(widget, textColor, font, fontSize);
} else {
updateDefaultAppearance(dropdown.acroField, textColor, font, fontSize);
}
const options = {
x: 0 + borderWidth / 2,
y: 0 + borderWidth / 2,
width: width - borderWidth,
height: height - borderWidth,
borderWidth: borderWidth ?? 0,
borderColor,
textColor,
font: font.name,
fontSize,
color: normalBackgroundColor,
textLines: [line],
padding,
};
return [...rotate, ...drawTextField(options)];
};
export const defaultOptionListAppearanceProvider: AppearanceProviderFor<PDFOptionList> = (
optionList,
widget,
font,
) => {
// The `/DA` entry can be at the widget or field level - so we handle both
const widgetColor = getDefaultColor(widget);
const fieldColor = getDefaultColor(optionList.acroField);
const widgetFontSize = getDefaultFontSize(widget);
const fieldFontSize = getDefaultFontSize(optionList.acroField);
const rectangle = widget.getRectangle();
const ap = widget.getAppearanceCharacteristics();
const bs = widget.getBorderStyle();
const borderWidth = bs?.getWidth() ?? 0;
const rotation = reduceRotation(ap?.getRotation());
const { width, height } = adjustDimsForRotation(rectangle, rotation);
const rotate = rotateInPlace({ ...rectangle, rotation });
const black = rgb(0, 0, 0);
const borderColor = componentsToColor(ap?.getBorderColor());
const normalBackgroundColor = componentsToColor(ap?.getBackgroundColor());
const options = optionList.getOptions();
const selected = optionList.getSelected();
if (optionList.isSorted()) options.sort();
let text = '';
for (let idx = 0, len = options.length; idx < len; idx++) {
text += options[idx];
if (idx < len - 1) text += '\n';
}
const padding = 1;
const bounds = {
x: borderWidth + padding,
y: borderWidth + padding,
width: width - (borderWidth + padding) * 2,
height: height - (borderWidth + padding) * 2,
};
const { lines, fontSize, lineHeight } = layoutMultilineText(text, {
alignment: TextAlignment.Left,
fontSize: widgetFontSize ?? fieldFontSize,
font,
bounds,
});
const selectedLines: number[] = [];
for (let idx = 0, len = lines.length; idx < len; idx++) {
const line = lines[idx];
if (selected.includes(line.text)) selectedLines.push(idx);
}
const blue = rgb(153 / 255, 193 / 255, 218 / 255);
// Update font size and color
const textColor = widgetColor ?? fieldColor ?? black;
if (widgetColor || widgetFontSize !== undefined) {
updateDefaultAppearance(widget, textColor, font, fontSize);
} else {
updateDefaultAppearance(optionList.acroField, textColor, font, fontSize);
}
return [
...rotate,
...drawOptionList({
x: 0 + borderWidth / 2,
y: 0 + borderWidth / 2,
width: width - borderWidth,
height: height - borderWidth,
borderWidth: borderWidth ?? 0,
borderColor,
textColor,
font: font.name,
fontSize,
color: normalBackgroundColor,
textLines: lines,
lineHeight,
selectedColor: blue,
selectedLines,
padding,
}),
];
};