Skip to content

Commit

Permalink
Add image stamping for TextField elements (#700)
Browse files Browse the repository at this point in the history
* Add image stamping for TextField elements

* - Add `ImageAlignment`
- Add test for image stamping for TextField elements
  • Loading branch information
btecu authored Dec 20, 2020
1 parent 7de691b commit 7dc89cb
Show file tree
Hide file tree
Showing 11 changed files with 130 additions and 70 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ const traitsField = form.getTextField('Feat+Traits')
const treasureField = form.getTextField('Treasure')

const characterImageField = form.getButton('CHARACTER IMAGE')
const factionImageField = form.getButton('Faction Symbol Image')
const factionImageField = form.getTextField('Faction Symbol Image')

// Fill in the basic info fields
nameField.setText('Mario')
Expand Down
2 changes: 1 addition & 1 deletion apps/deno/tests/test15.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default async (assets: Assets) => {
);

form.getTextField('FactionName').setText(`Mario's Emblem`);
form.getButton('Faction Symbol Image').setImage(emblemImage);
form.getTextField('Faction Symbol Image').setImage(emblemImage);

form
.getTextField('Backstory')
Expand Down
2 changes: 1 addition & 1 deletion apps/node/tests/test15.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export default async (assets: Assets) => {
);

form.getTextField('FactionName').setText(`Mario's Emblem`);
form.getButton('Faction Symbol Image').setImage(emblemImage);
form.getTextField('Faction Symbol Image').setImage(emblemImage);

form
.getTextField('Backstory')
Expand Down
2 changes: 1 addition & 1 deletion apps/rn/src/tests/test15.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default async () => {
);

form.getTextField('FactionName').setText(`Mario's Emblem`);
form.getButton('Faction Symbol Image').setImage(emblemImage);
form.getTextField('Faction Symbol Image').setImage(emblemImage);

form
.getTextField('Backstory')
Expand Down
2 changes: 1 addition & 1 deletion apps/web/test15.html
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
);

form.getTextField('FactionName').setText(`Mario's Emblem`);
form.getButton('Faction Symbol Image').setImage(emblemImage);
form.getTextField('Faction Symbol Image').setImage(emblemImage);

