feature: vor und nachteile

This commit is contained in:
Hikyu
2026-05-31 13:54:31 +02:00
parent 552a947a1c
commit e3dad17446
23270 changed files with 3024794 additions and 200 deletions
+7
View File
@@ -0,0 +1,7 @@
/**
* A PDF entity, like images or fonts, which needs to be embedded into the
* document before saving.
*/
export default interface Embeddable {
embed: () => Promise<void>;
}
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
import { EmbeddedFileOptions } from 'src/core/embedders/FileEmbedder';
import { TypeFeatures } from 'src/types/fontkit';
export enum ParseSpeeds {
Fastest = Infinity,
Fast = 1500,
Medium = 500,
Slow = 100,
}
export interface AttachmentOptions extends EmbeddedFileOptions {}
export interface SaveOptions {
useObjectStreams?: boolean;
addDefaultPage?: boolean;
objectsPerTick?: number;
updateFieldAppearances?: boolean;
}
export interface Base64SaveOptions extends SaveOptions {
dataUri?: boolean;
}
export interface LoadOptions {
ignoreEncryption?: boolean;
parseSpeed?: ParseSpeeds | number;
throwOnInvalidObject?: boolean;
updateMetadata?: boolean;
capNumbers?: boolean;
}
export interface CreateOptions {
updateMetadata?: boolean;
}
export interface EmbedFontOptions {
subset?: boolean;
customName?: string;
features?: TypeFeatures;
}
export interface SetTitleOptions {
showInWindowTitleBar: boolean;
}
+90
View File
@@ -0,0 +1,90 @@
import Embeddable from 'src/api/Embeddable';
import PDFDocument from 'src/api/PDFDocument';
import FileEmbedder from 'src/core/embedders/FileEmbedder';
import { PDFName, PDFArray, PDFDict, PDFHexString, PDFRef } from 'src/core';
/**
* Represents a file that has been embedded in a [[PDFDocument]].
*/
export default class PDFEmbeddedFile implements Embeddable {
/**
* > **NOTE:** You probably don't want to call this method directly. Instead,
* > consider using the [[PDFDocument.attach]] method, which will create
* instances of [[PDFEmbeddedFile]] for you.
*
* Create an instance of [[PDFEmbeddedFile]] from an existing ref and embedder
*
* @param ref The unique reference for this file.
* @param doc The document to which the file will belong.
* @param embedder The embedder that will be used to embed the file.
*/
static of = (ref: PDFRef, doc: PDFDocument, embedder: FileEmbedder) =>
new PDFEmbeddedFile(ref, doc, embedder);
/** The unique reference assigned to this embedded file within the document. */
readonly ref: PDFRef;
/** The document to which this embedded file belongs. */
readonly doc: PDFDocument;
private alreadyEmbedded = false;
private readonly embedder: FileEmbedder;
private constructor(ref: PDFRef, doc: PDFDocument, embedder: FileEmbedder) {
this.ref = ref;
this.doc = doc;
this.embedder = embedder;
}
/**
* > **NOTE:** You probably don't need to call this method directly. The
* > [[PDFDocument.save]] and [[PDFDocument.saveAsBase64]] methods will
* > automatically ensure all embeddable files get embedded.
*
* Embed this embeddable file in its document.
*
* @returns Resolves when the embedding is complete.
*/
async embed(): Promise<void> {
if (!this.alreadyEmbedded) {
const ref = await this.embedder.embedIntoContext(
this.doc.context,
this.ref,
);
if (!this.doc.catalog.has(PDFName.of('Names'))) {
this.doc.catalog.set(PDFName.of('Names'), this.doc.context.obj({}));
}
const Names = this.doc.catalog.lookup(PDFName.of('Names'), PDFDict);
if (!Names.has(PDFName.of('EmbeddedFiles'))) {
Names.set(PDFName.of('EmbeddedFiles'), this.doc.context.obj({}));
}
const EmbeddedFiles = Names.lookup(PDFName.of('EmbeddedFiles'), PDFDict);
if (!EmbeddedFiles.has(PDFName.of('Names'))) {
EmbeddedFiles.set(PDFName.of('Names'), this.doc.context.obj([]));
}
const EFNames = EmbeddedFiles.lookup(PDFName.of('Names'), PDFArray);
EFNames.push(PDFHexString.fromText(this.embedder.fileName));
EFNames.push(ref);
/**
* The AF-Tag is needed to achieve PDF-A3 compliance for embedded files
*
* The following document outlines the uses cases of the associated files (AF) tag.
* See:
* https://www.pdfa.org/wp-content/uploads/2018/10/PDF20_AN002-AF.pdf
*/
if (!this.doc.catalog.has(PDFName.of('AF'))) {
this.doc.catalog.set(PDFName.of('AF'), this.doc.context.obj([]));
}
const AF = this.doc.catalog.lookup(PDFName.of('AF'), PDFArray);
AF.push(ref);
this.alreadyEmbedded = true;
}
}
}
+104
View File
@@ -0,0 +1,104 @@
import Embeddable from 'src/api/Embeddable';
import PDFDocument from 'src/api/PDFDocument';
import { PDFPageEmbedder, PDFRef } from 'src/core';
import { assertIs } from 'src/utils';
/**
* Represents a PDF page that has been embedded in a [[PDFDocument]].
*/
export default class PDFEmbeddedPage implements Embeddable {
/**
* > **NOTE:** You probably don't want to call this method directly. Instead,
* > consider using the [[PDFDocument.embedPdf]] and
* > [[PDFDocument.embedPage]] methods, which will create instances of
* > [[PDFEmbeddedPage]] for you.
*
* Create an instance of [[PDFEmbeddedPage]] from an existing ref and embedder
*
* @param ref The unique reference for this embedded page.
* @param doc The document to which the embedded page will belong.
* @param embedder The embedder that will be used to embed the page.
*/
static of = (ref: PDFRef, doc: PDFDocument, embedder: PDFPageEmbedder) =>
new PDFEmbeddedPage(ref, doc, embedder);
/** The unique reference assigned to this embedded page within the document. */
readonly ref: PDFRef;
/** The document to which this embedded page belongs. */
readonly doc: PDFDocument;
/** The width of this page in pixels. */
readonly width: number;
/** The height of this page in pixels. */
readonly height: number;
private alreadyEmbedded = false;
private readonly embedder: PDFPageEmbedder;
private constructor(
ref: PDFRef,
doc: PDFDocument,
embedder: PDFPageEmbedder,
) {
assertIs(ref, 'ref', [[PDFRef, 'PDFRef']]);
assertIs(doc, 'doc', [[PDFDocument, 'PDFDocument']]);
assertIs(embedder, 'embedder', [[PDFPageEmbedder, 'PDFPageEmbedder']]);
this.ref = ref;
this.doc = doc;
this.width = embedder.width;
this.height = embedder.height;
this.embedder = embedder;
}
/**
* Compute the width and height of this page after being scaled by the
* given `factor`. For example:
* ```js
* embeddedPage.width // => 500
* embeddedPage.height // => 250
*
* const scaled = embeddedPage.scale(0.5)
* scaled.width // => 250
* scaled.height // => 125
* ```
* This operation is often useful before drawing a page with
* [[PDFPage.drawPage]] to compute the `width` and `height` options.
* @param factor The factor by which this page should be scaled.
* @returns The width and height of the page after being scaled.
*/
scale(factor: number) {
assertIs(factor, 'factor', ['number']);
return { width: this.width * factor, height: this.height * factor };
}
/**
* Get the width and height of this page. For example:
* ```js
* const { width, height } = embeddedPage.size()
* ```
* @returns The width and height of the page.
*/
size() {
return this.scale(1);
}
/**
* > **NOTE:** You probably don't need to call this method directly. The
* > [[PDFDocument.save]] and [[PDFDocument.saveAsBase64]] methods will
* > automatically ensure all embeddable pages get embedded.
*
* Embed this embeddable page in its document.
*
* @returns Resolves when the embedding is complete.
*/
async embed(): Promise<void> {
if (!this.alreadyEmbedded) {
await this.embedder.embedIntoContext(this.doc.context, this.ref);
this.alreadyEmbedded = true;
}
}
}
+154
View File
@@ -0,0 +1,154 @@
import Embeddable from 'src/api//Embeddable';
import PDFDocument from 'src/api/PDFDocument';
import {
CustomFontEmbedder,
PDFHexString,
PDFRef,
StandardFontEmbedder,
} from 'src/core';
import { assertIs, assertOrUndefined } from 'src/utils';
export type FontEmbedder = CustomFontEmbedder | StandardFontEmbedder;
/**
* Represents a font that has been embedded in a [[PDFDocument]].
*/
export default class PDFFont implements Embeddable {
/**
* > **NOTE:** You probably don't want to call this method directly. Instead,
* > consider using the [[PDFDocument.embedFont]] and
* > [[PDFDocument.embedStandardFont]] methods, which will create instances
* > of [[PDFFont]] for you.
*
* Create an instance of [[PDFFont]] from an existing ref and embedder
*
* @param ref The unique reference for this font.
* @param doc The document to which the font will belong.
* @param embedder The embedder that will be used to embed the font.
*/
static of = (ref: PDFRef, doc: PDFDocument, embedder: FontEmbedder) =>
new PDFFont(ref, doc, embedder);
/** The unique reference assigned to this font within the document. */
readonly ref: PDFRef;
/** The document to which this font belongs. */
readonly doc: PDFDocument;
/** The name of this font. */
readonly name: string;
private modified = true;
private readonly embedder: FontEmbedder;
private constructor(ref: PDFRef, doc: PDFDocument, embedder: FontEmbedder) {
assertIs(ref, 'ref', [[PDFRef, 'PDFRef']]);
assertIs(doc, 'doc', [[PDFDocument, 'PDFDocument']]);
assertIs(embedder, 'embedder', [
[CustomFontEmbedder, 'CustomFontEmbedder'],
[StandardFontEmbedder, 'StandardFontEmbedder'],
]);
this.ref = ref;
this.doc = doc;
this.name = embedder.fontName;
this.embedder = embedder;
}
/**
* > **NOTE:** You probably don't need to call this method directly. The
* > [[PDFPage.drawText]] method will automatically encode the text it is
* > given.
*
* Encodes a string of text in this font.
*
* @param text The text to be encoded.
* @returns The encoded text as a hex string.
*/
encodeText(text: string): PDFHexString {
assertIs(text, 'text', ['string']);
this.modified = true;
return this.embedder.encodeText(text);
}
/**
* Measure the width of a string of text drawn in this font at a given size.
* For example:
* ```js
* const width = font.widthOfTextAtSize('Foo Bar Qux Baz', 36)
* ```
* @param text The string of text to be measured.
* @param size The font size to be used for this measurement.
* @returns The width of the string of text when drawn in this font at the
* given size.
*/
widthOfTextAtSize(text: string, size: number): number {
assertIs(text, 'text', ['string']);
assertIs(size, 'size', ['number']);
return this.embedder.widthOfTextAtSize(text, size);
}
/**
* Measure the height of this font at a given size. For example:
* ```js
* const height = font.heightAtSize(24)
* ```
*
* The `options.descender` value controls whether or not the font's
* descender is included in the height calculation.
*
* @param size The font size to be used for this measurement.
* @param options The options to be used when computing this measurement.
* @returns The height of this font at the given size.
*/
heightAtSize(size: number, options?: { descender?: boolean }): number {
assertIs(size, 'size', ['number']);
assertOrUndefined(options?.descender, 'options.descender', ['boolean']);
return this.embedder.heightOfFontAtSize(size, {
descender: options?.descender ?? true,
});
}
/**
* Compute the font size at which this font is a given height. For example:
* ```js
* const fontSize = font.sizeAtHeight(12)
* ```
* @param height The height to be used for this calculation.
* @returns The font size at which this font is the given height.
*/
sizeAtHeight(height: number): number {
assertIs(height, 'height', ['number']);
return this.embedder.sizeOfFontAtHeight(height);
}
/**
* Get the set of unicode code points that can be represented by this font.
* @returns The set of unicode code points supported by this font.
*/
getCharacterSet(): number[] {
if (this.embedder instanceof StandardFontEmbedder) {
return this.embedder.encoding.supportedCodePoints;
} else {
return this.embedder.font.characterSet;
}
}
/**
* > **NOTE:** You probably don't need to call this method directly. The
* > [[PDFDocument.save]] and [[PDFDocument.saveAsBase64]] methods will
* > automatically ensure all fonts get embedded.
*
* Embed this font in its document.
*
* @returns Resolves when the embedding is complete.
*/
async embed(): Promise<void> {
// TODO: Cleanup orphan embedded objects if a font is embedded multiple times...
if (this.modified) {
await this.embedder.embedIntoContext(this.doc.context, this.ref);
this.modified = false;
}
}
}
+143
View File
@@ -0,0 +1,143 @@
import Embeddable from 'src/api/Embeddable';
import PDFDocument from 'src/api/PDFDocument';
import { JpegEmbedder, PDFRef, PngEmbedder } from 'src/core';
import { assertIs } from 'src/utils';
export type ImageEmbedder = JpegEmbedder | PngEmbedder;
/**
* Represents an image that has been embedded in a [[PDFDocument]].
*/
export default class PDFImage implements Embeddable {
/**
* > **NOTE:** You probably don't want to call this method directly. Instead,
* > consider using the [[PDFDocument.embedPng]] and [[PDFDocument.embedJpg]]
* > methods, which will create instances of [[PDFImage]] for you.
*
* Create an instance of [[PDFImage]] from an existing ref and embedder
*
* @param ref The unique reference for this image.
* @param doc The document to which the image will belong.
* @param embedder The embedder that will be used to embed the image.
*/
static of = (ref: PDFRef, doc: PDFDocument, embedder: ImageEmbedder) =>
new PDFImage(ref, doc, embedder);
/** The unique reference assigned to this image within the document. */
readonly ref: PDFRef;
/** The document to which this image belongs. */
readonly doc: PDFDocument;
/** The width of this image in pixels. */
readonly width: number;
/** The height of this image in pixels. */
readonly height: number;
private embedder: ImageEmbedder | undefined;
private embedTask: Promise<PDFRef> | undefined;
private constructor(ref: PDFRef, doc: PDFDocument, embedder: ImageEmbedder) {
assertIs(ref, 'ref', [[PDFRef, 'PDFRef']]);
assertIs(doc, 'doc', [[PDFDocument, 'PDFDocument']]);
assertIs(embedder, 'embedder', [
[JpegEmbedder, 'JpegEmbedder'],
[PngEmbedder, 'PngEmbedder'],
]);
this.ref = ref;
this.doc = doc;
this.width = embedder.width;
this.height = embedder.height;
this.embedder = embedder;
}
/**
* Compute the width and height of this image after being scaled by the
* given `factor`. For example:
* ```js
* image.width // => 500
* image.height // => 250
*
* const scaled = image.scale(0.5)
* scaled.width // => 250
* scaled.height // => 125
* ```
* This operation is often useful before drawing an image with
* [[PDFPage.drawImage]] to compute the `width` and `height` options.
* @param factor The factor by which this image should be scaled.
* @returns The width and height of the image after being scaled.
*/
scale(factor: number) {
assertIs(factor, 'factor', ['number']);
return { width: this.width * factor, height: this.height * factor };
}
/**
* Get the width and height of this image after scaling it as large as
* possible while maintaining its aspect ratio and not exceeding the
* specified `width` and `height`. For example:
* ```
* image.width // => 500
* image.height // => 250
*
* const scaled = image.scaleToFit(750, 1000)
* scaled.width // => 750
* scaled.height // => 375
* ```
* The `width` and `height` parameters can also be thought of as the width
* and height of a box that the scaled image must fit within.
* @param width The bounding box's width.
* @param height The bounding box's height.
* @returns The width and height of the image after being scaled.
*/
scaleToFit(width: number, height: number) {
assertIs(width, 'width', ['number']);
assertIs(height, 'height', ['number']);
const imgWidthScale = width / this.width;
const imgHeightScale = height / this.height;
const scale = Math.min(imgWidthScale, imgHeightScale);
return this.scale(scale);
}
/**
* Get the width and height of this image. For example:
* ```js
* const { width, height } = image.size()
* ```
* @returns The width and height of the image.
*/
size() {
return this.scale(1);
}
/**
* > **NOTE:** You probably don't need to call this method directly. The
* > [[PDFDocument.save]] and [[PDFDocument.saveAsBase64]] methods will
* > automatically ensure all images get embedded.
*
* Embed this image in its document.
*
* @returns Resolves when the embedding is complete.
*/
async embed(): Promise<void> {
if (!this.embedder) return;
// The image should only be embedded once. If there's a pending embed
// operation then wait on it. Otherwise we need to start the embed.
if (!this.embedTask) {
const { doc, ref } = this;
this.embedTask = this.embedder.embedIntoContext(doc.context, ref);
}
await this.embedTask;
// We clear `this.embedder` so that the indirectly referenced image data
// can be garbage collected, thus avoiding a memory leak.
// See https://github.com/Hopding/pdf-lib/pull/1032/files.
this.embedder = undefined;
}
}
+82
View File
@@ -0,0 +1,82 @@
import Embeddable from 'src/api/Embeddable';
import PDFDocument from 'src/api/PDFDocument';
import JavaScriptEmbedder from 'src/core/embedders/JavaScriptEmbedder';
import { PDFName, PDFArray, PDFDict, PDFHexString, PDFRef } from 'src/core';
/**
* Represents JavaScript that has been embedded in a [[PDFDocument]].
*/
export default class PDFJavaScript implements Embeddable {
/**
* > **NOTE:** You probably don't want to call this method directly. Instead,
* > consider using the [[PDFDocument.addJavaScript]] method, which will
* create instances of [[PDFJavaScript]] for you.
*
* Create an instance of [[PDFJavaScript]] from an existing ref and script
*
* @param ref The unique reference for this script.
* @param doc The document to which the script will belong.
* @param embedder The embedder that will be used to embed the script.
*/
static of = (ref: PDFRef, doc: PDFDocument, embedder: JavaScriptEmbedder) =>
new PDFJavaScript(ref, doc, embedder);
/** The unique reference assigned to this embedded script within the document. */
readonly ref: PDFRef;
/** The document to which this embedded script belongs. */
readonly doc: PDFDocument;
private alreadyEmbedded = false;
private readonly embedder: JavaScriptEmbedder;
private constructor(
ref: PDFRef,
doc: PDFDocument,
embedder: JavaScriptEmbedder,
) {
this.ref = ref;
this.doc = doc;
this.embedder = embedder;
}
/**
* > **NOTE:** You probably don't need to call this method directly. The
* > [[PDFDocument.save]] and [[PDFDocument.saveAsBase64]] methods will
* > automatically ensure all JavaScripts get embedded.
*
* Embed this JavaScript in its document.
*
* @returns Resolves when the embedding is complete.
*/
async embed(): Promise<void> {
if (!this.alreadyEmbedded) {
const { catalog, context } = this.doc;
const ref = await this.embedder.embedIntoContext(
this.doc.context,
this.ref,
);
if (!catalog.has(PDFName.of('Names'))) {
catalog.set(PDFName.of('Names'), context.obj({}));
}
const Names = catalog.lookup(PDFName.of('Names'), PDFDict);
if (!Names.has(PDFName.of('JavaScript'))) {
Names.set(PDFName.of('JavaScript'), context.obj({}));
}
const Javascript = Names.lookup(PDFName.of('JavaScript'), PDFDict);
if (!Javascript.has(PDFName.of('Names'))) {
Javascript.set(PDFName.of('Names'), context.obj([]));
}
const JSNames = Javascript.lookup(PDFName.of('Names'), PDFArray);
JSNames.push(PDFHexString.fromText(this.embedder.scriptName));
JSNames.push(ref);
this.alreadyEmbedded = true;
}
}
}
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
import { Color } from 'src/api/colors';
import PDFFont from 'src/api/PDFFont';
import { Rotation } from 'src/api/rotations';
import { LineCapStyle } from 'src/api/operators';
export enum BlendMode {
Normal = 'Normal',
Multiply = 'Multiply',
Screen = 'Screen',
Overlay = 'Overlay',
Darken = 'Darken',
Lighten = 'Lighten',
ColorDodge = 'ColorDodge',
ColorBurn = 'ColorBurn',
HardLight = 'HardLight',
SoftLight = 'SoftLight',
Difference = 'Difference',
Exclusion = 'Exclusion',
}
export interface PDFPageDrawTextOptions {
color?: Color;
opacity?: number;
blendMode?: BlendMode;
font?: PDFFont;
size?: number;
rotate?: Rotation;
xSkew?: Rotation;
ySkew?: Rotation;
x?: number;
y?: number;
lineHeight?: number;
maxWidth?: number;
wordBreaks?: string[];
}
export interface PDFPageDrawImageOptions {
x?: number;
y?: number;
width?: number;
height?: number;
rotate?: Rotation;
xSkew?: Rotation;
ySkew?: Rotation;
opacity?: number;
blendMode?: BlendMode;
}
export interface PDFPageDrawPageOptions {
x?: number;
y?: number;
xScale?: number;
yScale?: number;
width?: number;
height?: number;
rotate?: Rotation;
xSkew?: Rotation;
ySkew?: Rotation;
opacity?: number;
blendMode?: BlendMode;
}
export interface PDFPageDrawSVGOptions {
x?: number;
y?: number;
scale?: number;
rotate?: Rotation;
borderWidth?: number;
color?: Color;
opacity?: number;
borderColor?: Color;
borderOpacity?: number;
borderDashArray?: number[];
borderDashPhase?: number;
borderLineCap?: LineCapStyle;
blendMode?: BlendMode;
}
export interface PDFPageDrawLineOptions {
start: { x: number; y: number };
end: { x: number; y: number };
thickness?: number;
color?: Color;
opacity?: number;
lineCap?: LineCapStyle;
dashArray?: number[];
dashPhase?: number;
blendMode?: BlendMode;
}
export interface PDFPageDrawRectangleOptions {
x?: number;
y?: number;
width?: number;
height?: number;
rotate?: Rotation;
xSkew?: Rotation;
ySkew?: Rotation;
borderWidth?: number;
color?: Color;
opacity?: number;
borderColor?: Color;
borderOpacity?: number;
borderDashArray?: number[];
borderDashPhase?: number;
borderLineCap?: LineCapStyle;
blendMode?: BlendMode;
}
export interface PDFPageDrawSquareOptions {
x?: number;
y?: number;
size?: number;
rotate?: Rotation;
xSkew?: Rotation;
ySkew?: Rotation;
borderWidth?: number;
color?: Color;
opacity?: number;
borderColor?: Color;
borderOpacity?: number;
borderDashArray?: number[];
borderDashPhase?: number;
borderLineCap?: LineCapStyle;
blendMode?: BlendMode;
}
export interface PDFPageDrawEllipseOptions {
x?: number;
y?: number;
xScale?: number;
yScale?: number;
rotate?: Rotation;
color?: Color;
opacity?: number;
borderColor?: Color;
borderOpacity?: number;
borderWidth?: number;
borderDashArray?: number[];
borderDashPhase?: number;
borderLineCap?: LineCapStyle;
blendMode?: BlendMode;
}
export interface PDFPageDrawCircleOptions {
x?: number;
y?: number;
size?: number;
color?: Color;
opacity?: number;
borderColor?: Color;
borderOpacity?: number;
borderWidth?: number;
borderDashArray?: number[];
borderDashPhase?: number;
borderLineCap?: LineCapStyle;
blendMode?: BlendMode;
}
+16
View File
@@ -0,0 +1,16 @@
export enum StandardFonts {
Courier = 'Courier',
CourierBold = 'Courier-Bold',
CourierOblique = 'Courier-Oblique',
CourierBoldOblique = 'Courier-BoldOblique',
Helvetica = 'Helvetica',
HelveticaBold = 'Helvetica-Bold',
HelveticaOblique = 'Helvetica-Oblique',
HelveticaBoldOblique = 'Helvetica-BoldOblique',
TimesRoman = 'Times-Roman',
TimesRomanBold = 'Times-Bold',
TimesRomanItalic = 'Times-Italic',
TimesRomanBoldItalic = 'Times-BoldItalic',
Symbol = 'Symbol',
ZapfDingbats = 'ZapfDingbats',
}
+104
View File
@@ -0,0 +1,104 @@
import {
setFillingCmykColor,
setFillingGrayscaleColor,
setFillingRgbColor,
setStrokingCmykColor,
setStrokingGrayscaleColor,
setStrokingRgbColor,
} from 'src/api/operators';
import { assertRange, error } from 'src/utils';
export enum ColorTypes {
Grayscale = 'Grayscale',
RGB = 'RGB',
CMYK = 'CMYK',
}
export interface Grayscale {
type: ColorTypes.Grayscale;
gray: number;
}
export interface RGB {
type: ColorTypes.RGB;
red: number;
green: number;
blue: number;
}
export interface CMYK {
type: ColorTypes.CMYK;
cyan: number;
magenta: number;
yellow: number;
key: number;
}
export type Color = Grayscale | RGB | CMYK;
export const grayscale = (gray: number): Grayscale => {
assertRange(gray, 'gray', 0.0, 1.0);
return { type: ColorTypes.Grayscale, gray };
};
export const rgb = (red: number, green: number, blue: number): RGB => {
assertRange(red, 'red', 0, 1);
assertRange(green, 'green', 0, 1);
assertRange(blue, 'blue', 0, 1);
return { type: ColorTypes.RGB, red, green, blue };
};
export const cmyk = (
cyan: number,
magenta: number,
yellow: number,
key: number,
): CMYK => {
assertRange(cyan, 'cyan', 0, 1);
assertRange(magenta, 'magenta', 0, 1);
assertRange(yellow, 'yellow', 0, 1);
assertRange(key, 'key', 0, 1);
return { type: ColorTypes.CMYK, cyan, magenta, yellow, key };
};
const { Grayscale, RGB, CMYK } = ColorTypes;
// prettier-ignore
export const setFillingColor = (color: Color) =>
color.type === Grayscale ? setFillingGrayscaleColor(color.gray)
: color.type === RGB ? setFillingRgbColor(color.red, color.green, color.blue)
: color.type === CMYK ? setFillingCmykColor(color.cyan, color.magenta, color.yellow, color.key)
: error(`Invalid color: ${JSON.stringify(color)}`);
// prettier-ignore
export const setStrokingColor = (color: Color) =>
color.type === Grayscale ? setStrokingGrayscaleColor(color.gray)
: color.type === RGB ? setStrokingRgbColor(color.red, color.green, color.blue)
: color.type === CMYK ? setStrokingCmykColor(color.cyan, color.magenta, color.yellow, color.key)
: error(`Invalid color: ${JSON.stringify(color)}`);
// prettier-ignore
export const componentsToColor = (comps?: number[], scale = 1) => (
comps?.length === 1 ? grayscale(
comps[0] * scale,
)
: comps?.length === 3 ? rgb(
comps[0] * scale,
comps[1] * scale,
comps[2] * scale,
)
: comps?.length === 4 ? cmyk(
comps[0] * scale,
comps[1] * scale,
comps[2] * scale,
comps[3] * scale,
)
: undefined
);
// prettier-ignore
export const colorToComponents = (color: Color) =>
color.type === Grayscale ? [color.gray]
: color.type === RGB ? [color.red, color.green, color.blue]
: color.type === CMYK ? [color.cyan, color.magenta, color.yellow, color.key]
: error(`Invalid color: ${JSON.stringify(color)}`);
+111
View File
@@ -0,0 +1,111 @@
// tslint:disable: max-classes-per-file
// TODO: Include link to documentation with example
export class EncryptedPDFError extends Error {
constructor() {
const msg =
'Input document to `PDFDocument.load` is encrypted. You can use `PDFDocument.load(..., { ignoreEncryption: true })` if you wish to load the document anyways.';
super(msg);
}
}
// TODO: Include link to documentation with example
export class FontkitNotRegisteredError extends Error {
constructor() {
const msg =
'Input to `PDFDocument.embedFont` was a custom font, but no `fontkit` instance was found. You must register a `fontkit` instance with `PDFDocument.registerFontkit(...)` before embedding custom fonts.';
super(msg);
}
}
// TODO: Include link to documentation with example
export class ForeignPageError extends Error {
constructor() {
const msg =
'A `page` passed to `PDFDocument.addPage` or `PDFDocument.insertPage` was from a different (foreign) PDF document. If you want to copy pages from one PDFDocument to another, you must use `PDFDocument.copyPages(...)` to copy the pages before adding or inserting them.';
super(msg);
}
}
// TODO: Include link to documentation with example
export class RemovePageFromEmptyDocumentError extends Error {
constructor() {
const msg =
'PDFDocument has no pages so `PDFDocument.removePage` cannot be called';
super(msg);
}
}
export class NoSuchFieldError extends Error {
constructor(name: string) {
const msg = `PDFDocument has no form field with the name "${name}"`;
super(msg);
}
}
export class UnexpectedFieldTypeError extends Error {
constructor(name: string, expected: any, actual: any) {
const expectedType = expected?.name;
const actualType = actual?.constructor?.name ?? actual;
const msg =
`Expected field "${name}" to be of type ${expectedType}, ` +
`but it is actually of type ${actualType}`;
super(msg);
}
}
export class MissingOnValueCheckError extends Error {
constructor(onValue: any) {
const msg = `Failed to select check box due to missing onValue: "${onValue}"`;
super(msg);
}
}
export class FieldAlreadyExistsError extends Error {
constructor(name: string) {
const msg = `A field already exists with the specified name: "${name}"`;
super(msg);
}
}
export class InvalidFieldNamePartError extends Error {
constructor(namePart: string) {
const msg = `Field name contains invalid component: "${namePart}"`;
super(msg);
}
}
export class FieldExistsAsNonTerminalError extends Error {
constructor(name: string) {
const msg = `A non-terminal field already exists with the specified name: "${name}"`;
super(msg);
}
}
export class RichTextFieldReadError extends Error {
constructor(fieldName: string) {
const msg = `Reading rich text fields is not supported: Attempted to read rich text field: ${fieldName}`;
super(msg);
}
}
export class CombedTextLayoutError extends Error {
constructor(lineLength: number, cellCount: number) {
const msg = `Failed to layout combed text as lineLength=${lineLength} is greater than cellCount=${cellCount}`;
super(msg);
}
}
export class ExceededMaxLengthError extends Error {
constructor(textLength: number, maxLength: number, name: string) {
const msg = `Attempted to set text with length=${textLength} for TextField with maxLength=${maxLength} and name=${name}`;
super(msg);
}
}
export class InvalidMaxLengthError extends Error {
constructor(textLength: number, maxLength: number, name: string) {
const msg = `Attempted to set maxLength=${maxLength}, which is less than ${textLength}, the length of this field's current value (name=${name})`;
super(msg);
}
}
+267
View File
@@ -0,0 +1,267 @@
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,
defaultButtonAppearanceProvider,
} from 'src/api/form/appearances';
import PDFField, {
FieldAppearanceOptions,
assertFieldAppearanceOptions,
} from 'src/api/form/PDFField';
import { rgb } from 'src/api/colors';
import { degrees } from 'src/api/rotations';
import {
PDFRef,
PDFStream,
PDFAcroPushButton,
PDFWidgetAnnotation,
} from 'src/core';
import { assertIs, assertOrUndefined, assertPositive } from 'src/utils';
/**
* Represents a button field of a [[PDFForm]].
*
* [[PDFButton]] fields are interactive controls that users can click with their
* mouse. This type of [[PDFField]] is not stateful. The purpose of a button
* is to perform an action when the user clicks on it, such as opening a print
* modal or resetting the form. Buttons are typically rectangular in shape and
* have a text label describing the action that they perform when clicked.
*/
export default class PDFButton extends PDFField {
/**
* > **NOTE:** You probably don't want to call this method directly. Instead,
* > consider using the [[PDFForm.getButton]] method, which will create an
* > instance of [[PDFButton]] for you.
*
* Create an instance of [[PDFButton]] from an existing acroPushButton and ref
*
* @param acroPushButton The underlying `PDFAcroPushButton` for this button.
* @param ref The unique reference for this button.
* @param doc The document to which this button will belong.
*/
static of = (
acroPushButton: PDFAcroPushButton,
ref: PDFRef,
doc: PDFDocument,
) => new PDFButton(acroPushButton, ref, doc);
/** The low-level PDFAcroPushButton wrapped by this button. */
readonly acroField: PDFAcroPushButton;
private constructor(
acroPushButton: PDFAcroPushButton,
ref: PDFRef,
doc: PDFDocument,
) {
super(acroPushButton, ref, doc);
assertIs(acroPushButton, 'acroButton', [
[PDFAcroPushButton, 'PDFAcroPushButton'],
]);
this.acroField = acroPushButton;
}
/**
* 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, ImageAlignment.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, alignment = ImageAlignment.Center) {
const widgets = this.acroField.getWidgets();
for (let idx = 0, len = widgets.length; idx < len; idx++) {
const widget = widgets[idx];
const streamRef = this.createImageAppearanceStream(
widget,
image,
alignment,
);
this.updateWidgetAppearances(widget, { normal: streamRef });
}
this.markAsClean();
}
/**
* Set the font size for this field. Larger font sizes will result in larger
* text being displayed when PDF readers render this button. Font sizes may
* be integer or floating point numbers. Supplying a negative font size will
* cause this method to throw an error.
*
* For example:
* ```js
* const button = form.getButton('some.button.field')
* button.setFontSize(4)
* button.setFontSize(15.7)
* ```
*
* > This method depends upon the existence of a default appearance
* > (`/DA`) string. If this field does not have a default appearance string,
* > or that string does not contain a font size (via the `Tf` operator),
* > then this method will throw an error.
*
* @param fontSize The font size to be used when rendering text in this field.
*/
setFontSize(fontSize: number) {
assertPositive(fontSize, 'fontSize');
this.acroField.setFontSize(fontSize);
this.markAsDirty();
}
/**
* Show this button on the specified page with the given text. For example:
* ```js
* const ubuntuFont = await pdfDoc.embedFont(ubuntuFontBytes)
* const page = pdfDoc.addPage()
*
* const form = pdfDoc.getForm()
* const button = form.createButton('some.button.field')
*
* button.addToPage('Do Stuff', page, {
* x: 50,
* y: 75,
* width: 200,
* height: 100,
* textColor: rgb(1, 0, 0),
* backgroundColor: rgb(0, 1, 0),
* borderColor: rgb(0, 0, 1),
* borderWidth: 2,
* rotate: degrees(90),
* font: ubuntuFont,
* })
* ```
* This will create a new widget for this button field.
* @param text The text to be displayed for this button widget.
* @param page The page to which this button widget should be added.
* @param options The options to be used when adding this button widget.
*/
addToPage(
// TODO: This needs to be optional, e.g. for image buttons
text: string,
page: PDFPage,
options?: FieldAppearanceOptions,
) {
assertOrUndefined(text, 'text', ['string']);
assertOrUndefined(page, 'page', [[PDFPage, 'PDFPage']]);
assertFieldAppearanceOptions(options);
// Create a widget for this button
const widget = this.createWidget({
x: (options?.x ?? 0) - (options?.borderWidth ?? 0) / 2,
y: (options?.y ?? 0) - (options?.borderWidth ?? 0) / 2,
width: options?.width ?? 100,
height: options?.height ?? 50,
textColor: options?.textColor ?? rgb(0, 0, 0),
backgroundColor: options?.backgroundColor ?? rgb(0.75, 0.75, 0.75),
borderColor: options?.borderColor,
borderWidth: options?.borderWidth ?? 0,
rotate: options?.rotate ?? degrees(0),
caption: text,
hidden: options?.hidden,
page: page.ref,
});
const widgetRef = this.doc.context.register(widget.dict);
// Add widget to this field
this.acroField.addWidget(widgetRef);
// Set appearance streams for widget
const font = options?.font ?? this.doc.getForm().getDefaultFont();
this.updateWidgetAppearance(widget, font);
// Add widget to the given page
page.node.addAnnot(widgetRef);
}
/**
* Returns `true` if this button has been marked as dirty, or if any of this
* button's widgets do not have an appearance stream. For example:
* ```js
* const button = form.getButton('some.button.field')
* if (button.needsAppearancesUpdate()) console.log('Needs update')
* ```
* @returns Whether or not this button needs an appearance update.
*/
needsAppearancesUpdate(): boolean {
if (this.isDirty()) return true;
const widgets = this.acroField.getWidgets();
for (let idx = 0, len = widgets.length; idx < len; idx++) {
const widget = widgets[idx];
const hasAppearances =
widget.getAppearances()?.normal instanceof PDFStream;
if (!hasAppearances) return true;
}
return false;
}
/**
* Update the appearance streams for each of this button's widgets using
* the default appearance provider for buttons. For example:
* ```js
* const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica)
* const button = form.getButton('some.button.field')
* button.defaultUpdateAppearances(helvetica)
* ```
* @param font The font to be used for creating the appearance streams.
*/
defaultUpdateAppearances(font: PDFFont) {
assertIs(font, 'font', [[PDFFont, 'PDFFont']]);
this.updateAppearances(font);
}
/**
* Update the appearance streams for each of this button's widgets using
* the given appearance provider. If no `provider` is passed, the default
* appearance provider for buttons will be used. For example:
* ```js
* const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica)
* const button = form.getButton('some.button.field')
* button.updateAppearances(helvetica, (field, widget, font) => {
* ...
* return {
* normal: drawButton(...),
* down: drawButton(...),
* }
* })
* ```
* @param font The font to be used for creating the appearance streams.
* @param provider Optionally, the appearance provider to be used for
* generating the contents of the appearance streams.
*/
updateAppearances(
font: PDFFont,
provider?: AppearanceProviderFor<PDFButton>,
) {
assertIs(font, 'font', [[PDFFont, 'PDFFont']]);
assertOrUndefined(provider, 'provider', [Function]);
const widgets = this.acroField.getWidgets();
for (let idx = 0, len = widgets.length; idx < len; idx++) {
const widget = widgets[idx];
this.updateWidgetAppearance(widget, font, provider);
}
}
private updateWidgetAppearance(
widget: PDFWidgetAnnotation,
font: PDFFont,
provider?: AppearanceProviderFor<PDFButton>,
) {
const apProvider = provider ?? defaultButtonAppearanceProvider;
const appearances = normalizeAppearance(apProvider(this, widget, font));
this.updateWidgetAppearanceWithFont(widget, font, appearances);
}
}
+262
View File
@@ -0,0 +1,262 @@
import PDFDocument from 'src/api/PDFDocument';
import PDFPage from 'src/api/PDFPage';
import {
AppearanceProviderFor,
normalizeAppearance,
defaultCheckBoxAppearanceProvider,
} from 'src/api/form/appearances';
import { rgb } from 'src/api/colors';
import { degrees } from 'src/api/rotations';
import PDFField, {
FieldAppearanceOptions,
assertFieldAppearanceOptions,
} from 'src/api/form/PDFField';
import {
PDFName,
PDFRef,
PDFDict,
PDFAcroCheckBox,
PDFWidgetAnnotation,
} from 'src/core';
import { assertIs, assertOrUndefined } from 'src/utils';
/**
* Represents a check box field of a [[PDFForm]].
*
* [[PDFCheckBox]] fields are interactive boxes that users can click with their
* mouse. This type of [[PDFField]] has two states: `on` and `off`. The purpose
* of a check box is to enable users to select from one or more options, where
* each option is represented by a single check box. Check boxes are typically
* square in shape and display a check mark when they are in the `on` state.
*/
export default class PDFCheckBox extends PDFField {
/**
* > **NOTE:** You probably don't want to call this method directly. Instead,
* > consider using the [[PDFForm.getCheckBox]] method, which will create an
* > instance of [[PDFCheckBox]] for you.
*
* Create an instance of [[PDFCheckBox]] from an existing acroCheckBox and ref
*
* @param acroCheckBox The underlying `PDFAcroCheckBox` for this check box.
* @param ref The unique reference for this check box.
* @param doc The document to which this check box will belong.
*/
static of = (acroCheckBox: PDFAcroCheckBox, ref: PDFRef, doc: PDFDocument) =>
new PDFCheckBox(acroCheckBox, ref, doc);
/** The low-level PDFAcroCheckBox wrapped by this check box. */
readonly acroField: PDFAcroCheckBox;
private constructor(
acroCheckBox: PDFAcroCheckBox,
ref: PDFRef,
doc: PDFDocument,
) {
super(acroCheckBox, ref, doc);
assertIs(acroCheckBox, 'acroCheckBox', [
[PDFAcroCheckBox, 'PDFAcroCheckBox'],
]);
this.acroField = acroCheckBox;
}
/**
* Mark this check box. This operation is analogous to a human user clicking
* a check box to fill it in a PDF reader. This method will update the
* underlying state of the check box field to indicate it has been selected.
* PDF libraries and readers will be able to extract this value from the
* saved document and determine that it was selected.
*
* For example:
* ```js
* const checkBox = form.getCheckBox('some.checkBox.field')
* checkBox.check()
* ```
*
* This method will mark this check box as dirty, causing its appearance
* streams to be updated when either [[PDFDocument.save]] or
* [[PDFForm.updateFieldAppearances]] is called. The updated appearance
* streams will display a check mark inside the widgets of this check box
* field.
*/
check() {
const onValue = this.acroField.getOnValue() ?? PDFName.of('Yes');
this.markAsDirty();
this.acroField.setValue(onValue);
}
/**
* Clears this check box. This operation is analogous to a human user clicking
* a check box to unmark it in a PDF reader. This method will update the
* underlying state of the check box field to indicate it has been deselected.
* PDF libraries and readers will be able to extract this value from the
* saved document and determine that it was not selected.
*
* For example:
* ```js
* const checkBox = form.getCheckBox('some.checkBox.field')
* checkBox.uncheck()
* ```
*
* This method will mark this check box as dirty. See [[PDFCheckBox.check]]
* for more details about what this means.
*/
uncheck() {
this.markAsDirty();
this.acroField.setValue(PDFName.of('Off'));
}
/**
* Returns `true` if this check box is selected (either by a human user via
* a PDF reader, or else programmatically via software). For example:
* ```js
* const checkBox = form.getCheckBox('some.checkBox.field')
* if (checkBox.isChecked()) console.log('check box is selected')
* ```
* @returns Whether or not this check box is selected.
*/
isChecked(): boolean {
const onValue = this.acroField.getOnValue();
return !!onValue && onValue === this.acroField.getValue();
}
/**
* Show this check box on the specified page. For example:
* ```js
* const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica)
* const page = pdfDoc.addPage()
*
* const form = pdfDoc.getForm()
* const checkBox = form.createCheckBox('some.checkBox.field')
*
* checkBox.addToPage(page, {
* x: 50,
* y: 75,
* width: 25,
* height: 25,
* textColor: rgb(1, 0, 0),
* backgroundColor: rgb(0, 1, 0),
* borderColor: rgb(0, 0, 1),
* borderWidth: 2,
* rotate: degrees(90),
* })
* ```
* This will create a new widget for this check box field.
* @param page The page to which this check box widget should be added.
* @param options The options to be used when adding this check box widget.
*/
addToPage(page: PDFPage, options?: FieldAppearanceOptions) {
assertIs(page, 'page', [[PDFPage, 'PDFPage']]);
assertFieldAppearanceOptions(options);
if (!options) options = {};
if (!('textColor' in options)) options.textColor = rgb(0, 0, 0);
if (!('backgroundColor' in options)) options.backgroundColor = rgb(1, 1, 1);
if (!('borderColor' in options)) options.borderColor = rgb(0, 0, 0);
if (!('borderWidth' in options)) options.borderWidth = 1;
// Create a widget for this check box
const widget = this.createWidget({
x: options.x ?? 0,
y: options.y ?? 0,
width: options.width ?? 50,
height: options.height ?? 50,
textColor: options.textColor,
backgroundColor: options.backgroundColor,
borderColor: options.borderColor,
borderWidth: options.borderWidth ?? 0,
rotate: options.rotate ?? degrees(0),
hidden: options.hidden,
page: page.ref,
});
const widgetRef = this.doc.context.register(widget.dict);
// Add widget to this field
this.acroField.addWidget(widgetRef);
// Set appearance streams for widget
widget.setAppearanceState(PDFName.of('Off'));
this.updateWidgetAppearance(widget, PDFName.of('Yes'));
// Add widget to the given page
page.node.addAnnot(widgetRef);
}
/**
* Returns `true` if any of this check box's widgets do not have an
* appearance stream for its current state. For example:
* ```js
* const checkBox = form.getCheckBox('some.checkBox.field')
* if (checkBox.needsAppearancesUpdate()) console.log('Needs update')
* ```
* @returns Whether or not this check box needs an appearance update.
*/
needsAppearancesUpdate(): boolean {
const widgets = this.acroField.getWidgets();
for (let idx = 0, len = widgets.length; idx < len; idx++) {
const widget = widgets[idx];
const state = widget.getAppearanceState();
const normal = widget.getAppearances()?.normal;
if (!(normal instanceof PDFDict)) return true;
if (state && !normal.has(state)) return true;
}
return false;
}
/**
* Update the appearance streams for each of this check box's widgets using
* the default appearance provider for check boxes. For example:
* ```js
* const checkBox = form.getCheckBox('some.checkBox.field')
* checkBox.defaultUpdateAppearances()
* ```
*/
defaultUpdateAppearances() {
this.updateAppearances();
}
/**
* Update the appearance streams for each of this check box's widgets using
* the given appearance provider. If no `provider` is passed, the default
* appearance provider for check boxs will be used. For example:
* ```js
* const checkBox = form.getCheckBox('some.checkBox.field')
* checkBox.updateAppearances((field, widget) => {
* ...
* return {
* normal: { on: drawCheckBox(...), off: drawCheckBox(...) },
* down: { on: drawCheckBox(...), off: drawCheckBox(...) },
* }
* })
* ```
* @param provider Optionally, the appearance provider to be used for
* generating the contents of the appearance streams.
*/
updateAppearances(provider?: AppearanceProviderFor<PDFCheckBox>) {
assertOrUndefined(provider, 'provider', [Function]);
const widgets = this.acroField.getWidgets();
for (let idx = 0, len = widgets.length; idx < len; idx++) {
const widget = widgets[idx];
const onValue = widget.getOnValue() ?? PDFName.of('Yes');
if (!onValue) continue;
this.updateWidgetAppearance(widget, onValue, provider);
}
this.markAsClean();
}
private updateWidgetAppearance(
widget: PDFWidgetAnnotation,
onValue: PDFName,
provider?: AppearanceProviderFor<PDFCheckBox>,
) {
const apProvider = provider ?? defaultCheckBoxAppearanceProvider;
const appearances = normalizeAppearance(apProvider(this, widget));
this.updateOnOffWidgetAppearance(widget, onValue, appearances);
}
}
+652
View File
@@ -0,0 +1,652 @@
import PDFDocument from 'src/api/PDFDocument';
import PDFPage from 'src/api/PDFPage';
import PDFFont from 'src/api/PDFFont';
import PDFField, {
FieldAppearanceOptions,
assertFieldAppearanceOptions,
} from 'src/api/form/PDFField';
import {
AppearanceProviderFor,
normalizeAppearance,
defaultDropdownAppearanceProvider,
} from 'src/api/form/appearances';
import { rgb } from 'src/api/colors';
import { degrees } from 'src/api/rotations';
import {
PDFHexString,
PDFRef,
PDFString,
PDFStream,
PDFWidgetAnnotation,
PDFAcroComboBox,
AcroChoiceFlags,
} from 'src/core';
import { assertIs, assertOrUndefined, assertPositive } from 'src/utils';
/**
* Represents a dropdown field of a [[PDFForm]].
*
* [[PDFDropdown]] fields are interactive text boxes that display a single
* element (the currently selected value). The purpose of a dropdown is to
* enable users to select a single option from a set of possible options. Users
* can click on a dropdown to view the full list of options it provides.
* Clicking on an option in the list will cause it to be selected and displayed
* in the dropdown's text box. Some dropdowns allow users to enter text
* directly into the box from their keyboard, rather than only being allowed to
* choose an option from the list (see [[PDFDropdown.isEditable]]).
*/
export default class PDFDropdown extends PDFField {
/**
* > **NOTE:** You probably don't want to call this method directly. Instead,
* > consider using the [[PDFForm.getDropdown]] method, which will create an
* > instance of [[PDFDropdown]] for you.
*
* Create an instance of [[PDFDropdown]] from an existing acroComboBox and ref
*
* @param acroComboBox The underlying `PDFAcroComboBox` for this dropdown.
* @param ref The unique reference for this dropdown.
* @param doc The document to which this dropdown will belong.
*/
static of = (acroComboBox: PDFAcroComboBox, ref: PDFRef, doc: PDFDocument) =>
new PDFDropdown(acroComboBox, ref, doc);
/** The low-level PDFAcroComboBox wrapped by this dropdown. */
readonly acroField: PDFAcroComboBox;
private constructor(
acroComboBox: PDFAcroComboBox,
ref: PDFRef,
doc: PDFDocument,
) {
super(acroComboBox, ref, doc);
assertIs(acroComboBox, 'acroComboBox', [
[PDFAcroComboBox, 'PDFAcroComboBox'],
]);
this.acroField = acroComboBox;
}
/**
* Get the list of available options for this dropdown. These options will be
* displayed to users who click on this dropdown in a PDF reader.
* For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* const options = dropdown.getOptions()
* console.log('Dropdown options:', options)
* ```
* @returns The options for this dropdown.
*/
getOptions(): string[] {
const rawOptions = this.acroField.getOptions();
const options = new Array<string>(rawOptions.length);
for (let idx = 0, len = options.length; idx < len; idx++) {
const { display, value } = rawOptions[idx];
options[idx] = (display ?? value).decodeText();
}
return options;
}
/**
* Get the selected options for this dropdown. These are the values that were
* selected by a human user via a PDF reader, or programatically via
* software.
* For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* const selections = dropdown.getSelected()
* console.log('Dropdown selections:', selections)
* ```
* > **NOTE:** Note that PDF readers only display one selected option when
* > rendering dropdowns. However, the PDF specification does allow for
* > multiple values to be selected in a dropdown. As such, the `pdf-lib`
* > API supports this. However, in most cases the array returned by this
* > method will contain only a single element (or no elements).
* @returns The selected options in this dropdown.
*/
getSelected(): string[] {
const values = this.acroField.getValues();
const selected = new Array<string>(values.length);
for (let idx = 0, len = values.length; idx < len; idx++) {
selected[idx] = values[idx].decodeText();
}
return selected;
}
/**
* Set the list of options that are available for this dropdown. These are
* the values that will be available for users to select when they view this
* dropdown in a PDF reader. Note that preexisting options for this dropdown
* will be removed. Only the values passed as `options` will be available to
* select.
* For example:
* ```js
* const dropdown = form.getDropdown('planets.dropdown')
* dropdown.setOptions(['Earth', 'Mars', 'Pluto', 'Venus'])
* ```
* @param options The options that should be available in this dropdown.
*/
setOptions(options: string[]) {
assertIs(options, 'options', [Array]);
const optionObjects = new Array<{ value: PDFHexString }>(options.length);
for (let idx = 0, len = options.length; idx < len; idx++) {
optionObjects[idx] = { value: PDFHexString.fromText(options[idx]) };
}
this.acroField.setOptions(optionObjects);
}
/**
* Add to the list of options that are available for this dropdown. Users
* will be able to select these values in a PDF reader. In addition to the
* values passed as `options`, any preexisting options for this dropdown will
* still be available for users to select.
* For example:
* ```js
* const dropdown = form.getDropdown('rockets.dropdown')
* dropdown.addOptions(['Saturn IV', 'Falcon Heavy'])
* ```
* @param options New options that should be available in this dropdown.
*/
addOptions(options: string | string[]) {
assertIs(options, 'options', ['string', Array]);
const optionsArr = Array.isArray(options) ? options : [options];
const existingOptions: {
value: PDFString | PDFHexString;
display?: PDFString | PDFHexString;
}[] = this.acroField.getOptions();
const newOptions = new Array<{ value: PDFHexString }>(optionsArr.length);
for (let idx = 0, len = optionsArr.length; idx < len; idx++) {
newOptions[idx] = { value: PDFHexString.fromText(optionsArr[idx]) };
}
this.acroField.setOptions(existingOptions.concat(newOptions));
}
/**
* Select one or more values for this dropdown. This operation is analogous
* to a human user opening the dropdown in a PDF reader and clicking on a
* value to select it. This method will update the underlying state of the
* dropdown to indicate which values have been selected. PDF libraries and
* readers will be able to extract these values from the saved document and
* determine which values were selected.
*
* For example:
* ```js
* const dropdown = form.getDropdown('best.superhero.dropdown')
* dropdown.select('One Punch Man')
* ```
*
* This method will mark this dropdown as dirty, causing its appearance
* streams to be updated when either [[PDFDocument.save]] or
* [[PDFForm.updateFieldAppearances]] is called. The updated streams will
* display the selected option inside the widgets of this dropdown.
*
* **IMPORTANT:** The default font used to update appearance streams is
* [[StandardFonts.Helvetica]]. Note that this is a WinAnsi font. This means
* that encoding errors will be thrown if the selected option for this field
* contains characters outside the WinAnsi character set (the latin alphabet).
*
* Embedding a custom font and passing it to
* [[PDFForm.updateFieldAppearances]] or [[PDFDropdown.updateAppearances]]
* allows you to generate appearance streams with characters outside the
* latin alphabet (assuming the custom font supports them).
*
* Selecting an option that does not exist in this dropdown's option list
* (see [[PDFDropdown.getOptions]]) will enable editing on this dropdown
* (see [[PDFDropdown.enableEditing]]).
*
* > **NOTE:** PDF readers only display one selected option when rendering
* > dropdowns. However, the PDF specification does allow for multiple values
* > to be selected in a dropdown. As such, the `pdf-lib` API supports this.
* > However, it is not recommended to select more than one value with this
* > method, as only one will be visible. [[PDFOptionList]] fields are better
* > suited for displaying multiple selected values.
*
* @param options The options to be selected.
* @param merge Whether or not existing selections should be preserved.
*/
select(options: string | string[], merge = false) {
assertIs(options, 'options', ['string', Array]);
assertIs(merge, 'merge', ['boolean']);
const optionsArr = Array.isArray(options) ? options : [options];
const validOptions = this.getOptions();
const hasCustomOption = optionsArr.find(
(option) => !validOptions.includes(option),
);
if (hasCustomOption) this.enableEditing();
this.markAsDirty();
if (optionsArr.length > 1 || (optionsArr.length === 1 && merge)) {
this.enableMultiselect();
}
const values = new Array<PDFHexString>(optionsArr.length);
for (let idx = 0, len = optionsArr.length; idx < len; idx++) {
values[idx] = PDFHexString.fromText(optionsArr[idx]);
}
if (merge) {
const existingValues = this.acroField.getValues();
this.acroField.setValues(existingValues.concat(values));
} else {
this.acroField.setValues(values);
}
}
/**
* Clear all selected values for this dropdown. This operation is equivalent
* to selecting an empty list. This method will update the underlying state
* of the dropdown to indicate that no values have been selected.
* For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* dropdown.clear()
* ```
* This method will mark this text field as dirty. See [[PDFDropdown.select]]
* for more details about what this means.
*/
clear() {
this.markAsDirty();
this.acroField.setValues([]);
}
/**
* Set the font size for this field. Larger font sizes will result in larger
* text being displayed when PDF readers render this dropdown. Font sizes may
* be integer or floating point numbers. Supplying a negative font size will
* cause this method to throw an error.
*
* For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* dropdown.setFontSize(4)
* dropdown.setFontSize(15.7)
* ```
*
* > This method depends upon the existence of a default appearance
* > (`/DA`) string. If this field does not have a default appearance string,
* > or that string does not contain a font size (via the `Tf` operator),
* > then this method will throw an error.
*
* @param fontSize The font size to be used when rendering text in this field.
*/
setFontSize(fontSize: number) {
assertPositive(fontSize, 'fontSize');
this.acroField.setFontSize(fontSize);
this.markAsDirty();
}
/**
* Returns `true` if users are allowed to edit the selected value of this
* dropdown directly and are not constrained by the list of available
* options. See [[PDFDropdown.enableEditing]] and
* [[PDFDropdown.disableEditing]]. For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* if (dropdown.isEditable()) console.log('Editing is enabled')
* ```
* @returns Whether or not this dropdown is editable.
*/
isEditable(): boolean {
return this.acroField.hasFlag(AcroChoiceFlags.Edit);
}
/**
* Allow users to edit the selected value of this dropdown in PDF readers
* with their keyboard. This means that the selected value of this dropdown
* will not be constrained by the list of available options. However, if this
* dropdown has any available options, users will still be allowed to select
* from that list.
* For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* dropdown.enableEditing()
* ```
*/
enableEditing() {
this.acroField.setFlagTo(AcroChoiceFlags.Edit, true);
}
/**
* Do not allow users to edit the selected value of this dropdown in PDF
* readers with their keyboard. This will constrain the selected value of
* this dropdown to the list of available options. Users will only be able
* to select an option from that list.
* For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* dropdown.disableEditing()
* ```
*/
disableEditing() {
this.acroField.setFlagTo(AcroChoiceFlags.Edit, false);
}
/**
* Returns `true` if the option list of this dropdown is always displayed
* in alphabetical order, irrespective of the order in which the options
* were added to the dropdown. See [[PDFDropdown.enableSorting]] and
* [[PDFDropdown.disableSorting]]. For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* if (dropdown.isSorted()) console.log('Sorting is enabled')
* ```
* @returns Whether or not this dropdown's options are sorted.
*/
isSorted(): boolean {
return this.acroField.hasFlag(AcroChoiceFlags.Sort);
}
/**
* Always display the option list of this dropdown in alphabetical order,
* irrespective of the order in which the options were added to this dropdown.
* For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* dropdown.enableSorting()
* ```
*/
enableSorting() {
this.acroField.setFlagTo(AcroChoiceFlags.Sort, true);
}
/**
* Do not always display the option list of this dropdown in alphabetical
* order. Instead, display the options in whichever order they were added
* to the list. For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* dropdown.disableSorting()
* ```
*/
disableSorting() {
this.acroField.setFlagTo(AcroChoiceFlags.Sort, false);
}
/**
* Returns `true` if multiple options can be selected from this dropdown's
* option list. See [[PDFDropdown.enableMultiselect]] and
* [[PDFDropdown.disableMultiselect]]. For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* if (dropdown.isMultiselect()) console.log('Multiselect is enabled')
* ```
* @returns Whether or not multiple options can be selected.
*/
isMultiselect(): boolean {
return this.acroField.hasFlag(AcroChoiceFlags.MultiSelect);
}
/**
* Allow users to select more than one option from this dropdown's option
* list. For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* dropdown.enableMultiselect()
* ```
*/
enableMultiselect() {
this.acroField.setFlagTo(AcroChoiceFlags.MultiSelect, true);
}
/**
* Do not allow users to select more than one option from this dropdown's
* option list. For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* dropdown.disableMultiselect()
* ```
*/
disableMultiselect() {
this.acroField.setFlagTo(AcroChoiceFlags.MultiSelect, false);
}
/**
* Returns `true` if the selected option should be spell checked by PDF
* readers. Spell checking will only be performed if this dropdown allows
* editing (see [[PDFDropdown.isEditable]]). See
* [[PDFDropdown.enableSpellChecking]] and
* [[PDFDropdown.disableSpellChecking]]. For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* if (dropdown.isSpellChecked()) console.log('Spell checking is enabled')
* ```
* @returns Whether or not this dropdown can be spell checked.
*/
isSpellChecked(): boolean {
return !this.acroField.hasFlag(AcroChoiceFlags.DoNotSpellCheck);
}
/**
* Allow PDF readers to spell check the selected option of this dropdown.
* For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* dropdown.enableSpellChecking()
* ```
*/
enableSpellChecking() {
this.acroField.setFlagTo(AcroChoiceFlags.DoNotSpellCheck, false);
}
/**
* Do not allow PDF readers to spell check the selected option of this
* dropdown. For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* dropdown.disableSpellChecking()
* ```
*/
disableSpellChecking() {
this.acroField.setFlagTo(AcroChoiceFlags.DoNotSpellCheck, true);
}
/**
* Returns `true` if the option selected by a user is stored, or "committed",
* when the user clicks the option. The alternative is that the user's
* selection is stored when the user leaves this dropdown field (by clicking
* outside of it - on another field, for example). See
* [[PDFDropdown.enableSelectOnClick]] and
* [[PDFDropdown.disableSelectOnClick]]. For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* if (dropdown.isSelectOnClick()) console.log('Select on click is enabled')
* ```
* @returns Whether or not options are selected immediately after they are
* clicked.
*/
isSelectOnClick(): boolean {
return this.acroField.hasFlag(AcroChoiceFlags.CommitOnSelChange);
}
/**
* Store the option selected by a user immediately after the user clicks the
* option. Do not wait for the user to leave this dropdown field (by clicking
* outside of it - on another field, for example). For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* dropdown.enableSelectOnClick()
* ```
*/
enableSelectOnClick() {
this.acroField.setFlagTo(AcroChoiceFlags.CommitOnSelChange, true);
}
/**
* Wait to store the option selected by a user until they leave this dropdown
* field (by clicking outside of it - on another field, for example).
* For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* dropdown.disableSelectOnClick()
* ```
*/
disableSelectOnClick() {
this.acroField.setFlagTo(AcroChoiceFlags.CommitOnSelChange, false);
}
/**
* Show this dropdown on the specified page. For example:
* ```js
* const ubuntuFont = await pdfDoc.embedFont(ubuntuFontBytes)
* const page = pdfDoc.addPage()
*
* const form = pdfDoc.getForm()
* const dropdown = form.createDropdown('best.gundam')
* dropdown.setOptions(['Exia', 'Dynames'])
* dropdown.select('Exia')
*
* dropdown.addToPage(page, {
* x: 50,
* y: 75,
* width: 200,
* height: 100,
* textColor: rgb(1, 0, 0),
* backgroundColor: rgb(0, 1, 0),
* borderColor: rgb(0, 0, 1),
* borderWidth: 2,
* rotate: degrees(90),
* font: ubuntuFont,
* })
* ```
* This will create a new widget for this dropdown field.
* @param page The page to which this dropdown widget should be added.
* @param options The options to be used when adding this dropdown widget.
*/
addToPage(page: PDFPage, options?: FieldAppearanceOptions) {
assertIs(page, 'page', [[PDFPage, 'PDFPage']]);
assertFieldAppearanceOptions(options);
if (!options) options = {};
if (!('textColor' in options)) options.textColor = rgb(0, 0, 0);
if (!('backgroundColor' in options)) options.backgroundColor = rgb(1, 1, 1);
if (!('borderColor' in options)) options.borderColor = rgb(0, 0, 0);
if (!('borderWidth' in options)) options.borderWidth = 1;
// Create a widget for this dropdown
const widget = this.createWidget({
x: options.x ?? 0,
y: options.y ?? 0,
width: options.width ?? 200,
height: options.height ?? 50,
textColor: options.textColor,
backgroundColor: options.backgroundColor,
borderColor: options.borderColor,
borderWidth: options.borderWidth ?? 0,
rotate: options.rotate ?? degrees(0),
hidden: options.hidden,
page: page.ref,
});
const widgetRef = this.doc.context.register(widget.dict);
// Add widget to this field
this.acroField.addWidget(widgetRef);
// Set appearance streams for widget
const font = options.font ?? this.doc.getForm().getDefaultFont();
this.updateWidgetAppearance(widget, font);
// Add widget to the given page
page.node.addAnnot(widgetRef);
}
/**
* Returns `true` if this dropdown has been marked as dirty, or if any of
* this dropdown's widgets do not have an appearance stream. For example:
* ```js
* const dropdown = form.getDropdown('some.dropdown.field')
* if (dropdown.needsAppearancesUpdate()) console.log('Needs update')
* ```
* @returns Whether or not this dropdown needs an appearance update.
*/
needsAppearancesUpdate(): boolean {
if (this.isDirty()) return true;
const widgets = this.acroField.getWidgets();
for (let idx = 0, len = widgets.length; idx < len; idx++) {
const widget = widgets[idx];
const hasAppearances =
widget.getAppearances()?.normal instanceof PDFStream;
if (!hasAppearances) return true;
}
return false;
}
/**
* Update the appearance streams for each of this dropdown's widgets using
* the default appearance provider for dropdowns. For example:
* ```js
* const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica)
* const dropdown = form.getDropdown('some.dropdown.field')
* dropdown.defaultUpdateAppearances(helvetica)
* ```
* @param font The font to be used for creating the appearance streams.
*/
defaultUpdateAppearances(font: PDFFont) {
assertIs(font, 'font', [[PDFFont, 'PDFFont']]);
this.updateAppearances(font);
}
/**
* Update the appearance streams for each of this dropdown's widgets using
* the given appearance provider. If no `provider` is passed, the default
* appearance provider for dropdowns will be used. For example:
* ```js
* const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica)
* const dropdown = form.getDropdown('some.dropdown.field')
* dropdown.updateAppearances(helvetica, (field, widget, font) => {
* ...
* return drawTextField(...)
* })
* ```
* @param font The font to be used for creating the appearance streams.
* @param provider Optionally, the appearance provider to be used for
* generating the contents of the appearance streams.
*/
updateAppearances(
font: PDFFont,
provider?: AppearanceProviderFor<PDFDropdown>,
) {
assertIs(font, 'font', [[PDFFont, 'PDFFont']]);
assertOrUndefined(provider, 'provider', [Function]);
const widgets = this.acroField.getWidgets();
for (let idx = 0, len = widgets.length; idx < len; idx++) {
const widget = widgets[idx];
this.updateWidgetAppearance(widget, font, provider);
}
this.markAsClean();
}
// getOption(index: number): string {}
// getSelectedIndices(): number[] {}
// removeOptions(option: string | string[]) {}
// removeIndices(option: number[]) {}
// deselect(options: string | string[]) {}
// deselectIndices(optionIndices: number[]) {}
private updateWidgetAppearance(
widget: PDFWidgetAnnotation,
font: PDFFont,
provider?: AppearanceProviderFor<PDFDropdown>,
) {
const apProvider = provider ?? defaultDropdownAppearanceProvider;
const appearances = normalizeAppearance(apProvider(this, widget, font));
this.updateWidgetAppearanceWithFont(widget, font, appearances);
}
}
+521
View File
@@ -0,0 +1,521 @@
import PDFDocument from 'src/api/PDFDocument';
import PDFFont from 'src/api/PDFFont';
import { AppearanceMapping } from 'src/api/form/appearances';
import { Color, colorToComponents, setFillingColor } from 'src/api/colors';
import {
Rotation,
toDegrees,
rotateRectangle,
reduceRotation,
adjustDimsForRotation,
degrees,
} from 'src/api/rotations';
import {
PDFRef,
PDFWidgetAnnotation,
PDFOperator,
PDFName,
PDFDict,
MethodNotImplementedError,
AcroFieldFlags,
PDFAcroTerminal,
AnnotationFlags,
} from 'src/core';
import { assertIs, assertMultiple, assertOrUndefined } from 'src/utils';
import { ImageAlignment } from '../image';
import PDFImage from '../PDFImage';
import { drawImage, rotateInPlace } from '../operations';
export interface FieldAppearanceOptions {
x?: number;
y?: number;
width?: number;
height?: number;
textColor?: Color;
backgroundColor?: Color;
borderColor?: Color;
borderWidth?: number;
rotate?: Rotation;
font?: PDFFont;
hidden?: boolean;
}
export const assertFieldAppearanceOptions = (
options?: FieldAppearanceOptions,
) => {
assertOrUndefined(options?.x, 'options.x', ['number']);
assertOrUndefined(options?.y, 'options.y', ['number']);
assertOrUndefined(options?.width, 'options.width', ['number']);
assertOrUndefined(options?.height, 'options.height', ['number']);
assertOrUndefined(options?.textColor, 'options.textColor', [
[Object, 'Color'],
]);
assertOrUndefined(options?.backgroundColor, 'options.backgroundColor', [
[Object, 'Color'],
]);
assertOrUndefined(options?.borderColor, 'options.borderColor', [
[Object, 'Color'],
]);
assertOrUndefined(options?.borderWidth, 'options.borderWidth', ['number']);
assertOrUndefined(options?.rotate, 'options.rotate', [[Object, 'Rotation']]);
};
/**
* Represents a field of a [[PDFForm]].
*
* This class is effectively abstract. All fields in a [[PDFForm]] will
* actually be an instance of a subclass of this class.
*
* Note that each field in a PDF is represented by a single field object.
* However, a given field object may be rendered at multiple locations within
* the document (across one or more pages). The rendering of a field is
* controlled by its widgets. Each widget causes its field to be displayed at a
* particular location in the document.
*
* Most of the time each field in a PDF has only a single widget, and thus is
* only rendered once. However, if a field is rendered multiple times, it will
* have multiple widgets - one for each location it is rendered.
*
* This abstraction of field objects and widgets is defined in the PDF
* specification and dictates how PDF files store fields and where they are
* to be rendered.
*/
export default class PDFField {
/** The low-level PDFAcroTerminal wrapped by this field. */
readonly acroField: PDFAcroTerminal;
/** The unique reference assigned to this field within the document. */
readonly ref: PDFRef;
/** The document to which this field belongs. */
readonly doc: PDFDocument;
protected constructor(
acroField: PDFAcroTerminal,
ref: PDFRef,
doc: PDFDocument,
) {
assertIs(acroField, 'acroField', [[PDFAcroTerminal, 'PDFAcroTerminal']]);
assertIs(ref, 'ref', [[PDFRef, 'PDFRef']]);
assertIs(doc, 'doc', [[PDFDocument, 'PDFDocument']]);
this.acroField = acroField;
this.ref = ref;
this.doc = doc;
}
/**
* Get the fully qualified name of this field. For example:
* ```js
* const fields = form.getFields()
* fields.forEach(field => {
* const name = field.getName()
* console.log('Field name:', name)
* })
* ```
* Note that PDF fields are structured as a tree. Each field is the
* descendent of a series of ancestor nodes all the way up to the form node,
* which is always the root of the tree. Each node in the tree (except for
* the form node) has a partial name. Partial names can be composed of any
* unicode characters except a period (`.`). The fully qualified name of a
* field is composed of the partial names of all its ancestors joined
* with periods. This means that splitting the fully qualified name on
* periods and taking the last element of the resulting array will give you
* the partial name of a specific field.
* @returns The fully qualified name of this field.
*/
getName(): string {
return this.acroField.getFullyQualifiedName() ?? '';
}
/**
* Returns `true` if this field is read only. This means that PDF readers
* will not allow users to interact with the field or change its value. See
* [[PDFField.enableReadOnly]] and [[PDFField.disableReadOnly]].
* For example:
* ```js
* const field = form.getField('some.field')
* if (field.isReadOnly()) console.log('Read only is enabled')
* ```
* @returns Whether or not this is a read only field.
*/
isReadOnly(): boolean {
return this.acroField.hasFlag(AcroFieldFlags.ReadOnly);
}
/**
* Prevent PDF readers from allowing users to interact with this field or
* change its value. The field will not respond to mouse or keyboard input.
* For example:
* ```js
* const field = form.getField('some.field')
* field.enableReadOnly()
* ```
* Useful for fields whose values are computed, imported from a database, or
* prefilled by software before being displayed to the user.
*/
enableReadOnly() {
this.acroField.setFlagTo(AcroFieldFlags.ReadOnly, true);
}
/**
* Allow users to interact with this field and change its value in PDF
* readers via mouse and keyboard input. For example:
* ```js
* const field = form.getField('some.field')
* field.disableReadOnly()
* ```
*/
disableReadOnly() {
this.acroField.setFlagTo(AcroFieldFlags.ReadOnly, false);
}
/**
* Returns `true` if this field must have a value when the form is submitted.
* See [[PDFField.enableRequired]] and [[PDFField.disableRequired]].
* For example:
* ```js
* const field = form.getField('some.field')
* if (field.isRequired()) console.log('Field is required')
* ```
* @returns Whether or not this field is required.
*/
isRequired(): boolean {
return this.acroField.hasFlag(AcroFieldFlags.Required);
}
/**
* Require this field to have a value when the form is submitted.
* For example:
* ```js
* const field = form.getField('some.field')
* field.enableRequired()
* ```
*/
enableRequired() {
this.acroField.setFlagTo(AcroFieldFlags.Required, true);
}
/**
* Do not require this field to have a value when the form is submitted.
* For example:
* ```js
* const field = form.getField('some.field')
* field.disableRequired()
* ```
*/
disableRequired() {
this.acroField.setFlagTo(AcroFieldFlags.Required, false);
}
/**
* Returns `true` if this field's value should be exported when the form is
* submitted. See [[PDFField.enableExporting]] and
* [[PDFField.disableExporting]].
* For example:
* ```js
* const field = form.getField('some.field')
* if (field.isExported()) console.log('Exporting is enabled')
* ```
* @returns Whether or not this field's value should be exported.
*/
isExported(): boolean {
return !this.acroField.hasFlag(AcroFieldFlags.NoExport);
}
/**
* Indicate that this field's value should be exported when the form is
* submitted in a PDF reader. For example:
* ```js
* const field = form.getField('some.field')
* field.enableExporting()
* ```
*/
enableExporting() {
this.acroField.setFlagTo(AcroFieldFlags.NoExport, false);
}
/**
* Indicate that this field's value should **not** be exported when the form
* is submitted in a PDF reader. For example:
* ```js
* const field = form.getField('some.field')
* field.disableExporting()
* ```
*/
disableExporting() {
this.acroField.setFlagTo(AcroFieldFlags.NoExport, true);
}
/** @ignore */
needsAppearancesUpdate(): boolean {
throw new MethodNotImplementedError(
this.constructor.name,
'needsAppearancesUpdate',
);
}
/** @ignore */
defaultUpdateAppearances(_font: PDFFont) {
throw new MethodNotImplementedError(
this.constructor.name,
'defaultUpdateAppearances',
);
}
protected markAsDirty() {
this.doc.getForm().markFieldAsDirty(this.ref);
}
protected markAsClean() {
this.doc.getForm().markFieldAsClean(this.ref);
}
protected isDirty(): boolean {
return this.doc.getForm().fieldIsDirty(this.ref);
}
protected createWidget(options: {
x: number;
y: number;
width: number;
height: number;
textColor?: Color;
backgroundColor?: Color;
borderColor?: Color;
borderWidth: number;
rotate: Rotation;
caption?: string;
hidden?: boolean;
page?: PDFRef;
}): PDFWidgetAnnotation {
const textColor = options.textColor;
const backgroundColor = options.backgroundColor;
const borderColor = options.borderColor;
const borderWidth = options.borderWidth;
const degreesAngle = toDegrees(options.rotate);
const caption = options.caption;
const x = options.x;
const y = options.y;
const width = options.width + borderWidth;
const height = options.height + borderWidth;
const hidden = Boolean(options.hidden);
const pageRef = options.page;
assertMultiple(degreesAngle, 'degreesAngle', 90);
// Create a widget for this field
const widget = PDFWidgetAnnotation.create(this.doc.context, this.ref);
// Set widget properties
const rect = rotateRectangle(
{ x, y, width, height },
borderWidth,
degreesAngle,
);
widget.setRectangle(rect);
if (pageRef) widget.setP(pageRef);
const ac = widget.getOrCreateAppearanceCharacteristics();
if (backgroundColor) {
ac.setBackgroundColor(colorToComponents(backgroundColor));
}
ac.setRotation(degreesAngle);
if (caption) ac.setCaptions({ normal: caption });
if (borderColor) ac.setBorderColor(colorToComponents(borderColor));
const bs = widget.getOrCreateBorderStyle();
if (borderWidth !== undefined) bs.setWidth(borderWidth);
widget.setFlagTo(AnnotationFlags.Print, true);
widget.setFlagTo(AnnotationFlags.Hidden, hidden);
widget.setFlagTo(AnnotationFlags.Invisible, false);
// Set acrofield properties
if (textColor) {
const da = this.acroField.getDefaultAppearance() ?? '';
const newDa = da + '\n' + setFillingColor(textColor).toString();
this.acroField.setDefaultAppearance(newDa);
}
return widget;
}
protected updateWidgetAppearanceWithFont(
widget: PDFWidgetAnnotation,
font: PDFFont,
{ normal, rollover, down }: AppearanceMapping<PDFOperator[]>,
) {
this.updateWidgetAppearances(widget, {
normal: this.createAppearanceStream(widget, normal, font),
rollover: rollover && this.createAppearanceStream(widget, rollover, font),
down: down && this.createAppearanceStream(widget, down, font),
});
}
protected updateOnOffWidgetAppearance(
widget: PDFWidgetAnnotation,
onValue: PDFName,
{
normal,
rollover,
down,
}: AppearanceMapping<{ on: PDFOperator[]; off: PDFOperator[] }>,
) {
this.updateWidgetAppearances(widget, {
normal: this.createAppearanceDict(widget, normal, onValue),
rollover:
rollover && this.createAppearanceDict(widget, rollover, onValue),
down: down && this.createAppearanceDict(widget, down, onValue),
});
}
protected updateWidgetAppearances(
widget: PDFWidgetAnnotation,
{ normal, rollover, down }: AppearanceMapping<PDFRef | PDFDict>,
) {
widget.setNormalAppearance(normal);
if (rollover) {
widget.setRolloverAppearance(rollover);
} else {
widget.removeRolloverAppearance();
}
if (down) {
widget.setDownAppearance(down);
} else {
widget.removeDownAppearance();
}
}
// // TODO: Do we need to do this...?
// private foo(font: PDFFont, dict: PDFDict) {
// if (!dict.lookup(PDFName.of('DR'))) {
// dict.set(PDFName.of('DR'), dict.context.obj({}));
// }
// const DR = dict.lookup(PDFName.of('DR'), PDFDict);
// if (!DR.lookup(PDFName.of('Font'))) {
// DR.set(PDFName.of('Font'), dict.context.obj({}));
// }
// const Font = DR.lookup(PDFName.of('Font'), PDFDict);
// Font.set(PDFName.of(font.name), font.ref);
// }
private createAppearanceStream(
widget: PDFWidgetAnnotation,
appearance: PDFOperator[],
font?: PDFFont,
): PDFRef {
const { context } = this.acroField.dict;
const { width, height } = widget.getRectangle();
// TODO: Do we need to do this...?
// if (font) {
// this.foo(font, widget.dict);
// this.foo(font, this.doc.getForm().acroForm.dict);
// }
// END TODO
const Resources = font && { Font: { [font.name]: font.ref } };
const stream = context.formXObject(appearance, {
Resources,
BBox: context.obj([0, 0, width, height]),
Matrix: context.obj([1, 0, 0, 1, 0, 0]),
});
const streamRef = context.register(stream);
return streamRef;
}
/**
* Create a FormXObject of the supplied image and add it to context.
* The FormXObject size is calculated based on the widget (including
* the alignment).
* @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 ref for the FormXObject that was added to the context.
*/
protected createImageAppearanceStream(
widget: PDFWidgetAnnotation,
image: PDFImage,
alignment: ImageAlignment,
): PDFRef {
// NOTE: This implementation 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?
const { context } = this.acroField.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 = this.doc.context.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);
}
private createAppearanceDict(
widget: PDFWidgetAnnotation,
appearance: { on: PDFOperator[]; off: PDFOperator[] },
onValue: PDFName,
): PDFDict {
const { context } = this.acroField.dict;
const onStreamRef = this.createAppearanceStream(widget, appearance.on);
const offStreamRef = this.createAppearanceStream(widget, appearance.off);
const appearanceDict = context.obj({});
appearanceDict.set(onValue, onStreamRef);
appearanceDict.set(PDFName.of('Off'), offStreamRef);
return appearanceDict;
}
}
+852
View File
@@ -0,0 +1,852 @@
import PDFDocument from 'src/api/PDFDocument';
import PDFPage from 'src/api/PDFPage';
import PDFField from 'src/api/form/PDFField';
import PDFButton from 'src/api/form/PDFButton';
import PDFCheckBox from 'src/api/form/PDFCheckBox';
import PDFDropdown from 'src/api/form/PDFDropdown';
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 {
NoSuchFieldError,
UnexpectedFieldTypeError,
FieldAlreadyExistsError,
InvalidFieldNamePartError,
} from 'src/api/errors';
import PDFFont from 'src/api/PDFFont';
import { StandardFonts } from 'src/api/StandardFonts';
import { rotateInPlace } from 'src/api/operations';
import {
drawObject,
popGraphicsState,
pushGraphicsState,
translate,
} from 'src/api/operators';
import {
PDFAcroForm,
PDFAcroField,
PDFAcroCheckBox,
PDFAcroComboBox,
PDFAcroListBox,
PDFAcroRadioButton,
PDFAcroSignature,
PDFAcroText,
PDFAcroPushButton,
PDFAcroNonTerminal,
PDFDict,
PDFOperator,
PDFRef,
createPDFAcroFields,
PDFName,
PDFWidgetAnnotation,
} from 'src/core';
import { assertIs, Cache, assertOrUndefined } from 'src/utils';
export interface FlattenOptions {
updateFieldAppearances: boolean;
}
/**
* Represents the interactive form of a [[PDFDocument]].
*
* Interactive forms (sometimes called _AcroForms_) are collections of fields
* designed to gather information from a user. A PDF document may contains any
* number of fields that appear on various pages, all of which make up a single,
* global interactive form spanning the entire document. This means that
* instances of [[PDFDocument]] shall contain at most one [[PDFForm]].
*
* The fields of an interactive form are represented by [[PDFField]] instances.
*/
export default class PDFForm {
/**
* > **NOTE:** You probably don't want to call this method directly. Instead,
* > consider using the [[PDFDocument.getForm]] method, which will create an
* > instance of [[PDFForm]] for you.
*
* Create an instance of [[PDFForm]] from an existing acroForm and embedder
*
* @param acroForm The underlying `PDFAcroForm` for this form.
* @param doc The document to which the form will belong.
*/
static of = (acroForm: PDFAcroForm, doc: PDFDocument) =>
new PDFForm(acroForm, doc);
/** The low-level PDFAcroForm wrapped by this form. */
readonly acroForm: PDFAcroForm;
/** The document to which this form belongs. */
readonly doc: PDFDocument;
private readonly dirtyFields: Set<PDFRef>;
private readonly defaultFontCache: Cache<PDFFont>;
private constructor(acroForm: PDFAcroForm, doc: PDFDocument) {
assertIs(acroForm, 'acroForm', [[PDFAcroForm, 'PDFAcroForm']]);
assertIs(doc, 'doc', [[PDFDocument, 'PDFDocument']]);
this.acroForm = acroForm;
this.doc = doc;
this.dirtyFields = new Set();
this.defaultFontCache = Cache.populatedBy(this.embedDefaultFont);
}
/**
* Returns `true` if this [[PDFForm]] has XFA data. Most PDFs with form
* fields do not use XFA as it is not widely supported by PDF readers.
*
* > `pdf-lib` does not support creation, modification, or reading of XFA
* > fields.
*
* For example:
* ```js
* const form = pdfDoc.getForm()
* if (form.hasXFA()) console.log('PDF has XFA data')
* ```
* @returns Whether or not this form has XFA data.
*/
hasXFA(): boolean {
return this.acroForm.dict.has(PDFName.of('XFA'));
}
/**
* Disconnect the XFA data from this [[PDFForm]] (if any exists). This will
* force readers to fallback to standard fields if the [[PDFDocument]]
* contains any. For example:
*
* For example:
* ```js
* const form = pdfDoc.getForm()
* form.deleteXFA()
* ```
*/
deleteXFA(): void {
this.acroForm.dict.delete(PDFName.of('XFA'));
}
/**
* Get all fields contained in this [[PDFForm]]. For example:
* ```js
* const form = pdfDoc.getForm()
* const fields = form.getFields()
* fields.forEach(field => {
* const type = field.constructor.name
* const name = field.getName()
* console.log(`${type}: ${name}`)
* })
* ```
* @returns An array of all fields in this form.
*/
getFields(): PDFField[] {
const allFields = this.acroForm.getAllFields();
const fields: PDFField[] = [];
for (let idx = 0, len = allFields.length; idx < len; idx++) {
const [acroField, ref] = allFields[idx];
const field = convertToPDFField(acroField, ref, this.doc);
if (field) fields.push(field);
}
return fields;
}
/**
* Get the field in this [[PDFForm]] with the given name. For example:
* ```js
* const form = pdfDoc.getForm()
* const field = form.getFieldMaybe('Page1.Foo.Bar[0]')
* if (field) console.log('Field exists!')
* ```
* @param name A fully qualified field name.
* @returns The field with the specified name, if one exists.
*/
getFieldMaybe(name: string): PDFField | undefined {
assertIs(name, 'name', ['string']);
const fields = this.getFields();
for (let idx = 0, len = fields.length; idx < len; idx++) {
const field = fields[idx];
if (field.getName() === name) return field;
}
return undefined;
}
/**
* Get the field in this [[PDFForm]] with the given name. For example:
* ```js
* const form = pdfDoc.getForm()
* const field = form.getField('Page1.Foo.Bar[0]')
* ```
* If no field exists with the provided name, an error will be thrown.
* @param name A fully qualified field name.
* @returns The field with the specified name.
*/
getField(name: string): PDFField {
assertIs(name, 'name', ['string']);
const field = this.getFieldMaybe(name);
if (field) return field;
throw new NoSuchFieldError(name);
}
/**
* Get the button field in this [[PDFForm]] with the given name. For example:
* ```js
* const form = pdfDoc.getForm()
* const button = form.getButton('Page1.Foo.Button[0]')
* ```
* An error will be thrown if no field exists with the provided name, or if
* the field exists but is not a button.
* @param name A fully qualified button name.
* @returns The button with the specified name.
*/
getButton(name: string): PDFButton {
assertIs(name, 'name', ['string']);
const field = this.getField(name);
if (field instanceof PDFButton) return field;
throw new UnexpectedFieldTypeError(name, PDFButton, field);
}
/**
* Get the check box field in this [[PDFForm]] with the given name.
* For example:
* ```js
* const form = pdfDoc.getForm()
* const checkBox = form.getCheckBox('Page1.Foo.CheckBox[0]')
* checkBox.check()
* ```
* An error will be thrown if no field exists with the provided name, or if
* the field exists but is not a check box.
* @param name A fully qualified check box name.
* @returns The check box with the specified name.
*/
getCheckBox(name: string): PDFCheckBox {
assertIs(name, 'name', ['string']);
const field = this.getField(name);
if (field instanceof PDFCheckBox) return field;
throw new UnexpectedFieldTypeError(name, PDFCheckBox, field);
}
/**
* Get the dropdown field in this [[PDFForm]] with the given name.
* For example:
* ```js
* const form = pdfDoc.getForm()
* const dropdown = form.getDropdown('Page1.Foo.Dropdown[0]')
* const options = dropdown.getOptions()
* dropdown.select(options[0])
* ```
* An error will be thrown if no field exists with the provided name, or if
* the field exists but is not a dropdown.
* @param name A fully qualified dropdown name.
* @returns The dropdown with the specified name.
*/
getDropdown(name: string): PDFDropdown {
assertIs(name, 'name', ['string']);
const field = this.getField(name);
if (field instanceof PDFDropdown) return field;
throw new UnexpectedFieldTypeError(name, PDFDropdown, field);
}
/**
* Get the option list field in this [[PDFForm]] with the given name.
* For example:
* ```js
* const form = pdfDoc.getForm()
* const optionList = form.getOptionList('Page1.Foo.OptionList[0]')
* const options = optionList.getOptions()
* optionList.select(options[0])
* ```
* An error will be thrown if no field exists with the provided name, or if
* the field exists but is not an option list.
* @param name A fully qualified option list name.
* @returns The option list with the specified name.
*/
getOptionList(name: string): PDFOptionList {
assertIs(name, 'name', ['string']);
const field = this.getField(name);
if (field instanceof PDFOptionList) return field;
throw new UnexpectedFieldTypeError(name, PDFOptionList, field);
}
/**
* Get the radio group field in this [[PDFForm]] with the given name.
* For example:
* ```js
* const form = pdfDoc.getForm()
* const radioGroup = form.getRadioGroup('Page1.Foo.RadioGroup[0]')
* const options = radioGroup.getOptions()
* radioGroup.select(options[0])
* ```
* An error will be thrown if no field exists with the provided name, or if
* the field exists but is not a radio group.
* @param name A fully qualified radio group name.
* @returns The radio group with the specified name.
*/
getRadioGroup(name: string): PDFRadioGroup {
assertIs(name, 'name', ['string']);
const field = this.getField(name);
if (field instanceof PDFRadioGroup) return field;
throw new UnexpectedFieldTypeError(name, PDFRadioGroup, field);
}
/**
* Get the signature field in this [[PDFForm]] with the given name.
* For example:
* ```js
* const form = pdfDoc.getForm()
* const signature = form.getSignature('Page1.Foo.Signature[0]')
* ```
* An error will be thrown if no field exists with the provided name, or if
* the field exists but is not a signature.
* @param name A fully qualified signature name.
* @returns The signature with the specified name.
*/
getSignature(name: string): PDFSignature {
assertIs(name, 'name', ['string']);
const field = this.getField(name);
if (field instanceof PDFSignature) return field;
throw new UnexpectedFieldTypeError(name, PDFSignature, field);
}
/**
* Get the text field in this [[PDFForm]] with the given name.
* For example:
* ```js
* const form = pdfDoc.getForm()
* const textField = form.getTextField('Page1.Foo.TextField[0]')
* textField.setText('Are you designed to act or to be acted upon?')
* ```
* An error will be thrown if no field exists with the provided name, or if
* the field exists but is not a text field.
* @param name A fully qualified text field name.
* @returns The text field with the specified name.
*/
getTextField(name: string): PDFTextField {
assertIs(name, 'name', ['string']);
const field = this.getField(name);
if (field instanceof PDFTextField) return field;
throw new UnexpectedFieldTypeError(name, PDFTextField, field);
}
/**
* Create a new button field in this [[PDFForm]] with the given name.
* For example:
* ```js
* const font = await pdfDoc.embedFont(StandardFonts.Helvetica)
* const page = pdfDoc.addPage()
*
* const form = pdfDoc.getForm()
* const button = form.createButton('cool.new.button')
*
* button.addToPage('Do Stuff', font, page)
* ```
* An error will be thrown if a field already exists with the provided name.
* @param name The fully qualified name for the new button.
* @returns The new button field.
*/
createButton(name: string): PDFButton {
assertIs(name, 'name', ['string']);
const nameParts = splitFieldName(name);
const parent = this.findOrCreateNonTerminals(nameParts.nonTerminal);
const button = PDFAcroPushButton.create(this.doc.context);
button.setPartialName(nameParts.terminal);
addFieldToParent(parent, [button, button.ref], nameParts.terminal);
return PDFButton.of(button, button.ref, this.doc);
}
/**
* Create a new check box field in this [[PDFForm]] with the given name.
* For example:
* ```js
* const font = await pdfDoc.embedFont(StandardFonts.Helvetica)
* const page = pdfDoc.addPage()
*
* const form = pdfDoc.getForm()
* const checkBox = form.createCheckBox('cool.new.checkBox')
*
* checkBox.addToPage(page)
* ```
* An error will be thrown if a field already exists with the provided name.
* @param name The fully qualified name for the new check box.
* @returns The new check box field.
*/
createCheckBox(name: string): PDFCheckBox {
assertIs(name, 'name', ['string']);
const nameParts = splitFieldName(name);
const parent = this.findOrCreateNonTerminals(nameParts.nonTerminal);
const checkBox = PDFAcroCheckBox.create(this.doc.context);
checkBox.setPartialName(nameParts.terminal);
addFieldToParent(parent, [checkBox, checkBox.ref], nameParts.terminal);
return PDFCheckBox.of(checkBox, checkBox.ref, this.doc);
}
/**
* Create a new dropdown field in this [[PDFForm]] with the given name.
* For example:
* ```js
* const font = await pdfDoc.embedFont(StandardFonts.Helvetica)
* const page = pdfDoc.addPage()
*
* const form = pdfDoc.getForm()
* const dropdown = form.createDropdown('cool.new.dropdown')
*
* dropdown.addToPage(font, page)
* ```
* An error will be thrown if a field already exists with the provided name.
* @param name The fully qualified name for the new dropdown.
* @returns The new dropdown field.
*/
createDropdown(name: string): PDFDropdown {
assertIs(name, 'name', ['string']);
const nameParts = splitFieldName(name);
const parent = this.findOrCreateNonTerminals(nameParts.nonTerminal);
const comboBox = PDFAcroComboBox.create(this.doc.context);
comboBox.setPartialName(nameParts.terminal);
addFieldToParent(parent, [comboBox, comboBox.ref], nameParts.terminal);
return PDFDropdown.of(comboBox, comboBox.ref, this.doc);
}
/**
* Create a new option list field in this [[PDFForm]] with the given name.
* For example:
* ```js
* const font = await pdfDoc.embedFont(StandardFonts.Helvetica)
* const page = pdfDoc.addPage()
*
* const form = pdfDoc.getForm()
* const optionList = form.createOptionList('cool.new.optionList')
*
* optionList.addToPage(font, page)
* ```
* An error will be thrown if a field already exists with the provided name.
* @param name The fully qualified name for the new option list.
* @returns The new option list field.
*/
createOptionList(name: string): PDFOptionList {
assertIs(name, 'name', ['string']);
const nameParts = splitFieldName(name);
const parent = this.findOrCreateNonTerminals(nameParts.nonTerminal);
const listBox = PDFAcroListBox.create(this.doc.context);
listBox.setPartialName(nameParts.terminal);
addFieldToParent(parent, [listBox, listBox.ref], nameParts.terminal);
return PDFOptionList.of(listBox, listBox.ref, this.doc);
}
/**
* Create a new radio group field in this [[PDFForm]] with the given name.
* For example:
* ```js
* const font = await pdfDoc.embedFont(StandardFonts.Helvetica)
* const page = pdfDoc.addPage()
*
* const form = pdfDoc.getForm()
* const radioGroup = form.createRadioGroup('cool.new.radioGroup')
*
* radioGroup.addOptionToPage('is-dog', page, { y: 0 })
* radioGroup.addOptionToPage('is-cat', page, { y: 75 })
* ```
* An error will be thrown if a field already exists with the provided name.
* @param name The fully qualified name for the new radio group.
* @returns The new radio group field.
*/
createRadioGroup(name: string): PDFRadioGroup {
assertIs(name, 'name', ['string']);
const nameParts = splitFieldName(name);
const parent = this.findOrCreateNonTerminals(nameParts.nonTerminal);
const radioButton = PDFAcroRadioButton.create(this.doc.context);
radioButton.setPartialName(nameParts.terminal);
addFieldToParent(
parent,
[radioButton, radioButton.ref],
nameParts.terminal,
);
return PDFRadioGroup.of(radioButton, radioButton.ref, this.doc);
}
/**
* Create a new text field in this [[PDFForm]] with the given name.
* For example:
* ```js
* const font = await pdfDoc.embedFont(StandardFonts.Helvetica)
* const page = pdfDoc.addPage()
*
* const form = pdfDoc.getForm()
* const textField = form.createTextField('cool.new.textField')
*
* textField.addToPage(font, page)
* ```
* An error will be thrown if a field already exists with the provided name.
* @param name The fully qualified name for the new radio group.
* @returns The new radio group field.
*/
createTextField(name: string): PDFTextField {
assertIs(name, 'name', ['string']);
const nameParts = splitFieldName(name);
const parent = this.findOrCreateNonTerminals(nameParts.nonTerminal);
const text = PDFAcroText.create(this.doc.context);
text.setPartialName(nameParts.terminal);
addFieldToParent(parent, [text, text.ref], nameParts.terminal);
return PDFTextField.of(text, text.ref, this.doc);
}
/**
* Flatten all fields in this [[PDFForm]].
*
* Flattening a form field will take the current appearance for each of that
* field's widgets and make them part of their page's content stream. All form
* fields and annotations associated are then removed. Note that once a form
* has been flattened its fields can no longer be accessed or edited.
*
* This operation is often used after filling form fields to ensure a
* consistent appearance across different PDF readers and/or printers.
* Another common use case is to copy a template document with form fields
* into another document. In this scenario you would load the template
* document, fill its fields, flatten it, and then copy its pages into the
* recipient document - the filled fields will be copied over.
*
* For example:
* ```js
* const form = pdfDoc.getForm();
* form.flatten();
* ```
*/
flatten(options: FlattenOptions = { updateFieldAppearances: true }) {
if (options.updateFieldAppearances) {
this.updateFieldAppearances();
}
const fields = this.getFields();
for (let i = 0, lenFields = fields.length; i < lenFields; i++) {
const field = fields[i];
const widgets = field.acroField.getWidgets();
for (let j = 0, lenWidgets = widgets.length; j < lenWidgets; j++) {
const widget = widgets[j];
const page = this.findWidgetPage(widget);
const widgetRef = this.findWidgetAppearanceRef(field, widget);
const xObjectKey = page.node.newXObject('FlatWidget', widgetRef);
const rectangle = widget.getRectangle();
const operators = [
pushGraphicsState(),
translate(rectangle.x, rectangle.y),
...rotateInPlace({ ...rectangle, rotation: 0 }),
drawObject(xObjectKey),
popGraphicsState(),
].filter(Boolean) as PDFOperator[];
page.pushOperators(...operators);
}
this.removeField(field);
}
}
/**
* Remove a field from this [[PDFForm]].
*
* For example:
* ```js
* const form = pdfDoc.getForm();
* const ageField = form.getFields().find(x => x.getName() === 'Age');
* form.removeField(ageField);
* ```
*/
removeField(field: PDFField) {
const widgets = field.acroField.getWidgets();
const pages: Set<PDFPage> = new Set();
for (let i = 0, len = widgets.length; i < len; i++) {
const widget = widgets[i];
const widgetRef = this.findWidgetAppearanceRef(field, widget);
const page = this.findWidgetPage(widget);
pages.add(page);
page.node.removeAnnot(widgetRef);
}
pages.forEach((page) => page.node.removeAnnot(field.ref));
this.acroForm.removeField(field.acroField);
const fieldKids = field.acroField.normalizedEntries().Kids;
const kidsCount = fieldKids.size();
for (let childIndex = 0; childIndex < kidsCount; childIndex++) {
const child = fieldKids.get(childIndex);
if (child instanceof PDFRef) {
this.doc.context.delete(child);
}
}
this.doc.context.delete(field.ref);
}
/**
* Update the appearance streams for all widgets of all fields in this
* [[PDFForm]]. Appearance streams will only be created for a widget if it
* does not have any existing appearance streams, or the field's value has
* changed (e.g. by calling [[PDFTextField.setText]] or
* [[PDFDropdown.select]]).
*
* For example:
* ```js
* const courier = await pdfDoc.embedFont(StandardFonts.Courier)
* const form = pdfDoc.getForm()
* form.updateFieldAppearances(courier)
* ```
*
* **IMPORTANT:** The default value for the `font` parameter is
* [[StandardFonts.Helvetica]]. Note that this is a WinAnsi font. This means
* that encoding errors will be thrown if any fields contain text with
* characters outside the WinAnsi character set (the latin alphabet).
*
* Embedding a custom font and passing that as the `font`
* parameter allows you to generate appearance streams with non WinAnsi
* characters (assuming your custom font supports them).
*
* > **NOTE:** The [[PDFDocument.save]] method will call this method to
* > update appearances automatically if a form was accessed via the
* > [[PDFDocument.getForm]] method prior to saving.
*
* @param font Optionally, the font to use when creating new appearances.
*/
updateFieldAppearances(font?: PDFFont) {
assertOrUndefined(font, 'font', [[PDFFont, 'PDFFont']]);
font = font ?? this.getDefaultFont();
const fields = this.getFields();
for (let idx = 0, len = fields.length; idx < len; idx++) {
const field = fields[idx];
if (field.needsAppearancesUpdate()) {
field.defaultUpdateAppearances(font);
}
}
}
/**
* Mark a field as dirty. This will cause its appearance streams to be
* updated by [[PDFForm.updateFieldAppearances]].
* ```js
* const form = pdfDoc.getForm()
* const field = form.getField('foo.bar')
* form.markFieldAsDirty(field.ref)
* ```
* @param fieldRef The reference to the field that should be marked.
*/
markFieldAsDirty(fieldRef: PDFRef) {
assertOrUndefined(fieldRef, 'fieldRef', [[PDFRef, 'PDFRef']]);
this.dirtyFields.add(fieldRef);
}
/**
* Mark a field as dirty. This will cause its appearance streams to not be
* updated by [[PDFForm.updateFieldAppearances]].
* ```js
* const form = pdfDoc.getForm()
* const field = form.getField('foo.bar')
* form.markFieldAsClean(field.ref)
* ```
* @param fieldRef The reference to the field that should be marked.
*/
markFieldAsClean(fieldRef: PDFRef) {
assertOrUndefined(fieldRef, 'fieldRef', [[PDFRef, 'PDFRef']]);
this.dirtyFields.delete(fieldRef);
}
/**
* Returns `true` is the specified field has been marked as dirty.
* ```js
* const form = pdfDoc.getForm()
* const field = form.getField('foo.bar')
* if (form.fieldIsDirty(field.ref)) console.log('Field is dirty')
* ```
* @param fieldRef The reference to the field that should be checked.
* @returns Whether or not the specified field is dirty.
*/
fieldIsDirty(fieldRef: PDFRef): boolean {
assertOrUndefined(fieldRef, 'fieldRef', [[PDFRef, 'PDFRef']]);
return this.dirtyFields.has(fieldRef);
}
getDefaultFont() {
return this.defaultFontCache.access();
}
private findWidgetPage(widget: PDFWidgetAnnotation): PDFPage {
const pageRef = widget.P();
let page = this.doc.getPages().find((x) => x.ref === pageRef);
if (page === undefined) {
const widgetRef = this.doc.context.getObjectRef(widget.dict);
if (widgetRef === undefined) {
throw new Error('Could not find PDFRef for PDFObject');
}
page = this.doc.findPageForAnnotationRef(widgetRef);
if (page === undefined) {
throw new Error(`Could not find page for PDFRef ${widgetRef}`);
}
}
return page;
}
private findWidgetAppearanceRef(
field: PDFField,
widget: PDFWidgetAnnotation,
): PDFRef {
let refOrDict = widget.getNormalAppearance();
if (
refOrDict instanceof PDFDict &&
(field instanceof PDFCheckBox || field instanceof PDFRadioGroup)
) {
const value = field.acroField.getValue();
const ref = refOrDict.get(value) ?? refOrDict.get(PDFName.of('Off'));
if (ref instanceof PDFRef) {
refOrDict = ref;
}
}
if (!(refOrDict instanceof PDFRef)) {
const name = field.getName();
throw new Error(`Failed to extract appearance ref for: ${name}`);
}
return refOrDict;
}
private findOrCreateNonTerminals(partialNames: string[]) {
let nonTerminal: [PDFAcroForm] | [PDFAcroNonTerminal, PDFRef] = [
this.acroForm,
];
for (let idx = 0, len = partialNames.length; idx < len; idx++) {
const namePart = partialNames[idx];
if (!namePart) throw new InvalidFieldNamePartError(namePart);
const [parent, parentRef] = nonTerminal;
const res = this.findNonTerminal(namePart, parent);
if (res) {
nonTerminal = res;
} else {
const node = PDFAcroNonTerminal.create(this.doc.context);
node.setPartialName(namePart);
node.setParent(parentRef);
const nodeRef = this.doc.context.register(node.dict);
parent.addField(nodeRef);
nonTerminal = [node, nodeRef];
}
}
return nonTerminal;
}
private findNonTerminal(
partialName: string,
parent: PDFAcroForm | PDFAcroNonTerminal,
): [PDFAcroNonTerminal, PDFRef] | undefined {
const fields =
parent instanceof PDFAcroForm
? this.acroForm.getFields()
: createPDFAcroFields(parent.Kids());
for (let idx = 0, len = fields.length; idx < len; idx++) {
const [field, ref] = fields[idx];
if (field.getPartialName() === partialName) {
if (field instanceof PDFAcroNonTerminal) return [field, ref];
throw new FieldAlreadyExistsError(partialName);
}
}
return undefined;
}
private embedDefaultFont = (): PDFFont =>
this.doc.embedStandardFont(StandardFonts.Helvetica);
}
const convertToPDFField = (
field: PDFAcroField,
ref: PDFRef,
doc: PDFDocument,
): PDFField | undefined => {
if (field instanceof PDFAcroPushButton) return PDFButton.of(field, ref, doc);
if (field instanceof PDFAcroCheckBox) return PDFCheckBox.of(field, ref, doc);
if (field instanceof PDFAcroComboBox) return PDFDropdown.of(field, ref, doc);
if (field instanceof PDFAcroListBox) return PDFOptionList.of(field, ref, doc);
if (field instanceof PDFAcroText) return PDFTextField.of(field, ref, doc);
if (field instanceof PDFAcroRadioButton) {
return PDFRadioGroup.of(field, ref, doc);
}
if (field instanceof PDFAcroSignature) {
return PDFSignature.of(field, ref, doc);
}
return undefined;
};
const splitFieldName = (fullyQualifiedName: string) => {
if (fullyQualifiedName.length === 0) {
throw new Error('PDF field names must not be empty strings');
}
const parts = fullyQualifiedName.split('.');
for (let idx = 0, len = parts.length; idx < len; idx++) {
if (parts[idx] === '') {
throw new Error(
`Periods in PDF field names must be separated by at least one character: "${fullyQualifiedName}"`,
);
}
}
if (parts.length === 1) return { nonTerminal: [], terminal: parts[0] };
return {
nonTerminal: parts.slice(0, parts.length - 1),
terminal: parts[parts.length - 1],
};
};
const addFieldToParent = (
[parent, parentRef]: [PDFAcroForm] | [PDFAcroNonTerminal, PDFRef],
[field, fieldRef]: [PDFAcroField, PDFRef],
partialName: string,
) => {
const entries = parent.normalizedEntries();
const fields = createPDFAcroFields(
'Kids' in entries ? entries.Kids : entries.Fields,
);
for (let idx = 0, len = fields.length; idx < len; idx++) {
if (fields[idx][0].getPartialName() === partialName) {
throw new FieldAlreadyExistsError(partialName);
}
}
parent.addField(fieldRef);
field.setParent(parentRef);
};
+571
View File
@@ -0,0 +1,571 @@
import PDFDocument from 'src/api/PDFDocument';
import PDFPage from 'src/api/PDFPage';
import PDFFont from 'src/api/PDFFont';
import PDFField, {
FieldAppearanceOptions,
assertFieldAppearanceOptions,
} from 'src/api/form/PDFField';
import {
AppearanceProviderFor,
normalizeAppearance,
defaultOptionListAppearanceProvider,
} from 'src/api/form/appearances';
import { rgb } from 'src/api/colors';
import { degrees } from 'src/api/rotations';
import {
PDFRef,
PDFHexString,
PDFString,
PDFStream,
PDFAcroListBox,
AcroChoiceFlags,
PDFWidgetAnnotation,
} from 'src/core';
import {
assertIs,
assertIsSubset,
assertOrUndefined,
assertPositive,
} from 'src/utils';
/**
* Represents an option list field of a [[PDFForm]].
*
* [[PDFOptionList]] fields are interactive lists of options. The purpose of an
* option list is to enable users to select one or more options from a set of
* possible options. Users are able to see the full set of options without
* first having to click on the field (though scrolling may be necessary).
* Clicking an option in the list will cause it to be selected and displayed
* with a highlighted background. Some option lists allow users to select
* more than one option (see [[PDFOptionList.isMultiselect]]).
*/
export default class PDFOptionList extends PDFField {
/**
* > **NOTE:** You probably don't want to call this method directly. Instead,
* > consider using the [[PDFForm.getOptionList]] method, which will create
* > an instance of [[PDFOptionList]] for you.
*
* Create an instance of [[PDFOptionList]] from an existing acroListBox and
* ref
*
* @param acroComboBox The underlying `PDFAcroListBox` for this option list.
* @param ref The unique reference for this option list.
* @param doc The document to which this option list will belong.
*/
static of = (acroListBox: PDFAcroListBox, ref: PDFRef, doc: PDFDocument) =>
new PDFOptionList(acroListBox, ref, doc);
/** The low-level PDFAcroListBox wrapped by this option list. */
readonly acroField: PDFAcroListBox;
private constructor(
acroListBox: PDFAcroListBox,
ref: PDFRef,
doc: PDFDocument,
) {
super(acroListBox, ref, doc);
assertIs(acroListBox, 'acroListBox', [[PDFAcroListBox, 'PDFAcroListBox']]);
this.acroField = acroListBox;
}
/**
* Get the list of available options for this option list. These options will
* be displayed to users who view this option list in a PDF reader.
* For example:
* ```js
* const optionList = form.getOptionList('some.optionList.field')
* const options = optionList.getOptions()
* console.log('Option List options:', options)
* ```
* @returns The options for this option list.
*/
getOptions(): string[] {
const rawOptions = this.acroField.getOptions();
const options = new Array<string>(rawOptions.length);
for (let idx = 0, len = options.length; idx < len; idx++) {
const { display, value } = rawOptions[idx];
options[idx] = (display ?? value).decodeText();
}
return options;
}
/**
* Get the selected options for this option list. These are the values that
* were selected by a human user via a PDF reader, or programatically via
* software.
* For example:
* ```js
* const optionList = form.getOptionList('some.optionList.field')
* const selections = optionList.getSelected()
* console.log('Option List selections:', selections)
* ```
* @returns The selected options for this option list.
*/
getSelected(): string[] {
const values = this.acroField.getValues();
const selected = new Array<string>(values.length);
for (let idx = 0, len = values.length; idx < len; idx++) {
selected[idx] = values[idx].decodeText();
}
return selected;
}
/**
* Set the list of options that are available for this option list. These are
* the values that will be available for users to select when they view this
* option list in a PDF reader. Note that preexisting options for this
* option list will be removed. Only the values passed as `options` will be
* available to select.
*
* For example:
* ```js
* const optionList = form.getOptionList('planets.optionList')
* optionList.setOptions(['Earth', 'Mars', 'Pluto', 'Venus'])
* ```
*
* This method will mark this option list as dirty, causing its appearance
* streams to be updated when either [[PDFDocument.save]] or
* [[PDFForm.updateFieldAppearances]] is called. The updated streams will
* display the options this field contains inside the widgets of this text
* field (with selected options highlighted).
*
* **IMPORTANT:** The default font used to update appearance streams is
* [[StandardFonts.Helvetica]]. Note that this is a WinAnsi font. This means
* that encoding errors will be thrown if this field contains any options
* with characters outside the WinAnsi character set (the latin alphabet).
*
* Embedding a custom font and passing it to
* [[PDFForm.updateFieldAppearances]] or [[PDFOptionList.updateAppearances]]
* allows you to generate appearance streams with characters outside the
* latin alphabet (assuming the custom font supports them).
*
* @param options The options that should be available in this option list.
*/
setOptions(options: string[]) {
assertIs(options, 'options', [Array]);
this.markAsDirty();
const optionObjects = new Array<{ value: PDFHexString }>(options.length);
for (let idx = 0, len = options.length; idx < len; idx++) {
optionObjects[idx] = { value: PDFHexString.fromText(options[idx]) };
}
this.acroField.setOptions(optionObjects);
}
/**
* Add to the list of options that are available for this option list. Users
* will be able to select these values in a PDF reader. In addition to the
* values passed as `options`, any preexisting options for this option list
* will still be available for users to select.
* For example:
* ```js
* const optionList = form.getOptionList('rockets.optionList')
* optionList.addOptions(['Saturn IV', 'Falcon Heavy'])
* ```
* This method will mark this option list as dirty. See
* [[PDFOptionList.setOptions]] for more details about what this means.
* @param options New options that should be available in this option list.
*/
addOptions(options: string | string[]) {
assertIs(options, 'options', ['string', Array]);
this.markAsDirty();
const optionsArr = Array.isArray(options) ? options : [options];
const existingOptions: {
value: PDFString | PDFHexString;
display?: PDFString | PDFHexString;
}[] = this.acroField.getOptions();
const newOptions = new Array<{ value: PDFHexString }>(optionsArr.length);
for (let idx = 0, len = optionsArr.length; idx < len; idx++) {
newOptions[idx] = { value: PDFHexString.fromText(optionsArr[idx]) };
}
this.acroField.setOptions(existingOptions.concat(newOptions));
}
/**
* Select one or more values for this option list. This operation is analogous
* to a human user opening the option list in a PDF reader and clicking on one
* or more values to select them. This method will update the underlying state
* of the option list to indicate which values have been selected. PDF
* libraries and readers will be able to extract these values from the saved
* document and determine which values were selected.
* For example:
* ```js
* const optionList = form.getOptionList('best.superheroes.optionList')
* optionList.select(['One Punch Man', 'Iron Man'])
* ```
* This method will mark this option list as dirty. See
* [[PDFOptionList.setOptions]] for more details about what this means.
* @param options The options to be selected.
* @param merge Whether or not existing selections should be preserved.
*/
select(options: string | string[], merge = false) {
assertIs(options, 'options', ['string', Array]);
assertIs(merge, 'merge', ['boolean']);
const optionsArr = Array.isArray(options) ? options : [options];
const validOptions = this.getOptions();
assertIsSubset(optionsArr, 'option', validOptions);
this.markAsDirty();
if (optionsArr.length > 1 || (optionsArr.length === 1 && merge)) {
this.enableMultiselect();
}
const values = new Array<PDFHexString>(optionsArr.length);
for (let idx = 0, len = optionsArr.length; idx < len; idx++) {
values[idx] = PDFHexString.fromText(optionsArr[idx]);
}
if (merge) {
const existingValues = this.acroField.getValues();
this.acroField.setValues(existingValues.concat(values));
} else {
this.acroField.setValues(values);
}
}
/**
* Clear all selected values for this option list. This operation is
* equivalent to selecting an empty list. This method will update the
* underlying state of the option list to indicate that no values have been
* selected.
* For example:
* ```js
* const optionList = form.getOptionList('some.optionList.field')
* optionList.clear()
* ```
* This method will mark this option list as dirty. See
* [[PDFOptionList.setOptions]] for more details about what this means.
*/
clear() {
this.markAsDirty();
this.acroField.setValues([]);
}
/**
* Set the font size for the text in this field. There needs to be a
* default appearance string (DA) set with a font value specified
* for this to work. For example:
* ```js
* const optionList = form.getOptionList('some.optionList.field')
* optionList.setFontSize(4);
* ```
* @param fontSize The font size to set the font to.
*/
/**
* Set the font size for this field. Larger font sizes will result in larger
* text being displayed when PDF readers render this option list. Font sizes
* may be integer or floating point numbers. Supplying a negative font size
* will cause this method to throw an error.
*
* For example:
* ```js
* const optionList = form.getOptionList('some.optionList.field')
* optionList.setFontSize(4)
* optionList.setFontSize(15.7)
* ```
*
* > This method depends upon the existence of a default appearance
* > (`/DA`) string. If this field does not have a default appearance string,
* > or that string does not contain a font size (via the `Tf` operator),
* > then this method will throw an error.
*
* @param fontSize The font size to be used when rendering text in this field.
*/
setFontSize(fontSize: number) {
assertPositive(fontSize, 'fontSize');
this.acroField.setFontSize(fontSize);
this.markAsDirty();
}
/**
* Returns `true` if the options of this option list are always displayed
* in alphabetical order, irrespective of the order in which the options
* were added to the option list. See [[PDFOptionList.enableSorting]] and
* [[PDFOptionList.disableSorting]]. For example:
* ```js
* const optionList = form.getOptionList('some.optionList.field')
* if (optionList.isSorted()) console.log('Sorting is enabled')
* ```
* @returns Whether or not this option list is sorted.
*/
isSorted(): boolean {
return this.acroField.hasFlag(AcroChoiceFlags.Sort);
}
/**
* Always display the options of this option list in alphabetical order,
* irrespective of the order in which the options were added to this option
* list.
* For example:
* ```js
* const optionList = form.getOptionList('some.optionList.field')
* optionList.enableSorting()
* ```
*/
enableSorting() {
this.acroField.setFlagTo(AcroChoiceFlags.Sort, true);
}
/**
* Do not always display the options of this option list in alphabetical
* order. Instead, display the options in whichever order they were added
* to this option list. For example:
* ```js
* const optionList = form.getOptionList('some.optionList.field')
* optionList.disableSorting()
* ```
*/
disableSorting() {
this.acroField.setFlagTo(AcroChoiceFlags.Sort, false);
}
/**
* Returns `true` if multiple options can be selected from this option list.
* See [[PDFOptionList.enableMultiselect]] and
* [[PDFOptionList.disableMultiselect]]. For example:
* ```js
* const optionList = form.getOptionList('some.optionList.field')
* if (optionList.isMultiselect()) console.log('Multiselect is enabled')
* ```
* @returns Whether or not multiple options can be selected.
*/
isMultiselect(): boolean {
return this.acroField.hasFlag(AcroChoiceFlags.MultiSelect);
}
/**
* Allow users to select more than one option from this option list.
* For example:
* ```js
* const optionList = form.getOptionList('some.optionList.field')
* optionList.enableMultiselect()
* ```
*/
enableMultiselect() {
this.acroField.setFlagTo(AcroChoiceFlags.MultiSelect, true);
}
/**
* Do not allow users to select more than one option from this option list.
* For example:
* ```js
* const optionList = form.getOptionList('some.optionList.field')
* optionList.disableMultiselect()
* ```
*/
disableMultiselect() {
this.acroField.setFlagTo(AcroChoiceFlags.MultiSelect, false);
}
/**
* Returns `true` if the option selected by a user is stored, or "committed",
* when the user clicks the option. The alternative is that the user's
* selection is stored when the user leaves this option list field (by
* clicking outside of it - on another field, for example). See
* [[PDFOptionList.enableSelectOnClick]] and
* [[PDFOptionList.disableSelectOnClick]]. For example:
* ```js
* const optionList = form.getOptionList('some.optionList.field')
* if (optionList.isSelectOnClick()) console.log('Select on click is enabled')
* ```
* @returns Whether or not options are selected immediately after they are
* clicked.
*/
isSelectOnClick(): boolean {
return this.acroField.hasFlag(AcroChoiceFlags.CommitOnSelChange);
}
/**
* Store the option selected by a user immediately after the user clicks the
* option. Do not wait for the user to leave this option list field (by
* clicking outside of it - on another field, for example). For example:
* ```js
* const optionList = form.getOptionList('some.optionList.field')
* optionList.enableSelectOnClick()
* ```
*/
enableSelectOnClick() {
this.acroField.setFlagTo(AcroChoiceFlags.CommitOnSelChange, true);
}
/**
* Wait to store the option selected by a user until they leave this option
* list field (by clicking outside of it - on another field, for example).
* For example:
* ```js
* const optionList = form.getOptionList('some.optionList.field')
* optionList.disableSelectOnClick()
* ```
*/
disableSelectOnClick() {
this.acroField.setFlagTo(AcroChoiceFlags.CommitOnSelChange, false);
}
/**
* Show this option list on the specified page. For example:
* ```js
* const ubuntuFont = await pdfDoc.embedFont(ubuntuFontBytes)
* const page = pdfDoc.addPage()
*
* const form = pdfDoc.getForm()
* const optionList = form.createOptionList('best.gundams')
* optionList.setOptions(['Exia', 'Dynames', 'Kyrios', 'Virtue'])
* optionList.select(['Exia', 'Virtue'])
*
* optionList.addToPage(page, {
* x: 50,
* y: 75,
* width: 200,
* height: 100,
* textColor: rgb(1, 0, 0),
* backgroundColor: rgb(0, 1, 0),
* borderColor: rgb(0, 0, 1),
* borderWidth: 2,
* rotate: degrees(90),
* font: ubuntuFont,
* })
* ```
* This will create a new widget for this option list field.
* @param page The page to which this option list widget should be added.
* @param options The options to be used when adding this option list widget.
*/
addToPage(page: PDFPage, options?: FieldAppearanceOptions) {
assertIs(page, 'page', [[PDFPage, 'PDFPage']]);
assertFieldAppearanceOptions(options);
if (!options) options = {};
if (!('textColor' in options)) options.textColor = rgb(0, 0, 0);
if (!('backgroundColor' in options)) options.backgroundColor = rgb(1, 1, 1);
if (!('borderColor' in options)) options.borderColor = rgb(0, 0, 0);
if (!('borderWidth' in options)) options.borderWidth = 1;
// Create a widget for this option list
const widget = this.createWidget({
x: options.x ?? 0,
y: options.y ?? 0,
width: options.width ?? 200,
height: options.height ?? 100,
textColor: options.textColor,
backgroundColor: options.backgroundColor,
borderColor: options.borderColor,
borderWidth: options.borderWidth ?? 0,
rotate: options.rotate ?? degrees(0),
hidden: options.hidden,
page: page.ref,
});
const widgetRef = this.doc.context.register(widget.dict);
// Add widget to this field
this.acroField.addWidget(widgetRef);
// Set appearance streams for widget
const font = options.font ?? this.doc.getForm().getDefaultFont();
this.updateWidgetAppearance(widget, font);
// Add widget to the given page
page.node.addAnnot(widgetRef);
}
/**
* Returns `true` if this option list has been marked as dirty, or if any of
* this option list's widgets do not have an appearance stream. For example:
* ```js
* const optionList = form.getOptionList('some.optionList.field')
* if (optionList.needsAppearancesUpdate()) console.log('Needs update')
* ```
* @returns Whether or not this option list needs an appearance update.
*/
needsAppearancesUpdate(): boolean {
if (this.isDirty()) return true;
const widgets = this.acroField.getWidgets();
for (let idx = 0, len = widgets.length; idx < len; idx++) {
const widget = widgets[idx];
const hasAppearances =
widget.getAppearances()?.normal instanceof PDFStream;
if (!hasAppearances) return true;
}
return false;
}
/**
* Update the appearance streams for each of this option list's widgets using
* the default appearance provider for option lists. For example:
* ```js
* const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica)
* const optionList = form.getOptionList('some.optionList.field')
* optionList.defaultUpdateAppearances(helvetica)
* ```
* @param font The font to be used for creating the appearance streams.
*/
defaultUpdateAppearances(font: PDFFont) {
assertIs(font, 'font', [[PDFFont, 'PDFFont']]);
this.updateAppearances(font);
}
/**
* Update the appearance streams for each of this option list's widgets using
* the given appearance provider. If no `provider` is passed, the default
* appearance provider for option lists will be used. For example:
* ```js
* const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica)
* const optionList = form.getOptionList('some.optionList.field')
* optionList.updateAppearances(helvetica, (field, widget, font) => {
* ...
* return drawOptionList(...)
* })
* ```
* @param font The font to be used for creating the appearance streams.
* @param provider Optionally, the appearance provider to be used for
* generating the contents of the appearance streams.
*/
updateAppearances(
font: PDFFont,
provider?: AppearanceProviderFor<PDFOptionList>,
) {
assertIs(font, 'font', [[PDFFont, 'PDFFont']]);
assertOrUndefined(provider, 'provider', [Function]);
const widgets = this.acroField.getWidgets();
for (let idx = 0, len = widgets.length; idx < len; idx++) {
const widget = widgets[idx];
this.updateWidgetAppearance(widget, font, provider);
}
this.markAsClean();
}
// getOption(index: number): string {}
// getSelectedIndices(): number[] {}
// removeOptions(option: string | string[]) {}
// removeIndices(option: number[]) {}
// deselect(options: string | string[]) {}
// deselectIndices(optionIndices: number[]) {}
private updateWidgetAppearance(
widget: PDFWidgetAnnotation,
font: PDFFont,
provider?: AppearanceProviderFor<PDFOptionList>,
) {
const apProvider = provider ?? defaultOptionListAppearanceProvider;
const appearances = normalizeAppearance(apProvider(this, widget, font));
this.updateWidgetAppearanceWithFont(widget, font, appearances);
}
}
+471
View File
@@ -0,0 +1,471 @@
import PDFDocument from 'src/api/PDFDocument';
import PDFPage from 'src/api/PDFPage';
import PDFField, {
FieldAppearanceOptions,
assertFieldAppearanceOptions,
} from 'src/api/form/PDFField';
import {
AppearanceProviderFor,
normalizeAppearance,
defaultRadioGroupAppearanceProvider,
} from 'src/api/form/appearances';
import { rgb } from 'src/api/colors';
import { degrees } from 'src/api/rotations';
import {
PDFName,
PDFRef,
PDFHexString,
PDFDict,
PDFWidgetAnnotation,
PDFAcroRadioButton,
AcroButtonFlags,
} from 'src/core';
import { assertIs, assertOrUndefined, assertIsOneOf } from 'src/utils';
/**
* Represents a radio group field of a [[PDFForm]].
*
* [[PDFRadioGroup]] fields are collections of radio buttons. The purpose of a
* radio group is to enable users to select one option from a set of mutually
* exclusive choices. Each choice in a radio group is represented by a radio
* button. Radio buttons each have two states: `on` and `off`. At most one
* radio button in a group may be in the `on` state at any time. Users can
* click on a radio button to select it (and thereby automatically deselect any
* other radio button that might have already been selected). Some radio
* groups allow users to toggle a selected radio button `off` by clicking on
* it (see [[PDFRadioGroup.isOffToggleable]]).
*
* Note that some radio groups allow multiple radio buttons to be in the `on`
* state at the same type **if** they represent the same underlying value (see
* [[PDFRadioGroup.isMutuallyExclusive]]).
*/
export default class PDFRadioGroup extends PDFField {
/**
* > **NOTE:** You probably don't want to call this method directly. Instead,
* > consider using the [[PDFForm.getOptionList]] method, which will create an
* > instance of [[PDFOptionList]] for you.
*
* Create an instance of [[PDFOptionList]] from an existing acroRadioButton
* and ref
*
* @param acroRadioButton The underlying `PDFAcroRadioButton` for this
* radio group.
* @param ref The unique reference for this radio group.
* @param doc The document to which this radio group will belong.
*/
static of = (
acroRadioButton: PDFAcroRadioButton,
ref: PDFRef,
doc: PDFDocument,
) => new PDFRadioGroup(acroRadioButton, ref, doc);
/** The low-level PDFAcroRadioButton wrapped by this radio group. */
readonly acroField: PDFAcroRadioButton;
private constructor(
acroRadioButton: PDFAcroRadioButton,
ref: PDFRef,
doc: PDFDocument,
) {
super(acroRadioButton, ref, doc);
assertIs(acroRadioButton, 'acroRadioButton', [
[PDFAcroRadioButton, 'PDFAcroRadioButton'],
]);
this.acroField = acroRadioButton;
}
/**
* Get the list of available options for this radio group. Each option is
* represented by a radio button. These radio buttons are displayed at
* various locations in the document, potentially on different pages (though
* typically they are stacked horizontally or vertically on the same page).
* For example:
* ```js
* const radioGroup = form.getRadioGroup('some.radioGroup.field')
* const options = radioGroup.getOptions()
* console.log('Radio Group options:', options)
* ```
* @returns The options for this radio group.
*/
getOptions(): string[] {
const exportValues = this.acroField.getExportValues();
if (exportValues) {
const exportOptions = new Array<string>(exportValues.length);
for (let idx = 0, len = exportValues.length; idx < len; idx++) {
exportOptions[idx] = exportValues[idx].decodeText();
}
return exportOptions;
}
const onValues = this.acroField.getOnValues();
const onOptions = new Array<string>(onValues.length);
for (let idx = 0, len = onOptions.length; idx < len; idx++) {
onOptions[idx] = onValues[idx].decodeText();
}
return onOptions;
}
/**
* Get the selected option for this radio group. The selected option is
* represented by the radio button in this group that is turned on. At most
* one radio button in a group can be selected. If no buttons in this group
* are selected, `undefined` is returned.
* For example:
* ```js
* const radioGroup = form.getRadioGroup('some.radioGroup.field')
* const selected = radioGroup.getSelected()
* console.log('Selected radio button:', selected)
* ```
* @returns The selected option for this radio group.
*/
getSelected(): string | undefined {
const value = this.acroField.getValue();
if (value === PDFName.of('Off')) return undefined;
const exportValues = this.acroField.getExportValues();
if (exportValues) {
const onValues = this.acroField.getOnValues();
for (let idx = 0, len = onValues.length; idx < len; idx++) {
if (onValues[idx] === value) return exportValues[idx].decodeText();
}
}
return value.decodeText();
}
// // TODO: Figure out why this seems to crash Acrobat. Maybe it's because we
// // aren't removing the widget reference from the page's Annots?
// removeOption(option: string) {
// assertIs(option, 'option', ['string']);
// // TODO: Assert is valid `option`!
// const onValues = this.acroField.getOnValues();
// const exportValues = this.acroField.getExportValues();
// if (exportValues) {
// for (let idx = 0, len = exportValues.length; idx < len; idx++) {
// if (exportValues[idx].decodeText() === option) {
// this.acroField.removeWidget(idx);
// this.acroField.removeExportValue(idx);
// }
// }
// } else {
// for (let idx = 0, len = onValues.length; idx < len; idx++) {
// const value = onValues[idx];
// if (value.decodeText() === option) {
// this.acroField.removeWidget(idx);
// this.acroField.removeExportValue(idx);
// }
// }
// }
// }
/**
* Select an option for this radio group. This operation is analogous to a
* human user clicking one of the radio buttons in this group via a PDF
* reader to toggle it on. This method will update the underlying state of
* the radio group to indicate which option has been selected. PDF libraries
* and readers will be able to extract this value from the saved document and
* determine which option was selected.
*
* For example:
* ```js
* const radioGroup = form.getRadioGroup('best.superhero.radioGroup')
* radioGroup.select('One Punch Man')
* ```
*
* This method will mark this radio group as dirty, causing its appearance
* streams to be updated when either [[PDFDocument.save]] or
* [[PDFForm.updateFieldAppearances]] is called. The updated appearance
* streams will display a dot inside the widget of this check box field
* that represents the selected option.
*
* @param option The option to be selected.
*/
select(option: string) {
assertIs(option, 'option', ['string']);
const validOptions = this.getOptions();
assertIsOneOf(option, 'option', validOptions);
this.markAsDirty();
const onValues = this.acroField.getOnValues();
const exportValues = this.acroField.getExportValues();
if (exportValues) {
for (let idx = 0, len = exportValues.length; idx < len; idx++) {
if (exportValues[idx].decodeText() === option) {
this.acroField.setValue(onValues[idx]);
}
}
} else {
for (let idx = 0, len = onValues.length; idx < len; idx++) {
const value = onValues[idx];
if (value.decodeText() === option) this.acroField.setValue(value);
}
}
}
/**
* Clear any selected option for this dropdown. This will result in all
* radio buttons in this group being toggled off. This method will update
* the underlying state of the dropdown to indicate that no radio buttons
* have been selected.
* For example:
* ```js
* const radioGroup = form.getRadioGroup('some.radioGroup.field')
* radioGroup.clear()
* ```
* This method will mark this radio group as dirty. See
* [[PDFRadioGroup.select]] for more details about what this means.
*/
clear() {
this.markAsDirty();
this.acroField.setValue(PDFName.of('Off'));
}
/**
* Returns `true` if users can click on radio buttons in this group to toggle
* them off. The alternative is that once a user clicks on a radio button
* to select it, the only way to deselect it is by selecting on another radio
* button in the group. See [[PDFRadioGroup.enableOffToggling]] and
* [[PDFRadioGroup.disableOffToggling]]. For example:
* ```js
* const radioGroup = form.getRadioGroup('some.radioGroup.field')
* if (radioGroup.isOffToggleable()) console.log('Off toggling is enabled')
* ```
*/
isOffToggleable() {
return !this.acroField.hasFlag(AcroButtonFlags.NoToggleToOff);
}
/**
* Allow users to click on selected radio buttons in this group to toggle
* them off. For example:
* ```js
* const radioGroup = form.getRadioGroup('some.radioGroup.field')
* radioGroup.enableOffToggling()
* ```
* > **NOTE:** This feature is documented in the PDF specification
* > (Table 226). However, most PDF readers do not respect this option and
* > prevent users from toggling radio buttons off even when it is enabled.
* > At the time of this writing (9/6/2020) Mac's Preview software did
* > respect the option. Adobe Acrobat, Foxit Reader, and Google Chrome did
* > not.
*/
enableOffToggling() {
this.acroField.setFlagTo(AcroButtonFlags.NoToggleToOff, false);
}
/**
* Prevent users from clicking on selected radio buttons in this group to
* toggle them off. Clicking on a selected radio button will have no effect.
* The only way to deselect a selected radio button is to click on a
* different radio button in the group. For example:
* ```js
* const radioGroup = form.getRadioGroup('some.radioGroup.field')
* radioGroup.disableOffToggling()
* ```
*/
disableOffToggling() {
this.acroField.setFlagTo(AcroButtonFlags.NoToggleToOff, true);
}
/**
* Returns `true` if the radio buttons in this group are mutually exclusive.
* This means that when the user selects a radio button, only that specific
* button will be turned on. Even if other radio buttons in the group
* represent the same value, they will not be enabled. The alternative to
* this is that clicking a radio button will select that button along with
* any other radio buttons in the group that share the same value. See
* [[PDFRadioGroup.enableMutualExclusion]] and
* [[PDFRadioGroup.disableMutualExclusion]].
* For example:
* ```js
* const radioGroup = form.getRadioGroup('some.radioGroup.field')
* if (radioGroup.isMutuallyExclusive()) console.log('Mutual exclusion is enabled')
* ```
*/
isMutuallyExclusive() {
return !this.acroField.hasFlag(AcroButtonFlags.RadiosInUnison);
}
/**
* When the user clicks a radio button in this group it will be selected. In
* addition, any other radio buttons in this group that share the same
* underlying value will also be selected. For example:
* ```js
* const radioGroup = form.getRadioGroup('some.radioGroup.field')
* radioGroup.enableMutualExclusion()
* ```
* Note that this option must be enabled prior to adding options to the
* radio group. It does not currently apply retroactively to existing
* radio buttons in the group.
*/
enableMutualExclusion() {
this.acroField.setFlagTo(AcroButtonFlags.RadiosInUnison, false);
}
/**
* When the user clicks a radio button in this group only it will be selected.
* No other radio buttons in the group will be selected, even if they share
* the same underlying value. For example:
* ```js
* const radioGroup = form.getRadioGroup('some.radioGroup.field')
* radioGroup.disableMutualExclusion()
* ```
* Note that this option must be disabled prior to adding options to the
* radio group. It does not currently apply retroactively to existing
* radio buttons in the group.
*/
disableMutualExclusion() {
this.acroField.setFlagTo(AcroButtonFlags.RadiosInUnison, true);
}
/**
* Add a new radio button to this group on the specified page. For example:
* ```js
* const page = pdfDoc.addPage()
*
* const form = pdfDoc.getForm()
* const radioGroup = form.createRadioGroup('best.gundam')
*
* const options = {
* x: 50,
* width: 25,
* height: 25,
* textColor: rgb(1, 0, 0),
* backgroundColor: rgb(0, 1, 0),
* borderColor: rgb(0, 0, 1),
* borderWidth: 2,
* rotate: degrees(90),
* }
*
* radioGroup.addOptionToPage('Exia', page, { ...options, y: 50 })
* radioGroup.addOptionToPage('Dynames', page, { ...options, y: 110 })
* ```
* This will create a new radio button widget for this radio group field.
* @param option The option that the radio button widget represents.
* @param page The page to which the radio button widget should be added.
* @param options The options to be used when adding the radio button widget.
*/
addOptionToPage(
option: string,
page: PDFPage,
options?: FieldAppearanceOptions,
) {
assertIs(option, 'option', ['string']);
assertIs(page, 'page', [[PDFPage, 'PDFPage']]);
assertFieldAppearanceOptions(options);
// Create a widget for this radio button
const widget = this.createWidget({
x: options?.x ?? 0,
y: options?.y ?? 0,
width: options?.width ?? 50,
height: options?.height ?? 50,
textColor: options?.textColor ?? rgb(0, 0, 0),
backgroundColor: options?.backgroundColor ?? rgb(1, 1, 1),
borderColor: options?.borderColor ?? rgb(0, 0, 0),
borderWidth: options?.borderWidth ?? 1,
rotate: options?.rotate ?? degrees(0),
hidden: options?.hidden,
page: page.ref,
});
const widgetRef = this.doc.context.register(widget.dict);
// Add widget to this field
const apStateValue = this.acroField.addWidgetWithOpt(
widgetRef,
PDFHexString.fromText(option),
!this.isMutuallyExclusive(),
);
// Set appearance streams for widget
widget.setAppearanceState(PDFName.of('Off'));
this.updateWidgetAppearance(widget, apStateValue);
// Add widget to the given page
page.node.addAnnot(widgetRef);
}
/**
* Returns `true` if any of this group's radio button widgets do not have an
* appearance stream for their current state. For example:
* ```js
* const radioGroup = form.getRadioGroup('some.radioGroup.field')
* if (radioGroup.needsAppearancesUpdate()) console.log('Needs update')
* ```
* @returns Whether or not this radio group needs an appearance update.
*/
needsAppearancesUpdate(): boolean {
const widgets = this.acroField.getWidgets();
for (let idx = 0, len = widgets.length; idx < len; idx++) {
const widget = widgets[idx];
const state = widget.getAppearanceState();
const normal = widget.getAppearances()?.normal;
if (!(normal instanceof PDFDict)) return true;
if (state && !normal.has(state)) return true;
}
return false;
}
/**
* Update the appearance streams for each of this group's radio button widgets
* using the default appearance provider for radio groups. For example:
* ```js
* const radioGroup = form.getRadioGroup('some.radioGroup.field')
* radioGroup.defaultUpdateAppearances()
* ```
*/
defaultUpdateAppearances() {
this.updateAppearances();
}
// rg.updateAppearances((field: any, widget: any) => {
// assert(field === rg);
// assert(widget instanceof PDFWidgetAnnotation);
// return { on: [...rectangle, ...circle], off: [...rectangle, ...circle] };
// });
/**
* Update the appearance streams for each of this group's radio button widgets
* using the given appearance provider. If no `provider` is passed, the
* default appearance provider for radio groups will be used. For example:
* ```js
* const radioGroup = form.getRadioGroup('some.radioGroup.field')
* radioGroup.updateAppearances((field, widget) => {
* ...
* return {
* normal: { on: drawRadioButton(...), off: drawRadioButton(...) },
* down: { on: drawRadioButton(...), off: drawRadioButton(...) },
* }
* })
* ```
* @param provider Optionally, the appearance provider to be used for
* generating the contents of the appearance streams.
*/
updateAppearances(provider?: AppearanceProviderFor<PDFRadioGroup>) {
assertOrUndefined(provider, 'provider', [Function]);
const widgets = this.acroField.getWidgets();
for (let idx = 0, len = widgets.length; idx < len; idx++) {
const widget = widgets[idx];
const onValue = widget.getOnValue();
if (!onValue) continue;
this.updateWidgetAppearance(widget, onValue, provider);
}
}
private updateWidgetAppearance(
widget: PDFWidgetAnnotation,
onValue: PDFName,
provider?: AppearanceProviderFor<PDFRadioGroup>,
) {
const apProvider = provider ?? defaultRadioGroupAppearanceProvider;
const appearances = normalizeAppearance(apProvider(this, widget));
this.updateOnOffWidgetAppearance(widget, onValue, appearances);
}
}
+53
View File
@@ -0,0 +1,53 @@
import PDFDocument from 'src/api/PDFDocument';
import PDFField from 'src/api/form/PDFField';
import { PDFRef, PDFAcroSignature } from 'src/core';
import { assertIs } from 'src/utils';
/**
* Represents a signature field of a [[PDFForm]].
*
* [[PDFSignature]] fields are digital signatures. `pdf-lib` does not
* currently provide any specialized APIs for creating digital signatures or
* reading the contents of existing digital signatures.
*/
export default class PDFSignature extends PDFField {
/**
* > **NOTE:** You probably don't want to call this method directly. Instead,
* > consider using the [[PDFForm.getSignature]] method, which will create an
* > instance of [[PDFSignature]] for you.
*
* Create an instance of [[PDFSignature]] from an existing acroSignature and
* ref
*
* @param acroSignature The underlying `PDFAcroSignature` for this signature.
* @param ref The unique reference for this signature.
* @param doc The document to which this signature will belong.
*/
static of = (
acroSignature: PDFAcroSignature,
ref: PDFRef,
doc: PDFDocument,
) => new PDFSignature(acroSignature, ref, doc);
/** The low-level PDFAcroSignature wrapped by this signature. */
readonly acroField: PDFAcroSignature;
private constructor(
acroSignature: PDFAcroSignature,
ref: PDFRef,
doc: PDFDocument,
) {
super(acroSignature, ref, doc);
assertIs(acroSignature, 'acroSignature', [
[PDFAcroSignature, 'PDFAcroSignature'],
]);
this.acroField = acroSignature;
}
needsAppearancesUpdate() {
return false;
}
}
+826
View File
@@ -0,0 +1,826 @@
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,
} from 'src/api/form/PDFField';
import {
AppearanceProviderFor,
normalizeAppearance,
defaultTextFieldAppearanceProvider,
} from 'src/api/form/appearances';
import { rgb } from 'src/api/colors';
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 {
PDFHexString,
PDFRef,
PDFStream,
PDFAcroText,
AcroTextFlags,
PDFWidgetAnnotation,
} from 'src/core';
import {
assertIs,
assertIsOneOf,
assertOrUndefined,
assertPositive,
assertRangeOrUndefined,
} from 'src/utils';
/**
* Represents a text field of a [[PDFForm]].
*
* [[PDFTextField]] fields are boxes that display text entered by the user. The
* purpose of a text field is to enable users to enter text or view text values
* in the document prefilled by software. Users can click on a text field and
* input text via their keyboard. Some text fields allow multiple lines of text
* to be entered (see [[PDFTextField.isMultiline]]).
*/
export default class PDFTextField extends PDFField {
/**
* > **NOTE:** You probably don't want to call this method directly. Instead,
* > consider using the [[PDFForm.getTextField]] method, which will create an
* > instance of [[PDFTextField]] for you.
*
* Create an instance of [[PDFTextField]] from an existing acroText and ref
*
* @param acroText The underlying `PDFAcroText` for this text field.
* @param ref The unique reference for this text field.
* @param doc The document to which this text field will belong.
*/
static of = (acroText: PDFAcroText, ref: PDFRef, doc: PDFDocument) =>
new PDFTextField(acroText, ref, doc);
/** The low-level PDFAcroText wrapped by this text field. */
readonly acroField: PDFAcroText;
private constructor(acroText: PDFAcroText, ref: PDFRef, doc: PDFDocument) {
super(acroText, ref, doc);
assertIs(acroText, 'acroText', [[PDFAcroText, 'PDFAcroText']]);
this.acroField = acroText;
}
/**
* Get the text that this field contains. This text is visible to users who
* view this field in a PDF reader.
*
* For example:
* ```js
* const textField = form.getTextField('some.text.field')
* const text = textField.getText()
* console.log('Text field contents:', text)
* ```
*
* Note that if this text field contains no underlying value, `undefined`
* will be returned. Text fields may also contain an underlying value that
* is simply an empty string (`''`). This detail is largely irrelevant for
* most applications. In general, you'll want to treat both cases the same
* way and simply consider the text field to be empty. In either case, the
* text field will appear empty to users when viewed in a PDF reader.
*
* An error will be thrown if this is a rich text field. `pdf-lib` does not
* support reading rich text fields. Nor do most PDF readers and writers.
* Rich text fields are based on XFA (XML Forms Architecture). Relatively few
* PDFs use rich text fields or XFA. Unlike PDF itself, XFA is not an ISO
* standard. XFA has been deprecated in PDF 2.0:
* * https://en.wikipedia.org/wiki/XFA
* * http://blog.pdfshareforms.com/pdf-2-0-release-bid-farewell-xfa-forms/
*
* @returns The text contained in this text field.
*/
getText(): string | undefined {
const value = this.acroField.getValue();
if (!value && this.isRichFormatted()) {
throw new RichTextFieldReadError(this.getName());
}
return value?.decodeText();
}
/**
* Set the text for this field. This operation is analogous to a human user
* clicking on the text field in a PDF reader and typing in text via their
* keyboard. This method will update the underlying state of the text field
* to indicate what text has been set. PDF libraries and readers will be able
* to extract these values from the saved document and determine what text
* was set.
*
* For example:
* ```js
* const textField = form.getTextField('best.superhero.text.field')
* textField.setText('One Punch Man')
* ```
*
* This method will mark this text field as dirty, causing its appearance
* streams to be updated when either [[PDFDocument.save]] or
* [[PDFForm.updateFieldAppearances]] is called. The updated streams will
* display the text this field contains inside the widgets of this text
* field.
*
* **IMPORTANT:** The default font used to update appearance streams is
* [[StandardFonts.Helvetica]]. Note that this is a WinAnsi font. This means
* that encoding errors will be thrown if this field contains text outside
* the WinAnsi character set (the latin alphabet).
*
* Embedding a custom font and passing it to
* [[PDFForm.updateFieldAppearances]] or [[PDFTextField.updateAppearances]]
* allows you to generate appearance streams with characters outside the
* latin alphabet (assuming the custom font supports them).
*
* If this is a rich text field, it will be converted to a standard text
* field in order to set the text. `pdf-lib` does not support writing rich
* text strings. Nor do most PDF readers and writers. See
* [[PDFTextField.getText]] for more information about rich text fields and
* their deprecation in PDF 2.0.
*
* @param text The text this field should contain.
*/
setText(text: string | undefined) {
assertOrUndefined(text, 'text', ['string']);
const maxLength = this.getMaxLength();
if (maxLength !== undefined && text && text.length > maxLength) {
throw new ExceededMaxLengthError(text.length, maxLength, this.getName());
}
this.markAsDirty();
this.disableRichFormatting();
if (text) {
this.acroField.setValue(PDFHexString.fromText(text));
} else {
this.acroField.removeValue();
}
}
/**
* Get the alignment for this text field. This value represents the
* justification of the text when it is displayed to the user in PDF readers.
* There are three possible alignments: left, center, and right. For example:
* ```js
* const textField = form.getTextField('some.text.field')
* const alignment = textField.getAlignment()
* if (alignment === TextAlignment.Left) console.log('Text is left justified')
* if (alignment === TextAlignment.Center) console.log('Text is centered')
* if (alignment === TextAlignment.Right) console.log('Text is right justified')
* ```
* @returns The alignment of this text field.
*/
getAlignment(): TextAlignment {
const quadding = this.acroField.getQuadding();
// prettier-ignore
return (
quadding === 0 ? TextAlignment.Left
: quadding === 1 ? TextAlignment.Center
: quadding === 2 ? TextAlignment.Right
: TextAlignment.Left
);
}
/**
* Set the alignment for this text field. This will determine the
* justification of the text when it is displayed to the user in PDF readers.
* There are three possible alignments: left, center, and right. For example:
* ```js
* const textField = form.getTextField('some.text.field')
*
* // Text will be left justified when displayed
* textField.setAlignment(TextAlignment.Left)
*
* // Text will be centered when displayed
* textField.setAlignment(TextAlignment.Center)
*
* // Text will be right justified when displayed
* textField.setAlignment(TextAlignment.Right)
* ```
* This method will mark this text field as dirty. See
* [[PDFTextField.setText]] for more details about what this means.
* @param alignment The alignment for this text field.
*/
setAlignment(alignment: TextAlignment) {
assertIsOneOf(alignment, 'alignment', TextAlignment);
this.markAsDirty();
this.acroField.setQuadding(alignment);
}
/**
* Get the maximum length of this field. This value represents the maximum
* number of characters that can be typed into this field by the user. If
* this field does not have a maximum length, `undefined` is returned.
* For example:
* ```js
* const textField = form.getTextField('some.text.field')
* const maxLength = textField.getMaxLength()
* if (maxLength === undefined) console.log('No max length')
* else console.log(`Max length is ${maxLength}`)
* ```
* @returns The maximum number of characters allowed in this field, or
* `undefined` if no limit exists.
*/
getMaxLength(): number | undefined {
return this.acroField.getMaxLength();
}
/**
* Set the maximum length of this field. This limits the number of characters
* that can be typed into this field by the user. This also limits the length
* of the string that can be passed to [[PDFTextField.setText]]. This limit
* can be removed by passing `undefined` as `maxLength`. For example:
* ```js
* const textField = form.getTextField('some.text.field')
*
* // Allow between 0 and 5 characters to be entered
* textField.setMaxLength(5)
*
* // Allow any number of characters to be entered
* textField.setMaxLength(undefined)
* ```
* This method will mark this text field as dirty. See
* [[PDFTextField.setText]] for more details about what this means.
* @param maxLength The maximum number of characters allowed in this field, or
* `undefined` to remove the limit.
*/
setMaxLength(maxLength?: number) {
assertRangeOrUndefined(maxLength, 'maxLength', 0, Number.MAX_SAFE_INTEGER);
this.markAsDirty();
if (maxLength === undefined) {
this.acroField.removeMaxLength();
} else {
const text = this.getText();
if (text && text.length > maxLength) {
throw new InvalidMaxLengthError(text.length, maxLength, this.getName());
}
this.acroField.setMaxLength(maxLength);
}
}
/**
* Remove the maximum length for this text field. This allows any number of
* characters to be typed into this field by the user. For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.removeMaxLength()
* ```
* Calling this method is equivalent to passing `undefined` to
* [[PDFTextField.setMaxLength]].
*/
removeMaxLength() {
this.markAsDirty();
this.acroField.removeMaxLength();
}
/**
* 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();
// prettier-ignore
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 = this.createImageAppearanceStream(
widget,
image,
alignment,
);
this.updateWidgetAppearances(widget, { normal: streamRef });
}
this.markAsClean();
}
/**
* Set the font size for this field. Larger font sizes will result in larger
* text being displayed when PDF readers render this text field. Font sizes
* may be integer or floating point numbers. Supplying a negative font size
* will cause this method to throw an error.
*
* For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.setFontSize(4)
* textField.setFontSize(15.7)
* ```
*
* > This method depends upon the existence of a default appearance
* > (`/DA`) string. If this field does not have a default appearance string,
* > or that string does not contain a font size (via the `Tf` operator),
* > then this method will throw an error.
*
* @param fontSize The font size to be used when rendering text in this field.
*/
setFontSize(fontSize: number) {
assertPositive(fontSize, 'fontSize');
this.acroField.setFontSize(fontSize);
this.markAsDirty();
}
/**
* Returns `true` if each line of text is shown on a new line when this
* field is displayed in a PDF reader. The alternative is that all lines of
* text are merged onto a single line when displayed. See
* [[PDFTextField.enableMultiline]] and [[PDFTextField.disableMultiline]].
* For example:
* ```js
* const textField = form.getTextField('some.text.field')
* if (textField.isMultiline()) console.log('Multiline is enabled')
* ```
* @returns Whether or not this is a multiline text field.
*/
isMultiline(): boolean {
return this.acroField.hasFlag(AcroTextFlags.Multiline);
}
/**
* Display each line of text on a new line when this field is displayed in a
* PDF reader. For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.enableMultiline()
* ```
* This method will mark this text field as dirty. See
* [[PDFTextField.setText]] for more details about what this means.
*/
enableMultiline() {
this.markAsDirty();
this.acroField.setFlagTo(AcroTextFlags.Multiline, true);
}
/**
* Display each line of text on the same line when this field is displayed
* in a PDF reader. For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.disableMultiline()
* ```
* This method will mark this text field as dirty. See
* [[PDFTextField.setText]] for more details about what this means.
*/
disableMultiline() {
this.markAsDirty();
this.acroField.setFlagTo(AcroTextFlags.Multiline, false);
}
/**
* Returns `true` if this is a password text field. This means that the field
* is intended for storing a secure password. See
* [[PDFTextField.enablePassword]] and [[PDFTextField.disablePassword]].
* For example:
* ```js
* const textField = form.getTextField('some.text.field')
* if (textField.isPassword()) console.log('Password is enabled')
* ```
* @returns Whether or not this is a password text field.
*/
isPassword(): boolean {
return this.acroField.hasFlag(AcroTextFlags.Password);
}
/**
* Indicate that this text field is intended for storing a secure password.
* For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.enablePassword()
* ```
* Values entered into password text fields should not be displayed on the
* screen by PDF readers. Most PDF readers will display the value as
* asterisks or bullets. PDF readers should never store values entered by the
* user into password text fields. Similarly, applications should not
* write data to a password text field.
*
* **Please note that this method does not cause entered values to be
* encrypted or secured in any way! It simply sets a flag that PDF software
* and readers can access to determine the _purpose_ of this field.**
*/
enablePassword() {
this.acroField.setFlagTo(AcroTextFlags.Password, true);
}
/**
* Indicate that this text field is **not** intended for storing a secure
* password. For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.disablePassword()
* ```
*/
disablePassword() {
this.acroField.setFlagTo(AcroTextFlags.Password, false);
}
/**
* Returns `true` if the contents of this text field represent a file path.
* See [[PDFTextField.enableFileSelection]] and
* [[PDFTextField.disableFileSelection]]. For example:
* ```js
* const textField = form.getTextField('some.text.field')
* if (textField.isFileSelector()) console.log('Is a file selector')
* ```
* @returns Whether or not this field should contain file paths.
*/
isFileSelector(): boolean {
return this.acroField.hasFlag(AcroTextFlags.FileSelect);
}
/**
* Indicate that this text field is intended to store a file path. The
* contents of the file stored at that path should be submitted as the value
* of the field. For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.enableFileSelection()
* ```
*/
enableFileSelection() {
this.acroField.setFlagTo(AcroTextFlags.FileSelect, true);
}
/**
* Indicate that this text field is **not** intended to store a file path.
* For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.disableFileSelection()
* ```
*/
disableFileSelection() {
this.acroField.setFlagTo(AcroTextFlags.FileSelect, false);
}
/**
* Returns `true` if the text entered in this field should be spell checked
* by PDF readers. See [[PDFTextField.enableSpellChecking]] and
* [[PDFTextField.disableSpellChecking]]. For example:
* ```js
* const textField = form.getTextField('some.text.field')
* if (textField.isSpellChecked()) console.log('Spell checking is enabled')
* ```
* @returns Whether or not this field should be spell checked.
*/
isSpellChecked(): boolean {
return !this.acroField.hasFlag(AcroTextFlags.DoNotSpellCheck);
}
/**
* Allow PDF readers to spell check the text entered in this field.
* For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.enableSpellChecking()
* ```
*/
enableSpellChecking() {
this.acroField.setFlagTo(AcroTextFlags.DoNotSpellCheck, false);
}
/**
* Do not allow PDF readers to spell check the text entered in this field.
* For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.disableSpellChecking()
* ```
*/
disableSpellChecking() {
this.acroField.setFlagTo(AcroTextFlags.DoNotSpellCheck, true);
}
/**
* Returns `true` if PDF readers should allow the user to scroll the text
* field when its contents do not fit within the field's view bounds. See
* [[PDFTextField.enableScrolling]] and [[PDFTextField.disableScrolling]].
* For example:
* ```js
* const textField = form.getTextField('some.text.field')
* if (textField.isScrollable()) console.log('Scrolling is enabled')
* ```
* @returns Whether or not the field is scrollable in PDF readers.
*/
isScrollable(): boolean {
return !this.acroField.hasFlag(AcroTextFlags.DoNotScroll);
}
/**
* Allow PDF readers to present a scroll bar to the user when the contents
* of this text field do not fit within its view bounds. For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.enableScrolling()
* ```
* A horizontal scroll bar should be shown for singleline fields. A vertical
* scroll bar should be shown for multiline fields.
*/
enableScrolling() {
this.acroField.setFlagTo(AcroTextFlags.DoNotScroll, false);
}
/**
* Do not allow PDF readers to present a scroll bar to the user when the
* contents of this text field do not fit within its view bounds. For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.disableScrolling()
* ```
*/
disableScrolling() {
this.acroField.setFlagTo(AcroTextFlags.DoNotScroll, true);
}
/**
* Returns `true` if this is a combed text field. This means that the field
* is split into `n` equal size cells with one character in each (where `n`
* is equal to the max length of the text field). The result is that all
* characters in this field are displayed an equal distance apart from one
* another. See [[PDFTextField.enableCombing]] and
* [[PDFTextField.disableCombing]]. For example:
* ```js
* const textField = form.getTextField('some.text.field')
* if (textField.isCombed()) console.log('Combing is enabled')
* ```
* Note that in order for a text field to be combed, the following must be
* true (in addition to enabling combing):
* * It must not be a multiline field (see [[PDFTextField.isMultiline]])
* * It must not be a password field (see [[PDFTextField.isPassword]])
* * It must not be a file selector field (see [[PDFTextField.isFileSelector]])
* * It must have a max length defined (see [[PDFTextField.setMaxLength]])
* @returns Whether or not this field is combed.
*/
isCombed(): boolean {
return (
this.acroField.hasFlag(AcroTextFlags.Comb) &&
!this.isMultiline() &&
!this.isPassword() &&
!this.isFileSelector() &&
this.getMaxLength() !== undefined
);
}
/**
* Split this field into `n` equal size cells with one character in each
* (where `n` is equal to the max length of the text field). This will cause
* all characters in the field to be displayed an equal distance apart from
* one another. For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.enableCombing()
* ```
*
* In addition to calling this method, text fields must have a max length
* defined in order to be combed (see [[PDFTextField.setMaxLength]]).
*
* This method will also call the following three methods internally:
* * [[PDFTextField.disableMultiline]]
* * [[PDFTextField.disablePassword]]
* * [[PDFTextField.disableFileSelection]]
*
* This method will mark this text field as dirty. See
* [[PDFTextField.setText]] for more details about what this means.
*/
enableCombing() {
if (this.getMaxLength() === undefined) {
const msg = `PDFTextFields must have a max length in order to be combed`;
console.warn(msg);
}
this.markAsDirty();
this.disableMultiline();
this.disablePassword();
this.disableFileSelection();
this.acroField.setFlagTo(AcroTextFlags.Comb, true);
}
/**
* Turn off combing for this text field. For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.disableCombing()
* ```
* See [[PDFTextField.isCombed]] and [[PDFTextField.enableCombing]] for more
* information about what combing is.
*
* This method will mark this text field as dirty. See
* [[PDFTextField.setText]] for more details about what this means.
*/
disableCombing() {
this.markAsDirty();
this.acroField.setFlagTo(AcroTextFlags.Comb, false);
}
/**
* Returns `true` if this text field contains rich text. See
* [[PDFTextField.enableRichFormatting]] and
* [[PDFTextField.disableRichFormatting]]. For example:
* ```js
* const textField = form.getTextField('some.text.field')
* if (textField.isRichFormatted()) console.log('Rich formatting enabled')
* ```
* @returns Whether or not this field contains rich text.
*/
isRichFormatted(): boolean {
return this.acroField.hasFlag(AcroTextFlags.RichText);
}
/**
* Indicate that this field contains XFA data - or rich text. For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.enableRichFormatting()
* ```
* Note that `pdf-lib` does not support reading or writing rich text fields.
* Nor do most PDF readers and writers. Rich text fields are based on XFA
* (XML Forms Architecture). Relatively few PDFs use rich text fields or XFA.
* Unlike PDF itself, XFA is not an ISO standard. XFA has been deprecated in
* PDF 2.0:
* * https://en.wikipedia.org/wiki/XFA
* * http://blog.pdfshareforms.com/pdf-2-0-release-bid-farewell-xfa-forms/
*/
enableRichFormatting() {
this.acroField.setFlagTo(AcroTextFlags.RichText, true);
}
/**
* Indicate that this is a standard text field that does not XFA data (rich
* text). For example:
* ```js
* const textField = form.getTextField('some.text.field')
* textField.disableRichFormatting()
* ```
*/
disableRichFormatting() {
this.acroField.setFlagTo(AcroTextFlags.RichText, false);
}
/**
* Show this text field on the specified page. For example:
* ```js
* const ubuntuFont = await pdfDoc.embedFont(ubuntuFontBytes)
* const page = pdfDoc.addPage()
*
* const form = pdfDoc.getForm()
* const textField = form.createTextField('best.gundam')
* textField.setText('Exia')
*
* textField.addToPage(page, {
* x: 50,
* y: 75,
* width: 200,
* height: 100,
* textColor: rgb(1, 0, 0),
* backgroundColor: rgb(0, 1, 0),
* borderColor: rgb(0, 0, 1),
* borderWidth: 2,
* rotate: degrees(90),
* font: ubuntuFont,
* })
* ```
* This will create a new widget for this text field.
* @param page The page to which this text field widget should be added.
* @param options The options to be used when adding this text field widget.
*/
addToPage(page: PDFPage, options?: FieldAppearanceOptions) {
assertIs(page, 'page', [[PDFPage, 'PDFPage']]);
assertFieldAppearanceOptions(options);
if (!options) options = {};
if (!('textColor' in options)) options.textColor = rgb(0, 0, 0);
if (!('backgroundColor' in options)) options.backgroundColor = rgb(1, 1, 1);
if (!('borderColor' in options)) options.borderColor = rgb(0, 0, 0);
if (!('borderWidth' in options)) options.borderWidth = 1;
// Create a widget for this text field
const widget = this.createWidget({
x: options.x ?? 0,
y: options.y ?? 0,
width: options.width ?? 200,
height: options.height ?? 50,
textColor: options.textColor,
backgroundColor: options.backgroundColor,
borderColor: options.borderColor,
borderWidth: options.borderWidth ?? 0,
rotate: options.rotate ?? degrees(0),
hidden: options.hidden,
page: page.ref,
});
const widgetRef = this.doc.context.register(widget.dict);
// Add widget to this field
this.acroField.addWidget(widgetRef);
// Set appearance streams for widget
const font = options.font ?? this.doc.getForm().getDefaultFont();
this.updateWidgetAppearance(widget, font);
// Add widget to the given page
page.node.addAnnot(widgetRef);
}
/**
* 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:
* ```js
* const textField = form.getTextField('some.text.field')
* if (textField.needsAppearancesUpdate()) console.log('Needs update')
* ```
* @returns Whether or not this text field needs an appearance update.
*/
needsAppearancesUpdate(): boolean {
if (this.isDirty()) return true;
const widgets = this.acroField.getWidgets();
for (let idx = 0, len = widgets.length; idx < len; idx++) {
const widget = widgets[idx];
const hasAppearances =
widget.getAppearances()?.normal instanceof PDFStream;
if (!hasAppearances) return true;
}
return false;
}
/**
* Update the appearance streams for each of this text field's widgets using
* the default appearance provider for text fields. For example:
* ```js
* const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica)
* const textField = form.getTextField('some.text.field')
* textField.defaultUpdateAppearances(helvetica)
* ```
* @param font The font to be used for creating the appearance streams.
*/
defaultUpdateAppearances(font: PDFFont) {
assertIs(font, 'font', [[PDFFont, 'PDFFont']]);
this.updateAppearances(font);
}
/**
* Update the appearance streams for each of this text field's widgets using
* the given appearance provider. If no `provider` is passed, the default
* appearance provider for text fields will be used. For example:
* ```js
* const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica)
* const textField = form.getTextField('some.text.field')
* textField.updateAppearances(helvetica, (field, widget, font) => {
* ...
* return drawTextField(...)
* })
* ```
* @param font The font to be used for creating the appearance streams.
* @param provider Optionally, the appearance provider to be used for
* generating the contents of the appearance streams.
*/
updateAppearances(
font: PDFFont,
provider?: AppearanceProviderFor<PDFTextField>,
) {
assertIs(font, 'font', [[PDFFont, 'PDFFont']]);
assertOrUndefined(provider, 'provider', [Function]);
const widgets = this.acroField.getWidgets();
for (let idx = 0, len = widgets.length; idx < len; idx++) {
const widget = widgets[idx];
this.updateWidgetAppearance(widget, font, provider);
}
this.markAsClean();
}
private updateWidgetAppearance(
widget: PDFWidgetAnnotation,
font: PDFFont,
provider?: AppearanceProviderFor<PDFTextField>,
) {
const apProvider = provider ?? defaultTextFieldAppearanceProvider;
const appearances = normalizeAppearance(apProvider(this, widget, font));
this.updateWidgetAppearanceWithFont(widget, font, appearances);
}
}
+665
View File
@@ -0,0 +1,665 @@
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,
}),
];
};
+10
View File
@@ -0,0 +1,10 @@
export * from 'src/api/form/appearances';
export { default as PDFButton } from 'src/api/form/PDFButton';
export { default as PDFCheckBox } from 'src/api/form/PDFCheckBox';
export { default as PDFDropdown } from 'src/api/form/PDFDropdown';
export { default as PDFField } from 'src/api/form/PDFField';
export { default as PDFForm } from 'src/api/form/PDFForm';
export { default as PDFOptionList } from 'src/api/form/PDFOptionList';
export { default as PDFRadioGroup } from 'src/api/form/PDFRadioGroup';
export { default as PDFSignature } from 'src/api/form/PDFSignature';
export { default as PDFTextField } from 'src/api/form/PDFTextField';
+5
View File
@@ -0,0 +1,5 @@
export enum ImageAlignment {
Left = 0,
Center = 1,
Right = 2,
}
+1
View File
@@ -0,0 +1 @@
export * from 'src/api/image/alignment';
+20
View File
@@ -0,0 +1,20 @@
export * from 'src/api/form';
export * from 'src/api/text';
export * from 'src/api/colors';
export * from 'src/api/errors';
export * from 'src/api/image';
export * from 'src/api/objects';
export * from 'src/api/operations';
export * from 'src/api/operators';
export * from 'src/api/rotations';
export * from 'src/api/sizes';
export * from 'src/api/PDFPageOptions';
export * from 'src/api/PDFDocumentOptions';
export * from 'src/api/StandardFonts';
export { default as PDFDocument } from 'src/api/PDFDocument';
export { default as PDFFont } from 'src/api/PDFFont';
export { default as PDFImage } from 'src/api/PDFImage';
export { default as PDFPage } from 'src/api/PDFPage';
export { default as PDFEmbeddedPage } from 'src/api/PDFEmbeddedPage';
export { default as PDFJavaScript } from 'src/api/PDFJavaScript';
export { default as Embeddable } from 'src/api/Embeddable';
+10
View File
@@ -0,0 +1,10 @@
import { PDFName, PDFNumber } from 'src/core';
export const asPDFName = (name: string | PDFName) =>
name instanceof PDFName ? name : PDFName.of(name);
export const asPDFNumber = (num: number | PDFNumber) =>
num instanceof PDFNumber ? num : PDFNumber.of(num);
export const asNumber = (num: number | PDFNumber) =>
num instanceof PDFNumber ? num.asNumber() : num;
+800
View File
@@ -0,0 +1,800 @@
import { Color, setFillingColor, setStrokingColor } from 'src/api/colors';
import {
beginText,
closePath,
drawObject,
endText,
fill,
fillAndStroke,
lineTo,
moveTo,
nextLine,
popGraphicsState,
pushGraphicsState,
rotateAndSkewTextRadiansAndTranslate,
rotateRadians,
scale,
setFontAndSize,
setLineHeight,
setLineWidth,
showText,
skewRadians,
stroke,
translate,
LineCapStyle,
setLineCap,
rotateDegrees,
setGraphicsState,
setDashPattern,
beginMarkedContent,
endMarkedContent,
clip,
endPath,
appendBezierCurve,
} from 'src/api/operators';
import { Rotation, degrees, toRadians } from 'src/api/rotations';
import { svgPathToOperators } from 'src/api/svgPath';
import { PDFHexString, PDFName, PDFNumber, PDFOperator } from 'src/core';
import { asNumber } from 'src/api/objects';
export interface DrawTextOptions {
color: Color;
font: string | PDFName;
size: number | PDFNumber;
rotate: Rotation;
xSkew: Rotation;
ySkew: Rotation;
x: number | PDFNumber;
y: number | PDFNumber;
graphicsState?: string | PDFName;
}
export const drawText = (
line: PDFHexString,
options: DrawTextOptions,
): PDFOperator[] =>
[
pushGraphicsState(),
options.graphicsState && setGraphicsState(options.graphicsState),
beginText(),
setFillingColor(options.color),
setFontAndSize(options.font, options.size),
rotateAndSkewTextRadiansAndTranslate(
toRadians(options.rotate),
toRadians(options.xSkew),
toRadians(options.ySkew),
options.x,
options.y,
),
showText(line),
endText(),
popGraphicsState(),
].filter(Boolean) as PDFOperator[];
export interface DrawLinesOfTextOptions extends DrawTextOptions {
lineHeight: number | PDFNumber;
}
export const drawLinesOfText = (
lines: PDFHexString[],
options: DrawLinesOfTextOptions,
): PDFOperator[] => {
const operators = [
pushGraphicsState(),
options.graphicsState && setGraphicsState(options.graphicsState),
beginText(),
setFillingColor(options.color),
setFontAndSize(options.font, options.size),
setLineHeight(options.lineHeight),
rotateAndSkewTextRadiansAndTranslate(
toRadians(options.rotate),
toRadians(options.xSkew),
toRadians(options.ySkew),
options.x,
options.y,
),
].filter(Boolean) as PDFOperator[];
for (let idx = 0, len = lines.length; idx < len; idx++) {
operators.push(showText(lines[idx]), nextLine());
}
operators.push(endText(), popGraphicsState());
return operators;
};
export const drawImage = (
name: string | PDFName,
options: {
x: number | PDFNumber;
y: number | PDFNumber;
width: number | PDFNumber;
height: number | PDFNumber;
rotate: Rotation;
xSkew: Rotation;
ySkew: Rotation;
graphicsState?: string | PDFName;
},
): PDFOperator[] =>
[
pushGraphicsState(),
options.graphicsState && setGraphicsState(options.graphicsState),
translate(options.x, options.y),
rotateRadians(toRadians(options.rotate)),
scale(options.width, options.height),
skewRadians(toRadians(options.xSkew), toRadians(options.ySkew)),
drawObject(name),
popGraphicsState(),
].filter(Boolean) as PDFOperator[];
export const drawPage = (
name: string | PDFName,
options: {
x: number | PDFNumber;
y: number | PDFNumber;
xScale: number | PDFNumber;
yScale: number | PDFNumber;
rotate: Rotation;
xSkew: Rotation;
ySkew: Rotation;
graphicsState?: string | PDFName;
},
): PDFOperator[] =>
[
pushGraphicsState(),
options.graphicsState && setGraphicsState(options.graphicsState),
translate(options.x, options.y),
rotateRadians(toRadians(options.rotate)),
scale(options.xScale, options.yScale),
skewRadians(toRadians(options.xSkew), toRadians(options.ySkew)),
drawObject(name),
popGraphicsState(),
].filter(Boolean) as PDFOperator[];
export const drawLine = (options: {
start: { x: number | PDFNumber; y: number | PDFNumber };
end: { x: number | PDFNumber; y: number | PDFNumber };
thickness: number | PDFNumber;
color: Color | undefined;
dashArray?: (number | PDFNumber)[];
dashPhase?: number | PDFNumber;
lineCap?: LineCapStyle;
graphicsState?: string | PDFName;
}) =>
[
pushGraphicsState(),
options.graphicsState && setGraphicsState(options.graphicsState),
options.color && setStrokingColor(options.color),
setLineWidth(options.thickness),
setDashPattern(options.dashArray ?? [], options.dashPhase ?? 0),
moveTo(options.start.x, options.start.y),
options.lineCap && setLineCap(options.lineCap),
moveTo(options.start.x, options.start.y),
lineTo(options.end.x, options.end.y),
stroke(),
popGraphicsState(),
].filter(Boolean) as PDFOperator[];
export const drawRectangle = (options: {
x: number | PDFNumber;
y: number | PDFNumber;
width: number | PDFNumber;
height: number | PDFNumber;
borderWidth: number | PDFNumber;
color: Color | undefined;
borderColor: Color | undefined;
rotate: Rotation;
xSkew: Rotation;
ySkew: Rotation;
borderLineCap?: LineCapStyle;
borderDashArray?: (number | PDFNumber)[];
borderDashPhase?: number | PDFNumber;
graphicsState?: string | PDFName;
}) =>
[
pushGraphicsState(),
options.graphicsState && setGraphicsState(options.graphicsState),
options.color && setFillingColor(options.color),
options.borderColor && setStrokingColor(options.borderColor),
setLineWidth(options.borderWidth),
options.borderLineCap && setLineCap(options.borderLineCap),
setDashPattern(options.borderDashArray ?? [], options.borderDashPhase ?? 0),
translate(options.x, options.y),
rotateRadians(toRadians(options.rotate)),
skewRadians(toRadians(options.xSkew), toRadians(options.ySkew)),
moveTo(0, 0),
lineTo(0, options.height),
lineTo(options.width, options.height),
lineTo(options.width, 0),
closePath(),
// prettier-ignore
options.color && options.borderWidth ? fillAndStroke()
: options.color ? fill()
: options.borderColor ? stroke()
: closePath(),
popGraphicsState(),
].filter(Boolean) as PDFOperator[];
const KAPPA = 4.0 * ((Math.sqrt(2) - 1.0) / 3.0);
/** @deprecated */
export const drawEllipsePath = (config: {
x: number | PDFNumber;
y: number | PDFNumber;
xScale: number | PDFNumber;
yScale: number | PDFNumber;
}): PDFOperator[] => {
let x = asNumber(config.x);
let y = asNumber(config.y);
const xScale = asNumber(config.xScale);
const yScale = asNumber(config.yScale);
x -= xScale;
y -= yScale;
const ox = xScale * KAPPA;
const oy = yScale * KAPPA;
const xe = x + xScale * 2;
const ye = y + yScale * 2;
const xm = x + xScale;
const ym = y + yScale;
return [
pushGraphicsState(),
moveTo(x, ym),
appendBezierCurve(x, ym - oy, xm - ox, y, xm, y),
appendBezierCurve(xm + ox, y, xe, ym - oy, xe, ym),
appendBezierCurve(xe, ym + oy, xm + ox, ye, xm, ye),
appendBezierCurve(xm - ox, ye, x, ym + oy, x, ym),
popGraphicsState(),
];
};
const drawEllipseCurves = (config: {
x: number | PDFNumber;
y: number | PDFNumber;
xScale: number | PDFNumber;
yScale: number | PDFNumber;
rotate: Rotation;
}): PDFOperator[] => {
const centerX = asNumber(config.x);
const centerY = asNumber(config.y);
const xScale = asNumber(config.xScale);
const yScale = asNumber(config.yScale);
const x = -xScale;
const y = -yScale;
const ox = xScale * KAPPA;
const oy = yScale * KAPPA;
const xe = x + xScale * 2;
const ye = y + yScale * 2;
const xm = x + xScale;
const ym = y + yScale;
return [
translate(centerX, centerY),
rotateRadians(toRadians(config.rotate)),
moveTo(x, ym),
appendBezierCurve(x, ym - oy, xm - ox, y, xm, y),
appendBezierCurve(xm + ox, y, xe, ym - oy, xe, ym),
appendBezierCurve(xe, ym + oy, xm + ox, ye, xm, ye),
appendBezierCurve(xm - ox, ye, x, ym + oy, x, ym),
];
};
export const drawEllipse = (options: {
x: number | PDFNumber;
y: number | PDFNumber;
xScale: number | PDFNumber;
yScale: number | PDFNumber;
rotate?: Rotation;
color: Color | undefined;
borderColor: Color | undefined;
borderWidth: number | PDFNumber;
borderDashArray?: (number | PDFNumber)[];
borderDashPhase?: number | PDFNumber;
graphicsState?: string | PDFName;
borderLineCap?: LineCapStyle;
}) =>
[
pushGraphicsState(),
options.graphicsState && setGraphicsState(options.graphicsState),
options.color && setFillingColor(options.color),
options.borderColor && setStrokingColor(options.borderColor),
setLineWidth(options.borderWidth),
options.borderLineCap && setLineCap(options.borderLineCap),
setDashPattern(options.borderDashArray ?? [], options.borderDashPhase ?? 0),
// The `drawEllipsePath` branch is only here for backwards compatibility.
// See https://github.com/Hopding/pdf-lib/pull/511#issuecomment-667685655.
...(options.rotate === undefined
? drawEllipsePath({
x: options.x,
y: options.y,
xScale: options.xScale,
yScale: options.yScale,
})
: drawEllipseCurves({
x: options.x,
y: options.y,
xScale: options.xScale,
yScale: options.yScale,
rotate: options.rotate ?? degrees(0),
})),
// prettier-ignore
options.color && options.borderWidth ? fillAndStroke()
: options.color ? fill()
: options.borderColor ? stroke()
: closePath(),
popGraphicsState(),
].filter(Boolean) as PDFOperator[];
export const drawSvgPath = (
path: string,
options: {
x: number | PDFNumber;
y: number | PDFNumber;
rotate?: Rotation;
scale: number | PDFNumber | undefined;
color: Color | undefined;
borderColor: Color | undefined;
borderWidth: number | PDFNumber;
borderDashArray?: (number | PDFNumber)[];
borderDashPhase?: number | PDFNumber;
borderLineCap?: LineCapStyle;
graphicsState?: string | PDFName;
},
) =>
[
pushGraphicsState(),
options.graphicsState && setGraphicsState(options.graphicsState),
translate(options.x, options.y),
rotateRadians(toRadians(options.rotate ?? degrees(0))),
// SVG path Y axis is opposite pdf-lib's
options.scale ? scale(options.scale, -options.scale) : scale(1, -1),
options.color && setFillingColor(options.color),
options.borderColor && setStrokingColor(options.borderColor),
options.borderWidth && setLineWidth(options.borderWidth),
options.borderLineCap && setLineCap(options.borderLineCap),
setDashPattern(options.borderDashArray ?? [], options.borderDashPhase ?? 0),
...svgPathToOperators(path),
// prettier-ignore
options.color && options.borderWidth ? fillAndStroke()
: options.color ? fill()
: options.borderColor ? stroke()
: closePath(),
popGraphicsState(),
].filter(Boolean) as PDFOperator[];
export const drawCheckMark = (options: {
x: number | PDFNumber;
y: number | PDFNumber;
size: number | PDFNumber;
thickness: number | PDFNumber;
color: Color | undefined;
}) => {
const size = asNumber(options.size);
/*********************** Define Check Mark Points ***************************/
// A check mark is defined by three points in some coordinate space. Here, we
// define these points in a unit coordinate system, where the range of the x
// and y axis are both [-1, 1].
//
// Note that we do not hard code `p1y` in case we wish to change the
// size/shape of the check mark in the future. We want the check mark to
// always form a right angle. This means that the dot product between (p1-p2)
// and (p3-p2) should be zero:
//
// (p1x-p2x) * (p3x-p2x) + (p1y-p2y) * (p3y-p2y) = 0
//
// We can now rejigger this equation to solve for `p1y`:
//
// (p1y-p2y) * (p3y-p2y) = -((p1x-p2x) * (p3x-p2x))
// (p1y-p2y) = -((p1x-p2x) * (p3x-p2x)) / (p3y-p2y)
// p1y = -((p1x-p2x) * (p3x-p2x)) / (p3y-p2y) + p2y
//
// Thanks to my friend Joel Walker (https://github.com/JWalker1995) for
// devising the above equation and unit coordinate system approach!
// (x, y) coords of the check mark's bottommost point
const p2x = -1 + 0.75;
const p2y = -1 + 0.51;
// (x, y) coords of the check mark's topmost point
const p3y = 1 - 0.525;
const p3x = 1 - 0.31;
// (x, y) coords of the check mark's center (vertically) point
const p1x = -1 + 0.325;
const p1y = -((p1x - p2x) * (p3x - p2x)) / (p3y - p2y) + p2y;
/****************************************************************************/
return [
pushGraphicsState(),
options.color && setStrokingColor(options.color),
setLineWidth(options.thickness),
translate(options.x, options.y),
moveTo(p1x * size, p1y * size),
lineTo(p2x * size, p2y * size),
lineTo(p3x * size, p3y * size),
stroke(),
popGraphicsState(),
].filter(Boolean) as PDFOperator[];
};
// prettier-ignore
export const rotateInPlace = (options: {
width: number | PDFNumber;
height: number | PDFNumber;
rotation: 0 | 90 | 180 | 270;
}) =>
options.rotation === 0 ? [
translate(0, 0),
rotateDegrees(0)
]
: options.rotation === 90 ? [
translate(options.width, 0),
rotateDegrees(90)
]
: options.rotation === 180 ? [
translate(options.width, options.height),
rotateDegrees(180)
]
: options.rotation === 270 ? [
translate(0, options.height),
rotateDegrees(270)
]
: []; // Invalid rotation - noop
export const drawCheckBox = (options: {
x: number | PDFNumber;
y: number | PDFNumber;
width: number | PDFNumber;
height: number | PDFNumber;
thickness: number | PDFNumber;
borderWidth: number | PDFNumber;
markColor: Color | undefined;
color: Color | undefined;
borderColor: Color | undefined;
filled: boolean;
}) => {
const outline = drawRectangle({
x: options.x,
y: options.y,
width: options.width,
height: options.height,
borderWidth: options.borderWidth,
color: options.color,
borderColor: options.borderColor,
rotate: degrees(0),
xSkew: degrees(0),
ySkew: degrees(0),
});
if (!options.filled) return outline;
const width = asNumber(options.width);
const height = asNumber(options.height);
const checkMarkSize = Math.min(width, height) / 2;
const checkMark = drawCheckMark({
x: width / 2,
y: height / 2,
size: checkMarkSize,
thickness: options.thickness,
color: options.markColor,
});
return [pushGraphicsState(), ...outline, ...checkMark, popGraphicsState()];
};
export const drawRadioButton = (options: {
x: number | PDFNumber;
y: number | PDFNumber;
width: number | PDFNumber;
height: number | PDFNumber;
borderWidth: number | PDFNumber;
dotColor: Color | undefined;
color: Color | undefined;
borderColor: Color | undefined;
filled: boolean;
}) => {
const width = asNumber(options.width);
const height = asNumber(options.height);
const outlineScale = Math.min(width, height) / 2;
const outline = drawEllipse({
x: options.x,
y: options.y,
xScale: outlineScale,
yScale: outlineScale,
color: options.color,
borderColor: options.borderColor,
borderWidth: options.borderWidth,
});
if (!options.filled) return outline;
const dot = drawEllipse({
x: options.x,
y: options.y,
xScale: outlineScale * 0.45,
yScale: outlineScale * 0.45,
color: options.dotColor,
borderColor: undefined,
borderWidth: 0,
});
return [pushGraphicsState(), ...outline, ...dot, popGraphicsState()];
};
export const drawButton = (options: {
x: number | PDFNumber;
y: number | PDFNumber;
width: number | PDFNumber;
height: number | PDFNumber;
borderWidth: number | PDFNumber;
color: Color | undefined;
borderColor: Color | undefined;
textLines: { encoded: PDFHexString; x: number; y: number }[];
textColor: Color;
font: string | PDFName;
fontSize: number | PDFNumber;
}) => {
const x = asNumber(options.x);
const y = asNumber(options.y);
const width = asNumber(options.width);
const height = asNumber(options.height);
const background = drawRectangle({
x,
y,
width,
height,
borderWidth: options.borderWidth,
color: options.color,
borderColor: options.borderColor,
rotate: degrees(0),
xSkew: degrees(0),
ySkew: degrees(0),
});
const lines = drawTextLines(options.textLines, {
color: options.textColor,
font: options.font,
size: options.fontSize,
rotate: degrees(0),
xSkew: degrees(0),
ySkew: degrees(0),
});
return [pushGraphicsState(), ...background, ...lines, popGraphicsState()];
};
export interface DrawTextLinesOptions {
color: Color;
font: string | PDFName;
size: number | PDFNumber;
rotate: Rotation;
xSkew: Rotation;
ySkew: Rotation;
}
export const drawTextLines = (
lines: { encoded: PDFHexString; x: number; y: number }[],
options: DrawTextLinesOptions,
): PDFOperator[] => {
const operators = [
beginText(),
setFillingColor(options.color),
setFontAndSize(options.font, options.size),
];
for (let idx = 0, len = lines.length; idx < len; idx++) {
const { encoded, x, y } = lines[idx];
operators.push(
rotateAndSkewTextRadiansAndTranslate(
toRadians(options.rotate),
toRadians(options.xSkew),
toRadians(options.ySkew),
x,
y,
),
showText(encoded),
);
}
operators.push(endText());
return operators;
};
export const drawTextField = (options: {
x: number | PDFNumber;
y: number | PDFNumber;
width: number | PDFNumber;
height: number | PDFNumber;
borderWidth: number | PDFNumber;
color: Color | undefined;
borderColor: Color | undefined;
textLines: { encoded: PDFHexString; x: number; y: number }[];
textColor: Color;
font: string | PDFName;
fontSize: number | PDFNumber;
padding: number | PDFNumber;
}) => {
const x = asNumber(options.x);
const y = asNumber(options.y);
const width = asNumber(options.width);
const height = asNumber(options.height);
const borderWidth = asNumber(options.borderWidth);
const padding = asNumber(options.padding);
const clipX = x + borderWidth / 2 + padding;
const clipY = y + borderWidth / 2 + padding;
const clipWidth = width - (borderWidth / 2 + padding) * 2;
const clipHeight = height - (borderWidth / 2 + padding) * 2;
const clippingArea = [
moveTo(clipX, clipY),
lineTo(clipX, clipY + clipHeight),
lineTo(clipX + clipWidth, clipY + clipHeight),
lineTo(clipX + clipWidth, clipY),
closePath(),
clip(),
endPath(),
];
const background = drawRectangle({
x,
y,
width,
height,
borderWidth: options.borderWidth,
color: options.color,
borderColor: options.borderColor,
rotate: degrees(0),
xSkew: degrees(0),
ySkew: degrees(0),
});
const lines = drawTextLines(options.textLines, {
color: options.textColor,
font: options.font,
size: options.fontSize,
rotate: degrees(0),
xSkew: degrees(0),
ySkew: degrees(0),
});
const markedContent = [
beginMarkedContent('Tx'),
pushGraphicsState(),
...lines,
popGraphicsState(),
endMarkedContent(),
];
return [
pushGraphicsState(),
...background,
...clippingArea,
...markedContent,
popGraphicsState(),
];
};
export const drawOptionList = (options: {
x: number | PDFNumber;
y: number | PDFNumber;
width: number | PDFNumber;
height: number | PDFNumber;
borderWidth: number | PDFNumber;
color: Color | undefined;
borderColor: Color | undefined;
textLines: { encoded: PDFHexString; x: number; y: number; height: number }[];
textColor: Color;
font: string | PDFName;
fontSize: number | PDFNumber;
lineHeight: number | PDFNumber;
selectedLines: number[];
selectedColor: Color;
padding: number | PDFNumber;
}) => {
const x = asNumber(options.x);
const y = asNumber(options.y);
const width = asNumber(options.width);
const height = asNumber(options.height);
const lineHeight = asNumber(options.lineHeight);
const borderWidth = asNumber(options.borderWidth);
const padding = asNumber(options.padding);
const clipX = x + borderWidth / 2 + padding;
const clipY = y + borderWidth / 2 + padding;
const clipWidth = width - (borderWidth / 2 + padding) * 2;
const clipHeight = height - (borderWidth / 2 + padding) * 2;
const clippingArea = [
moveTo(clipX, clipY),
lineTo(clipX, clipY + clipHeight),
lineTo(clipX + clipWidth, clipY + clipHeight),
lineTo(clipX + clipWidth, clipY),
closePath(),
clip(),
endPath(),
];
const background = drawRectangle({
x,
y,
width,
height,
borderWidth: options.borderWidth,
color: options.color,
borderColor: options.borderColor,
rotate: degrees(0),
xSkew: degrees(0),
ySkew: degrees(0),
});
const highlights: PDFOperator[] = [];
for (let idx = 0, len = options.selectedLines.length; idx < len; idx++) {
const line = options.textLines[options.selectedLines[idx]];
highlights.push(
...drawRectangle({
x: line.x - padding,
y: line.y - (lineHeight - line.height) / 2,
width: width - borderWidth,
height: line.height + (lineHeight - line.height) / 2,
borderWidth: 0,
color: options.selectedColor,
borderColor: undefined,
rotate: degrees(0),
xSkew: degrees(0),
ySkew: degrees(0),
}),
);
}
const lines = drawTextLines(options.textLines, {
color: options.textColor,
font: options.font,
size: options.fontSize,
rotate: degrees(0),
xSkew: degrees(0),
ySkew: degrees(0),
});
const markedContent = [
beginMarkedContent('Tx'),
pushGraphicsState(),
...lines,
popGraphicsState(),
endMarkedContent(),
];
return [
pushGraphicsState(),
...background,
...highlights,
...clippingArea,
...markedContent,
popGraphicsState(),
];
};
+360
View File
@@ -0,0 +1,360 @@
import { asNumber, asPDFName, asPDFNumber } from 'src/api/objects';
import { degreesToRadians } from 'src/api/rotations';
import {
PDFHexString,
PDFName,
PDFNumber,
PDFOperator,
PDFOperatorNames as Ops,
} from 'src/core';
/* ==================== Clipping Path Operators ==================== */
export const clip = () => PDFOperator.of(Ops.ClipNonZero);
export const clipEvenOdd = () => PDFOperator.of(Ops.ClipEvenOdd);
/* ==================== Graphics State Operators ==================== */
const { cos, sin, tan } = Math;
export const concatTransformationMatrix = (
a: number | PDFNumber,
b: number | PDFNumber,
c: number | PDFNumber,
d: number | PDFNumber,
e: number | PDFNumber,
f: number | PDFNumber,
) =>
PDFOperator.of(Ops.ConcatTransformationMatrix, [
asPDFNumber(a),
asPDFNumber(b),
asPDFNumber(c),
asPDFNumber(d),
asPDFNumber(e),
asPDFNumber(f),
]);
export const translate = (xPos: number | PDFNumber, yPos: number | PDFNumber) =>
concatTransformationMatrix(1, 0, 0, 1, xPos, yPos);
export const scale = (xPos: number | PDFNumber, yPos: number | PDFNumber) =>
concatTransformationMatrix(xPos, 0, 0, yPos, 0, 0);
export const rotateRadians = (angle: number | PDFNumber) =>
concatTransformationMatrix(
cos(asNumber(angle)),
sin(asNumber(angle)),
-sin(asNumber(angle)),
cos(asNumber(angle)),
0,
0,
);
export const rotateDegrees = (angle: number | PDFNumber) =>
rotateRadians(degreesToRadians(asNumber(angle)));
export const skewRadians = (
xSkewAngle: number | PDFNumber,
ySkewAngle: number | PDFNumber,
) =>
concatTransformationMatrix(
1,
tan(asNumber(xSkewAngle)),
tan(asNumber(ySkewAngle)),
1,
0,
0,
);
export const skewDegrees = (
xSkewAngle: number | PDFNumber,
ySkewAngle: number | PDFNumber,
) =>
skewRadians(
degreesToRadians(asNumber(xSkewAngle)),
degreesToRadians(asNumber(ySkewAngle)),
);
export const setDashPattern = (
dashArray: (number | PDFNumber)[],
dashPhase: number | PDFNumber,
) =>
PDFOperator.of(Ops.SetLineDashPattern, [
`[${dashArray.map(asPDFNumber).join(' ')}]`,
asPDFNumber(dashPhase),
]);
export const restoreDashPattern = () => setDashPattern([], 0);
export enum LineCapStyle {
Butt = 0,
Round = 1,
Projecting = 2,
}
export const setLineCap = (style: LineCapStyle) =>
PDFOperator.of(Ops.SetLineCapStyle, [asPDFNumber(style)]);
export enum LineJoinStyle {
Miter = 0,
Round = 1,
Bevel = 2,
}
export const setLineJoin = (style: LineJoinStyle) =>
PDFOperator.of(Ops.SetLineJoinStyle, [asPDFNumber(style)]);
export const setGraphicsState = (state: string | PDFName) =>
PDFOperator.of(Ops.SetGraphicsStateParams, [asPDFName(state)]);
export const pushGraphicsState = () => PDFOperator.of(Ops.PushGraphicsState);
export const popGraphicsState = () => PDFOperator.of(Ops.PopGraphicsState);
export const setLineWidth = (width: number | PDFNumber) =>
PDFOperator.of(Ops.SetLineWidth, [asPDFNumber(width)]);
/* ==================== Path Construction Operators ==================== */
export const appendBezierCurve = (
x1: number | PDFNumber,
y1: number | PDFNumber,
x2: number | PDFNumber,
y2: number | PDFNumber,
x3: number | PDFNumber,
y3: number | PDFNumber,
) =>
PDFOperator.of(Ops.AppendBezierCurve, [
asPDFNumber(x1),
asPDFNumber(y1),
asPDFNumber(x2),
asPDFNumber(y2),
asPDFNumber(x3),
asPDFNumber(y3),
]);
export const appendQuadraticCurve = (
x1: number | PDFNumber,
y1: number | PDFNumber,
x2: number | PDFNumber,
y2: number | PDFNumber,
) =>
PDFOperator.of(Ops.CurveToReplicateInitialPoint, [
asPDFNumber(x1),
asPDFNumber(y1),
asPDFNumber(x2),
asPDFNumber(y2),
]);
export const closePath = () => PDFOperator.of(Ops.ClosePath);
export const moveTo = (xPos: number | PDFNumber, yPos: number | PDFNumber) =>
PDFOperator.of(Ops.MoveTo, [asPDFNumber(xPos), asPDFNumber(yPos)]);
export const lineTo = (xPos: number | PDFNumber, yPos: number | PDFNumber) =>
PDFOperator.of(Ops.LineTo, [asPDFNumber(xPos), asPDFNumber(yPos)]);
/**
* @param xPos x coordinate for the lower left corner of the rectangle
* @param yPos y coordinate for the lower left corner of the rectangle
* @param width width of the rectangle
* @param height height of the rectangle
*/
export const rectangle = (
xPos: number | PDFNumber,
yPos: number | PDFNumber,
width: number | PDFNumber,
height: number | PDFNumber,
) =>
PDFOperator.of(Ops.AppendRectangle, [
asPDFNumber(xPos),
asPDFNumber(yPos),
asPDFNumber(width),
asPDFNumber(height),
]);
/**
* @param xPos x coordinate for the lower left corner of the square
* @param yPos y coordinate for the lower left corner of the square
* @param size width and height of the square
*/
export const square = (xPos: number, yPos: number, size: number) =>
rectangle(xPos, yPos, size, size);
/* ==================== Path Painting Operators ==================== */
export const stroke = () => PDFOperator.of(Ops.StrokePath);
export const fill = () => PDFOperator.of(Ops.FillNonZero);
export const fillAndStroke = () => PDFOperator.of(Ops.FillNonZeroAndStroke);
export const endPath = () => PDFOperator.of(Ops.EndPath);
/* ==================== Text Positioning Operators ==================== */
export const nextLine = () => PDFOperator.of(Ops.NextLine);
export const moveText = (x: number | PDFNumber, y: number | PDFNumber) =>
PDFOperator.of(Ops.MoveText, [asPDFNumber(x), asPDFNumber(y)]);
/* ==================== Text Showing Operators ==================== */
export const showText = (text: PDFHexString) =>
PDFOperator.of(Ops.ShowText, [text]);
/* ==================== Text State Operators ==================== */
export const beginText = () => PDFOperator.of(Ops.BeginText);
export const endText = () => PDFOperator.of(Ops.EndText);
export const setFontAndSize = (
name: string | PDFName,
size: number | PDFNumber,
) => PDFOperator.of(Ops.SetFontAndSize, [asPDFName(name), asPDFNumber(size)]);
export const setCharacterSpacing = (spacing: number | PDFNumber) =>
PDFOperator.of(Ops.SetCharacterSpacing, [asPDFNumber(spacing)]);
export const setWordSpacing = (spacing: number | PDFNumber) =>
PDFOperator.of(Ops.SetWordSpacing, [asPDFNumber(spacing)]);
/** @param squeeze horizontal character spacing */
export const setCharacterSqueeze = (squeeze: number | PDFNumber) =>
PDFOperator.of(Ops.SetTextHorizontalScaling, [asPDFNumber(squeeze)]);
export const setLineHeight = (lineHeight: number | PDFNumber) =>
PDFOperator.of(Ops.SetTextLineHeight, [asPDFNumber(lineHeight)]);
export const setTextRise = (rise: number | PDFNumber) =>
PDFOperator.of(Ops.SetTextRise, [asPDFNumber(rise)]);
export enum TextRenderingMode {
Fill = 0,
Outline = 1,
FillAndOutline = 2,
Invisible = 3,
FillAndClip = 4,
OutlineAndClip = 5,
FillAndOutlineAndClip = 6,
Clip = 7,
}
export const setTextRenderingMode = (mode: TextRenderingMode) =>
PDFOperator.of(Ops.SetTextRenderingMode, [asPDFNumber(mode)]);
export const setTextMatrix = (
a: number | PDFNumber,
b: number | PDFNumber,
c: number | PDFNumber,
d: number | PDFNumber,
e: number | PDFNumber,
f: number | PDFNumber,
) =>
PDFOperator.of(Ops.SetTextMatrix, [
asPDFNumber(a),
asPDFNumber(b),
asPDFNumber(c),
asPDFNumber(d),
asPDFNumber(e),
asPDFNumber(f),
]);
export const rotateAndSkewTextRadiansAndTranslate = (
rotationAngle: number | PDFNumber,
xSkewAngle: number | PDFNumber,
ySkewAngle: number | PDFNumber,
x: number | PDFNumber,
y: number | PDFNumber,
) =>
setTextMatrix(
cos(asNumber(rotationAngle)),
sin(asNumber(rotationAngle)) + tan(asNumber(xSkewAngle)),
-sin(asNumber(rotationAngle)) + tan(asNumber(ySkewAngle)),
cos(asNumber(rotationAngle)),
x,
y,
);
export const rotateAndSkewTextDegreesAndTranslate = (
rotationAngle: number | PDFNumber,
xSkewAngle: number | PDFNumber,
ySkewAngle: number | PDFNumber,
x: number | PDFNumber,
y: number | PDFNumber,
) =>
rotateAndSkewTextRadiansAndTranslate(
degreesToRadians(asNumber(rotationAngle)),
degreesToRadians(asNumber(xSkewAngle)),
degreesToRadians(asNumber(ySkewAngle)),
x,
y,
);
/* ==================== XObject Operator ==================== */
export const drawObject = (name: string | PDFName) =>
PDFOperator.of(Ops.DrawObject, [asPDFName(name)]);
/* ==================== Color Operators ==================== */
export const setFillingGrayscaleColor = (gray: number | PDFNumber) =>
PDFOperator.of(Ops.NonStrokingColorGray, [asPDFNumber(gray)]);
export const setStrokingGrayscaleColor = (gray: number | PDFNumber) =>
PDFOperator.of(Ops.StrokingColorGray, [asPDFNumber(gray)]);
export const setFillingRgbColor = (
red: number | PDFNumber,
green: number | PDFNumber,
blue: number | PDFNumber,
) =>
PDFOperator.of(Ops.NonStrokingColorRgb, [
asPDFNumber(red),
asPDFNumber(green),
asPDFNumber(blue),
]);
export const setStrokingRgbColor = (
red: number | PDFNumber,
green: number | PDFNumber,
blue: number | PDFNumber,
) =>
PDFOperator.of(Ops.StrokingColorRgb, [
asPDFNumber(red),
asPDFNumber(green),
asPDFNumber(blue),
]);
export const setFillingCmykColor = (
cyan: number | PDFNumber,
magenta: number | PDFNumber,
yellow: number | PDFNumber,
key: number | PDFNumber,
) =>
PDFOperator.of(Ops.NonStrokingColorCmyk, [
asPDFNumber(cyan),
asPDFNumber(magenta),
asPDFNumber(yellow),
asPDFNumber(key),
]);
export const setStrokingCmykColor = (
cyan: number | PDFNumber,
magenta: number | PDFNumber,
yellow: number | PDFNumber,
key: number | PDFNumber,
) =>
PDFOperator.of(Ops.StrokingColorCmyk, [
asPDFNumber(cyan),
asPDFNumber(magenta),
asPDFNumber(yellow),
asPDFNumber(key),
]);
/* ==================== Marked Content Operators ==================== */
export const beginMarkedContent = (tag: string | PDFName) =>
PDFOperator.of(Ops.BeginMarkedContent, [asPDFName(tag)]);
export const endMarkedContent = () => PDFOperator.of(Ops.EndMarkedContent);
+87
View File
@@ -0,0 +1,87 @@
import { assertIs, error } from 'src/utils';
export enum RotationTypes {
Degrees = 'degrees',
Radians = 'radians',
}
export interface Radians {
type: RotationTypes.Radians;
angle: number;
}
export interface Degrees {
type: RotationTypes.Degrees;
angle: number;
}
export type Rotation = Radians | Degrees;
export const radians = (radianAngle: number): Radians => {
assertIs(radianAngle, 'radianAngle', ['number']);
return { type: RotationTypes.Radians, angle: radianAngle };
};
export const degrees = (degreeAngle: number): Degrees => {
assertIs(degreeAngle, 'degreeAngle', ['number']);
return { type: RotationTypes.Degrees, angle: degreeAngle };
};
const { Radians, Degrees } = RotationTypes;
export const degreesToRadians = (degree: number) => (degree * Math.PI) / 180;
export const radiansToDegrees = (radian: number) => (radian * 180) / Math.PI;
// prettier-ignore
export const toRadians = (rotation: Rotation) =>
rotation.type === Radians ? rotation.angle
: rotation.type === Degrees ? degreesToRadians(rotation.angle)
: error(`Invalid rotation: ${JSON.stringify(rotation)}`);
// prettier-ignore
export const toDegrees = (rotation: Rotation) =>
rotation.type === Radians ? radiansToDegrees(rotation.angle)
: rotation.type === Degrees ? rotation.angle
: error(`Invalid rotation: ${JSON.stringify(rotation)}`);
export const reduceRotation = (degreeAngle = 0) => {
const quadrants = (degreeAngle / 90) % 4;
if (quadrants === 0) return 0;
if (quadrants === 1) return 90;
if (quadrants === 2) return 180;
if (quadrants === 3) return 270;
return 0; // `degreeAngle` is not a multiple of 90
};
export const adjustDimsForRotation = (
dims: { width: number; height: number },
degreeAngle = 0,
) => {
const rotation = reduceRotation(degreeAngle);
return rotation === 90 || rotation === 270
? { width: dims.height, height: dims.width }
: { width: dims.width, height: dims.height };
};
export const rotateRectangle = (
rectangle: {
x: number;
y: number;
width: number;
height: number;
},
borderWidth = 0,
degreeAngle = 0,
) => {
const { x, y, width: w, height: h } = rectangle;
const r = reduceRotation(degreeAngle);
const b = borderWidth / 2;
// prettier-ignore
if (r === 0) return { x: x - b, y: y - b, width: w, height: h };
else if (r === 90) return { x: x - h + b, y: y - b, width: h, height: w };
else if (r === 180) return { x: x - w + b, y: y - h + b, width: w, height: h };
else if (r === 270) return { x: x - b, y: y - w + b, width: h, height: w };
else return { x: x - b, y: y - b, width: w, height: h };
};
+52
View File
@@ -0,0 +1,52 @@
export const PageSizes = {
'4A0': [4767.87, 6740.79] as [number, number],
'2A0': [3370.39, 4767.87] as [number, number],
A0: [2383.94, 3370.39] as [number, number],
A1: [1683.78, 2383.94] as [number, number],
A2: [1190.55, 1683.78] as [number, number],
A3: [841.89, 1190.55] as [number, number],
A4: [595.28, 841.89] as [number, number],
A5: [419.53, 595.28] as [number, number],
A6: [297.64, 419.53] as [number, number],
A7: [209.76, 297.64] as [number, number],
A8: [147.4, 209.76] as [number, number],
A9: [104.88, 147.4] as [number, number],
A10: [73.7, 104.88] as [number, number],
B0: [2834.65, 4008.19] as [number, number],
B1: [2004.09, 2834.65] as [number, number],
B2: [1417.32, 2004.09] as [number, number],
B3: [1000.63, 1417.32] as [number, number],
B4: [708.66, 1000.63] as [number, number],
B5: [498.9, 708.66] as [number, number],
B6: [354.33, 498.9] as [number, number],
B7: [249.45, 354.33] as [number, number],
B8: [175.75, 249.45] as [number, number],
B9: [124.72, 175.75] as [number, number],
B10: [87.87, 124.72] as [number, number],
C0: [2599.37, 3676.54] as [number, number],
C1: [1836.85, 2599.37] as [number, number],
C2: [1298.27, 1836.85] as [number, number],
C3: [918.43, 1298.27] as [number, number],
C4: [649.13, 918.43] as [number, number],
C5: [459.21, 649.13] as [number, number],
C6: [323.15, 459.21] as [number, number],
C7: [229.61, 323.15] as [number, number],
C8: [161.57, 229.61] as [number, number],
C9: [113.39, 161.57] as [number, number],
C10: [79.37, 113.39] as [number, number],
RA0: [2437.8, 3458.27] as [number, number],
RA1: [1729.13, 2437.8] as [number, number],
RA2: [1218.9, 1729.13] as [number, number],
RA3: [864.57, 1218.9] as [number, number],
RA4: [609.45, 864.57] as [number, number],
SRA0: [2551.18, 3628.35] as [number, number],
SRA1: [1814.17, 2551.18] as [number, number],
SRA2: [1275.59, 1814.17] as [number, number],
SRA3: [907.09, 1275.59] as [number, number],
SRA4: [637.8, 907.09] as [number, number],
Executive: [521.86, 756.0] as [number, number],
Folio: [612.0, 936.0] as [number, number],
Legal: [612.0, 1008.0] as [number, number],
Letter: [612.0, 792.0] as [number, number],
Tabloid: [792.0, 1224.0] as [number, number],
};
+489
View File
@@ -0,0 +1,489 @@
// Originated from pdfkit Copyright (c) 2014 Devon Govett
// https://github.com/foliojs/pdfkit/blob/1e62e6ffe24b378eb890df507a47610f4c4a7b24/lib/path.js
// MIT LICENSE
// Updated for pdf-lib & TypeScript by Jeremy Messenger
import {
appendBezierCurve,
appendQuadraticCurve,
closePath,
lineTo,
moveTo,
} from 'src/api/operators';
import { PDFOperator } from 'src/core';
let cx: number = 0;
let cy: number = 0;
let px: number | null = 0;
let py: number | null = 0;
let sx: number = 0;
let sy: number = 0;
const parameters = new Map<string, number>([
['A', 7],
['a', 7],
['C', 6],
['c', 6],
['H', 1],
['h', 1],
['L', 2],
['l', 2],
['M', 2],
['m', 2],
['Q', 4],
['q', 4],
['S', 4],
['s', 4],
['T', 2],
['t', 2],
['V', 1],
['v', 1],
['Z', 0],
['z', 0],
]);
interface Cmd {
cmd?: string;
args: number[];
}
const parse = (path: string) => {
let cmd;
const ret: Cmd[] = [];
let args: number[] = [];
let curArg = '';
let foundDecimal = false;
let params = 0;
for (const c of path) {
if (parameters.has(c)) {
params = parameters.get(c)!;
if (cmd) {
// save existing command
if (curArg.length > 0) {
args[args.length] = +curArg;
}
ret[ret.length] = { cmd, args };
args = [];
curArg = '';
foundDecimal = false;
}
cmd = c;
} else if (
[' ', ','].includes(c) ||
(c === '-' && curArg.length > 0 && curArg[curArg.length - 1] !== 'e') ||
(c === '.' && foundDecimal)
) {
if (curArg.length === 0) {
continue;
}
if (args.length === params) {
// handle reused commands
ret[ret.length] = { cmd, args };
args = [+curArg];
// handle assumed commands
if (cmd === 'M') {
cmd = 'L';
}
if (cmd === 'm') {
cmd = 'l';
}
} else {
args[args.length] = +curArg;
}
foundDecimal = c === '.';
// fix for negative numbers or repeated decimals with no delimeter between commands
curArg = ['-', '.'].includes(c) ? c : '';
} else {
curArg += c;
if (c === '.') {
foundDecimal = true;
}
}
}
// add the last command
if (curArg.length > 0) {
if (args.length === params) {
// handle reused commands
ret[ret.length] = { cmd, args };
args = [+curArg];
// handle assumed commands
if (cmd === 'M') {
cmd = 'L';
}
if (cmd === 'm') {
cmd = 'l';
}
} else {
args[args.length] = +curArg;
}
}
ret[ret.length] = { cmd, args };
return ret;
};
const apply = (commands: Cmd[]) => {
// current point, control point, and subpath starting point
cx = cy = px = py = sx = sy = 0;
// run the commands
let cmds: PDFOperator[] = [];
for (let i = 0; i < commands.length; i++) {
const c = commands[i];
if (c.cmd && typeof runners[c.cmd] === 'function') {
const cmd = runners[c.cmd](c.args);
if (Array.isArray(cmd)) {
cmds = cmds.concat(cmd);
} else {
cmds.push(cmd);
}
}
}
return cmds;
};
interface CmdToOperatorsMap {
[cmd: string]: (a: number[]) => PDFOperator | PDFOperator[];
}
const runners: CmdToOperatorsMap = {
M(a) {
cx = a[0];
cy = a[1];
px = py = null;
sx = cx;
sy = cy;
return moveTo(cx, cy);
},
m(a) {
cx += a[0];
cy += a[1];
px = py = null;
sx = cx;
sy = cy;
return moveTo(cx, cy);
},
C(a) {
cx = a[4];
cy = a[5];
px = a[2];
py = a[3];
return appendBezierCurve(a[0], a[1], a[2], a[3], a[4], a[5]);
},
c(a) {
const cmd = appendBezierCurve(
a[0] + cx,
a[1] + cy,
a[2] + cx,
a[3] + cy,
a[4] + cx,
a[5] + cy,
);
px = cx + a[2];
py = cy + a[3];
cx += a[4];
cy += a[5];
return cmd;
},
S(a) {
if (px === null || py === null) {
px = cx;
py = cy;
}
const cmd = appendBezierCurve(
cx - (px - cx),
cy - (py - cy),
a[0],
a[1],
a[2],
a[3],
);
px = a[0];
py = a[1];
cx = a[2];
cy = a[3];
return cmd;
},
s(a) {
if (px === null || py === null) {
px = cx;
py = cy;
}
const cmd = appendBezierCurve(
cx - (px - cx),
cy - (py - cy),
cx + a[0],
cy + a[1],
cx + a[2],
cy + a[3],
);
px = cx + a[0];
py = cy + a[1];
cx += a[2];
cy += a[3];
return cmd;
},
Q(a) {
px = a[0];
py = a[1];
cx = a[2];
cy = a[3];
return appendQuadraticCurve(a[0], a[1], cx, cy);
},
q(a) {
const cmd = appendQuadraticCurve(
a[0] + cx,
a[1] + cy,
a[2] + cx,
a[3] + cy,
);
px = cx + a[0];
py = cy + a[1];
cx += a[2];
cy += a[3];
return cmd;
},
T(a) {
if (px === null || py === null) {
px = cx;
py = cy;
} else {
px = cx - (px - cx);
py = cy - (py - cy);
}
const cmd = appendQuadraticCurve(px, py, a[0], a[1]);
px = cx - (px - cx);
py = cy - (py - cy);
cx = a[0];
cy = a[1];
return cmd;
},
t(a) {
if (px === null || py === null) {
px = cx;
py = cy;
} else {
px = cx - (px - cx);
py = cy - (py - cy);
}
const cmd = appendQuadraticCurve(px, py, cx + a[0], cy + a[1]);
cx += a[0];
cy += a[1];
return cmd;
},
A(a) {
const cmds = solveArc(cx, cy, a);
cx = a[5];
cy = a[6];
return cmds;
},
a(a) {
a[5] += cx;
a[6] += cy;
const cmds = solveArc(cx, cy, a);
cx = a[5];
cy = a[6];
return cmds;
},
L(a) {
cx = a[0];
cy = a[1];
px = py = null;
return lineTo(cx, cy);
},
l(a) {
cx += a[0];
cy += a[1];
px = py = null;
return lineTo(cx, cy);
},
H(a) {
cx = a[0];
px = py = null;
return lineTo(cx, cy);
},
h(a) {
cx += a[0];
px = py = null;
return lineTo(cx, cy);
},
V(a) {
cy = a[0];
px = py = null;
return lineTo(cx, cy);
},
v(a) {
cy += a[0];
px = py = null;
return lineTo(cx, cy);
},
Z() {
const cmd = closePath();
cx = sx;
cy = sy;
return cmd;
},
z() {
const cmd = closePath();
cx = sx;
cy = sy;
return cmd;
},
};
const solveArc = (x: number, y: number, coords: number[]) => {
const [rx, ry, rot, large, sweep, ex, ey] = coords;
const segs = arcToSegments(ex, ey, rx, ry, large, sweep, rot, x, y);
const cmds: PDFOperator[] = [];
for (const seg of segs) {
const bez = segmentToBezier(...seg);
cmds.push(appendBezierCurve(...bez));
}
return cmds;
};
type Segment = [number, number, number, number, number, number, number, number];
type Bezier = [number, number, number, number, number, number];
// from Inkscape svgtopdf, thanks!
const arcToSegments = (
x: number,
y: number,
rx: number,
ry: number,
large: number,
sweep: number,
rotateX: number,
ox: number,
oy: number,
) => {
const th = rotateX * (Math.PI / 180);
const sinTh = Math.sin(th);
const cosTh = Math.cos(th);
rx = Math.abs(rx);
ry = Math.abs(ry);
px = cosTh * (ox - x) * 0.5 + sinTh * (oy - y) * 0.5;
py = cosTh * (oy - y) * 0.5 - sinTh * (ox - x) * 0.5;
let pl = (px * px) / (rx * rx) + (py * py) / (ry * ry);
if (pl > 1) {
pl = Math.sqrt(pl);
rx *= pl;
ry *= pl;
}
const a00 = cosTh / rx;
const a01 = sinTh / rx;
const a10 = -sinTh / ry;
const a11 = cosTh / ry;
const x0 = a00 * ox + a01 * oy;
const y0 = a10 * ox + a11 * oy;
const x1 = a00 * x + a01 * y;
const y1 = a10 * x + a11 * y;
const d = (x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0);
let sfactorSq = 1 / d - 0.25;
if (sfactorSq < 0) {
sfactorSq = 0;
}
let sfactor = Math.sqrt(sfactorSq);
if (sweep === large) {
sfactor = -sfactor;
}
const xc = 0.5 * (x0 + x1) - sfactor * (y1 - y0);
const yc = 0.5 * (y0 + y1) + sfactor * (x1 - x0);
const th0 = Math.atan2(y0 - yc, x0 - xc);
const th1 = Math.atan2(y1 - yc, x1 - xc);
let thArc = th1 - th0;
if (thArc < 0 && sweep === 1) {
thArc += 2 * Math.PI;
} else if (thArc > 0 && sweep === 0) {
thArc -= 2 * Math.PI;
}
const segments = Math.ceil(Math.abs(thArc / (Math.PI * 0.5 + 0.001)));
const result: Segment[] = [];
for (let i = 0; i < segments; i++) {
const th2 = th0 + (i * thArc) / segments;
const th3 = th0 + ((i + 1) * thArc) / segments;
result[i] = [xc, yc, th2, th3, rx, ry, sinTh, cosTh];
}
return result;
};
const segmentToBezier = (
cx1: number,
cy1: number,
th0: number,
th1: number,
rx: number,
ry: number,
sinTh: number,
cosTh: number,
) => {
const a00 = cosTh * rx;
const a01 = -sinTh * ry;
const a10 = sinTh * rx;
const a11 = cosTh * ry;
const thHalf = 0.5 * (th1 - th0);
const t =
((8 / 3) * Math.sin(thHalf * 0.5) * Math.sin(thHalf * 0.5)) /
Math.sin(thHalf);
const x1 = cx1 + Math.cos(th0) - t * Math.sin(th0);
const y1 = cy1 + Math.sin(th0) + t * Math.cos(th0);
const x3 = cx1 + Math.cos(th1);
const y3 = cy1 + Math.sin(th1);
const x2 = x3 + t * Math.sin(th1);
const y2 = y3 - t * Math.cos(th1);
const result: Bezier = [
a00 * x1 + a01 * y1,
a10 * x1 + a11 * y1,
a00 * x2 + a01 * y2,
a10 * x2 + a11 * y2,
a00 * x3 + a01 * y3,
a10 * x3 + a11 * y3,
];
return result;
};
export const svgPathToOperators = (path: string) => apply(parse(path));
+5
View File
@@ -0,0 +1,5 @@
export enum TextAlignment {
Left = 0,
Center = 1,
Right = 2,
}
+2
View File
@@ -0,0 +1,2 @@
export * from 'src/api/text/alignment';
export * from 'src/api/text/layout';
+343
View File
@@ -0,0 +1,343 @@
import PDFFont from 'src/api/PDFFont';
import { CombedTextLayoutError } from 'src/api/errors';
import { TextAlignment } from 'src/api/text/alignment';
import { PDFHexString } from 'src/core';
import {
cleanText,
lineSplit,
mergeLines,
charAtIndex,
charSplit,
} from 'src/utils';
export interface TextPosition {
text: string;
encoded: PDFHexString;
x: number;
y: number;
width: number;
height: number;
}
export interface LayoutBounds {
x: number;
y: number;
width: number;
height: number;
}
const MIN_FONT_SIZE = 4;
const MAX_FONT_SIZE = 500;
const computeFontSize = (
lines: string[],
font: PDFFont,
bounds: LayoutBounds,
multiline: boolean = false,
) => {
let fontSize = MIN_FONT_SIZE;
while (fontSize < MAX_FONT_SIZE) {
let linesUsed = 0;
for (
let lineIdx = 0, lineLen = lines.length;
lineIdx < lineLen;
lineIdx++
) {
linesUsed += 1;
const line = lines[lineIdx];
const words = line.split(' ');
// Layout the words using the current `fontSize`, line wrapping
// whenever we reach the end of the current line.
let spaceInLineRemaining = bounds.width;
for (let idx = 0, len = words.length; idx < len; idx++) {
const isLastWord = idx === len - 1;
const word = isLastWord ? words[idx] : words[idx] + ' ';
const widthOfWord = font.widthOfTextAtSize(word, fontSize);
spaceInLineRemaining -= widthOfWord;
if (spaceInLineRemaining <= 0) {
linesUsed += 1;
spaceInLineRemaining = bounds.width - widthOfWord;
}
}
}
// Return if we exceeded the allowed width
if (!multiline && linesUsed > lines.length) return fontSize - 1;
const height = font.heightAtSize(fontSize);
const lineHeight = height + height * 0.2;
const totalHeight = lineHeight * linesUsed;
// Return if we exceeded the allowed height
if (totalHeight > Math.abs(bounds.height)) return fontSize - 1;
fontSize += 1;
}
return fontSize;
};
const computeCombedFontSize = (
line: string,
font: PDFFont,
bounds: LayoutBounds,
cellCount: number,
) => {
const cellWidth = bounds.width / cellCount;
const cellHeight = bounds.height;
let fontSize = MIN_FONT_SIZE;
const chars = charSplit(line);
while (fontSize < MAX_FONT_SIZE) {
for (let idx = 0, len = chars.length; idx < len; idx++) {
const c = chars[idx];
const tooLong = font.widthOfTextAtSize(c, fontSize) > cellWidth * 0.75;
if (tooLong) return fontSize - 1;
}
const height = font.heightAtSize(fontSize, { descender: false });
if (height > cellHeight) return fontSize - 1;
fontSize += 1;
}
return fontSize;
};
export interface LayoutTextOptions {
alignment: TextAlignment;
fontSize?: number;
font: PDFFont;
bounds: LayoutBounds;
}
export interface MultilineTextLayout {
bounds: LayoutBounds;
lines: TextPosition[];
fontSize: number;
lineHeight: number;
}
const lastIndexOfWhitespace = (line: string) => {
for (let idx = line.length; idx > 0; idx--) {
if (/\s/.test(line[idx])) return idx;
}
return undefined;
};
const splitOutLines = (
input: string,
maxWidth: number,
font: PDFFont,
fontSize: number,
) => {
let lastWhitespaceIdx = input.length;
while (lastWhitespaceIdx > 0) {
const line = input.substring(0, lastWhitespaceIdx);
const encoded = font.encodeText(line);
const width = font.widthOfTextAtSize(line, fontSize);
if (width < maxWidth) {
const remainder = input.substring(lastWhitespaceIdx) || undefined;
return { line, encoded, width, remainder };
}
lastWhitespaceIdx = lastIndexOfWhitespace(line) ?? 0;
}
// We were unable to split the input enough to get a chunk that would fit
// within the specified `maxWidth` so we'll just return everything
return {
line: input,
encoded: font.encodeText(input),
width: font.widthOfTextAtSize(input, fontSize),
remainder: undefined,
};
};
export const layoutMultilineText = (
text: string,
{ alignment, fontSize, font, bounds }: LayoutTextOptions,
): MultilineTextLayout => {
const lines = lineSplit(cleanText(text));
if (fontSize === undefined || fontSize === 0) {
fontSize = computeFontSize(lines, font, bounds, true);
}
const height = font.heightAtSize(fontSize);
const lineHeight = height + height * 0.2;
const textLines: TextPosition[] = [];
let minX = bounds.x;
let minY = bounds.y;
let maxX = bounds.x + bounds.width;
let maxY = bounds.y + bounds.height;
let y = bounds.y + bounds.height;
for (let idx = 0, len = lines.length; idx < len; idx++) {
let prevRemainder: string | undefined = lines[idx];
while (prevRemainder !== undefined) {
const { line, encoded, width, remainder } = splitOutLines(
prevRemainder,
bounds.width,
font,
fontSize,
);
// prettier-ignore
const x = (
alignment === TextAlignment.Left ? bounds.x
: alignment === TextAlignment.Center ? bounds.x + (bounds.width / 2) - (width / 2)
: alignment === TextAlignment.Right ? bounds.x + bounds.width - width
: bounds.x
);
y -= lineHeight;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x + width > maxX) maxX = x + width;
if (y + height > maxY) maxY = y + height;
textLines.push({ text: line, encoded, width, height, x, y });
// Only trim lines that we had to split ourselves. So we won't trim lines
// that the user provided themselves with whitespace.
prevRemainder = remainder?.trim();
}
}
return {
fontSize,
lineHeight,
lines: textLines,
bounds: {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
},
};
};
export interface LayoutCombedTextOptions {
fontSize?: number;
font: PDFFont;
bounds: LayoutBounds;
cellCount: number;
}
export interface CombedTextLayout {
bounds: LayoutBounds;
cells: TextPosition[];
fontSize: number;
}
export const layoutCombedText = (
text: string,
{ fontSize, font, bounds, cellCount }: LayoutCombedTextOptions,
): CombedTextLayout => {
const line = mergeLines(cleanText(text));
if (line.length > cellCount) {
throw new CombedTextLayoutError(line.length, cellCount);
}
if (fontSize === undefined || fontSize === 0) {
fontSize = computeCombedFontSize(line, font, bounds, cellCount);
}
const cellWidth = bounds.width / cellCount;
const height = font.heightAtSize(fontSize, { descender: false });
const y = bounds.y + (bounds.height / 2 - height / 2);
const cells: TextPosition[] = [];
let minX = bounds.x;
let minY = bounds.y;
let maxX = bounds.x + bounds.width;
let maxY = bounds.y + bounds.height;
let cellOffset = 0;
let charOffset = 0;
while (cellOffset < cellCount) {
const [char, charLength] = charAtIndex(line, charOffset);
const encoded = font.encodeText(char);
const width = font.widthOfTextAtSize(char, fontSize);
const cellCenter = bounds.x + (cellWidth * cellOffset + cellWidth / 2);
const x = cellCenter - width / 2;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x + width > maxX) maxX = x + width;
if (y + height > maxY) maxY = y + height;
cells.push({ text: line, encoded, width, height, x, y });
cellOffset += 1;
charOffset += charLength;
}
return {
fontSize,
cells,
bounds: {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
},
};
};
export interface LayoutSinglelineTextOptions {
alignment: TextAlignment;
fontSize?: number;
font: PDFFont;
bounds: LayoutBounds;
}
export interface SinglelineTextLayout {
bounds: LayoutBounds;
line: TextPosition;
fontSize: number;
}
export const layoutSinglelineText = (
text: string,
{ alignment, fontSize, font, bounds }: LayoutSinglelineTextOptions,
): SinglelineTextLayout => {
const line = mergeLines(cleanText(text));
if (fontSize === undefined || fontSize === 0) {
fontSize = computeFontSize([line], font, bounds);
}
const encoded = font.encodeText(line);
const width = font.widthOfTextAtSize(line, fontSize);
const height = font.heightAtSize(fontSize, { descender: false });
// prettier-ignore
const x = (
alignment === TextAlignment.Left ? bounds.x
: alignment === TextAlignment.Center ? bounds.x + (bounds.width / 2) - (width / 2)
: alignment === TextAlignment.Right ? bounds.x + bounds.width - width
: bounds.x
);
const y = bounds.y + (bounds.height / 2 - height / 2);
return {
fontSize,
line: { text: line, encoded, width, height, x, y },
bounds: { x, y, width, height },
};
};