form
.getTextField('Backstory')
Expand Down
Binary file modified assets/pdfs/dod_character.pdf
Binary file not shown.
71 changes: 8 additions & 63 deletions src/api/form/PDFButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import PDFDocument from 'src/api/PDFDocument';
import PDFPage from 'src/api/PDFPage';
import PDFFont from 'src/api/PDFFont';
import PDFImage from 'src/api/PDFImage';
import { ImageAlignment } from 'src/api/image/alignment';
import {
AppearanceProviderFor,
normalizeAppearance,
Expand All @@ -12,20 +13,16 @@ import PDFField, {
assertFieldAppearanceOptions,
} from 'src/api/form/PDFField';
import { rgb } from 'src/api/colors';
import {
degrees,
adjustDimsForRotation,
reduceRotation,
} from 'src/api/rotations';
import { drawImage, rotateInPlace } from 'src/api/operations';
import { degrees } from 'src/api/rotations';
import { createWidgetImageStream } from 'src/api/operations';

import {
PDFRef,
PDFStream,
PDFAcroPushButton,
PDFWidgetAnnotation,
} from 'src/core';
import { assertIs, assertOrUndefined, addRandomSuffix } from 'src/utils';
import { assertIs, assertOrUndefined } from 'src/utils';

/**
* Represents a button field of a [[PDFForm]].
Expand Down Expand Up @@ -71,74 +68,22 @@ export default class PDFButton extends PDFField {
this.acroField = acroPushButton;
}

// NOTE: This doesn't handle image borders.
// NOTE: Acrobat seems to resize the image (maybe even skewing its aspect
// ratio) to fit perfectly within the widget's rectangle. This method
// does not currently do that. Should there be an option for that?
/**
* Display an image inside the bounds of this button's widgets. For example:
* ```js
* const pngImage = await pdfDoc.embedPng(...)
* const button = form.getButton('some.button.field')
* button.setImage(pngImage)
* button.setImage(pngImage, TextAlignment.Center)
* ```
* This will update the appearances streams for each of this button's widgets.
* @param image The image that should be displayed.
* @param alignment The alignment of the image.
*/
setImage(image: PDFImage) {
// Create appearance stream with image, ignoring caption property
const { context } = this.acroField.dict;

setImage(image: PDFImage, alignment?: ImageAlignment) {
const widgets = this.acroField.getWidgets();
for (let idx = 0, len = widgets.length; idx < len; idx++) {
const widget = widgets[idx];

////////////
const rectangle = widget.getRectangle();
const ap = widget.getAppearanceCharacteristics();
const bs = widget.getBorderStyle();

const borderWidth = bs?.getWidth() ?? 1;
const rotation = reduceRotation(ap?.getRotation());

const rotate = rotateInPlace({ ...rectangle, rotation });

const adj = adjustDimsForRotation(rectangle, rotation);
const imageDims = image.scaleToFit(
adj.width - borderWidth * 2,
adj.height - borderWidth * 2,
);

const drawingArea = {
x: 0 + borderWidth,
y: 0 + borderWidth,
width: adj.width - borderWidth * 2,
height: adj.height - borderWidth * 2,
};

// Support borders on images and maybe other properties
const options = {
x: drawingArea.x + (drawingArea.width / 2 - imageDims.width / 2),
y: drawingArea.y + (drawingArea.height / 2 - imageDims.height / 2),
width: imageDims.width,
height: imageDims.height,
//
rotate: degrees(0),
xSkew: degrees(0),
ySkew: degrees(0),
};

const imageName = addRandomSuffix('Image', 10);
const appearance = [...rotate, ...drawImage(imageName, options)];
////////////

const Resources = { XObject: { [imageName]: image.ref } };
const stream = context.formXObject(appearance, {
Resources,
BBox: context.obj([0, 0, rectangle.width, rectangle.height]),
Matrix: context.obj([1, 0, 0, 1, 0, 0]),
});
const streamRef = context.register(stream);
const streamRef = createWidgetImageStream(widget, alignment ?? ImageAlignment.Center, image);

this.updateWidgetAppearances(widget, { normal: streamRef });
}
Expand Down
32 changes: 32 additions & 0 deletions src/api/form/PDFTextField.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import PDFDocument from 'src/api/PDFDocument';
import PDFPage from 'src/api/PDFPage';
import PDFFont from 'src/api/PDFFont';
import PDFImage from 'src/api/PDFImage';
import PDFField, {
FieldAppearanceOptions,
assertFieldAppearanceOptions,
Expand All @@ -11,12 +12,14 @@ import {
defaultTextFieldAppearanceProvider,
} from 'src/api/form/appearances';
import { rgb } from 'src/api/colors';
import { createWidgetImageStream } from 'src/api/operations';
import { degrees } from 'src/api/rotations';
import {
RichTextFieldReadError,
ExceededMaxLengthError,
InvalidMaxLengthError,
} from 'src/api/errors';
import { ImageAlignment } from 'src/api/image/alignment';
import { TextAlignment } from 'src/api/text/alignment';

import {
Expand Down Expand Up @@ -681,6 +684,35 @@ export default class PDFTextField extends PDFField {
page.node.addAnnot(widgetRef);
}

/**
* Display an image inside the bounds of this text field's widgets. For example:
* ```js
* const pngImage = await pdfDoc.embedPng(...)
* const textField = form.getTextField('some.text.field')
* textField.setImage(pngImage)
* ```
* This will update the appearances streams for each of this text field's widgets.
* @param image The image that should be displayed.
*/
setImage(image: PDFImage) {
const fieldAlignment = this.getAlignment();
const alignment = fieldAlignment === TextAlignment.Center
? ImageAlignment.Center
: fieldAlignment === TextAlignment.Right
? ImageAlignment.Right
: ImageAlignment.Left;

const widgets = this.acroField.getWidgets();
for (let idx = 0, len = widgets.length; idx < len; idx++) {
const widget = widgets[idx];
const streamRef = createWidgetImageStream(widget, alignment, image);

this.updateWidgetAppearances(widget, { normal: streamRef });
}

this.markAsClean();
}

/**
* Returns `true` if this text field has been marked as dirty, or if any of
* this text field's widgets do not have an appearance stream. For example:
Expand Down
5 changes: 5 additions & 0 deletions src/api/image/alignment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum ImageAlignment {
Left = 0,
Center = 1,
Right = 2,
}
1 change: 1 addition & 0 deletions src/api/image/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from 'src/api/image/alignment';
81 changes: 79 additions & 2 deletions src/api/operations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import PDFImage from 'src/api/PDFImage';
import { Color, setFillingColor, setStrokingColor } from 'src/api/colors';
import { ImageAlignment } from 'src/api/image/alignment';
import {
beginText,
closePath,
Expand Down Expand Up @@ -32,10 +34,17 @@ import {
endPath,
appendBezierCurve,
} from 'src/api/operators';
import { Rotation, toRadians, degrees } from 'src/api/rotations';
import {
Rotation,
adjustDimsForRotation,
degrees,
reduceRotation,
toRadians
} from 'src/api/rotations';
import { svgPathToOperators } from 'src/api/svgPath';
import { PDFHexString, PDFName, PDFNumber, PDFOperator } from 'src/core';
import { PDFHexString, PDFName, PDFNumber, PDFOperator, PDFRef, PDFWidgetAnnotation } from 'src/core';
import { asNumber } from 'src/api/objects';
import { addRandomSuffix } from 'src/utils';

export interface DrawTextOptions {
color: Color;
Expand Down Expand Up @@ -798,3 +807,71 @@ export const drawOptionList = (options: {
popGraphicsState(),
];
};

// NOTE: This doesn't handle image borders.
// NOTE: Acrobat seems to resize the image (maybe even skewing its aspect
// ratio) to fit perfectly within the widget's rectangle. This method
// does not currently do that. Should there be an option for that?
/**
* Calculate the image size based on the widget, including the alignment and add it to context.
*
* @param widget The widget that should display the image.
* @param alignment The alignment of the image.
* @param image The image that should be displayed.
* @returns The PDFRef for the widget image stream that was added to the context.
*/
export const createWidgetImageStream = (
widget: PDFWidgetAnnotation,
alignment: ImageAlignment,
image: PDFImage
): PDFRef => {
const { context } = widget.dict;

const rectangle = widget.getRectangle();
const ap = widget.getAppearanceCharacteristics();
const bs = widget.getBorderStyle();

const borderWidth = bs?.getWidth() ?? 0;
const rotation = reduceRotation(ap?.getRotation());

const rotate = rotateInPlace({ ...rectangle, rotation });

const adj = adjustDimsForRotation(rectangle, rotation);
const imageDims = image.scaleToFit(
adj.width - borderWidth * 2,
adj.height - borderWidth * 2,
);

// Support borders on images and maybe other properties
const options = {
x: borderWidth,
y: borderWidth,
width: imageDims.width,
height: imageDims.height,
//
rotate: degrees(0),
xSkew: degrees(0),
ySkew: degrees(0),
};

if (alignment === ImageAlignment.Center) {
options.x += (adj.width - borderWidth * 2) / 2 - imageDims.width / 2;
options.y += (adj.height - borderWidth * 2) / 2 - imageDims.height / 2;
} else if (alignment === ImageAlignment.Right) {
options.x = adj.width - borderWidth - imageDims.width;
options.y = adj.height - borderWidth - imageDims.height;
}

const imageName = addRandomSuffix('Image', 10);
const appearance = [...rotate, ...drawImage(imageName, options)];
////////////

const Resources = { XObject: { [imageName]: image.ref } };
const stream = context.formXObject(appearance, {
Resources,
BBox: context.obj([0, 0, rectangle.width, rectangle.height]),
Matrix: context.obj([1, 0, 0, 1, 0, 0]),
});

return context.register(stream);
}

0 comments on commit 7dc89cb

Please sign in to comment.