Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for textfied & choice widgets printing #12112

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
348 changes: 308 additions & 40 deletions src/core/annotation.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ import {
AnnotationReplyType,
AnnotationType,
assert,
escapeString,
isString,
OPS,
stringToBytes,
stringToPDFString,
Util,
warn,
Expand All @@ -33,7 +33,7 @@ import { Dict, isDict, isName, isRef, isStream } from "./primitives.js";
import { ColorSpace } from "./colorspace.js";
import { getInheritableProperty } from "./core_utils.js";
import { OperatorList } from "./operator_list.js";
import { Stream } from "./stream.js";
import { StringStream } from "./stream.js";

class AnnotationFactory {
/**
Expand Down Expand Up @@ -888,18 +888,314 @@ class WidgetAnnotation extends Annotation {
}

getOperatorList(evaluator, task, renderForms, annotationStorage) {
// Do not render form elements on the canvas when interactive forms are
// enabled. The display layer is responsible for rendering them instead.
if (renderForms) {
return Promise.resolve(new OperatorList());
}
return super.getOperatorList(
evaluator,
task,
renderForms,
annotationStorage

if (!this.data.hasText) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Does hasText indicate that it's an editable widget? Maybe editable would be a better name then?

return super.getOperatorList(
evaluator,
task,
renderForms,
annotationStorage
);
}

return this.getAppearance(evaluator, task, annotationStorage).then(
content => {
// Do not render form elements on the canvas when interactive forms are
// enabled. The display layer is responsible for rendering them instead.
if (this.appearance && content === null) {
return super.getOperatorList(
evaluator,
task,
renderForms,
annotationStorage
);
}

const operatorList = new OperatorList();

// Even if there is an appearance stream, ignore it. This is the
// behaviour used by Adobe Reader.
if (!this.data.defaultAppearance || content === null) {
return operatorList;
}

const matrix = [1, 0, 0, 1, 0, 0];
const bbox = [
0,
0,
this.data.rect[2] - this.data.rect[0],
this.data.rect[3] - this.data.rect[1],
];

const transform = getTransformMatrix(this.data.rect, bbox, matrix);
operatorList.addOp(OPS.beginAnnotation, [
this.data.rect,
transform,
matrix,
]);

const stream = new StringStream(content);
return evaluator
.getOperatorList({
stream,
task,
resources: this.fieldResources,
operatorList,
})
.then(function () {
operatorList.addOp(OPS.endAnnotation, []);
return operatorList;
});
}
);
}

async getAppearance(evaluator, task, annotationStorage) {
// If it's a password textfield then no rendering to avoid to leak it.
// see 12.7.4.3, table 228
if (!annotationStorage || this.data.isPassword) {
return null;
}
let value = annotationStorage[this.data.id] || "";
if (value === "") {
return null;
}
value = escapeString(value);

// Magic value
const defaultPadding = 2;

// Default horizontal padding: can we have an heuristic to guess it?
const hPadding = defaultPadding;
const totalHeight = this.data.rect[3] - this.data.rect[1];
const totalWidth = this.data.rect[2] - this.data.rect[0];

const fontInfo = await this.getFontData(evaluator, task);
const [font, fontName] = fontInfo;
let [, , fontSize] = fontInfo;
Copy link
Contributor

Choose a reason for hiding this comment

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

This looks a bit odd. Can we at least use underscores for the unused parts? Otherwise let fontSize = fontInfo[2] would probably be more readable.


fontSize = this.computeAutoSizedFont(font, fontName, fontSize, totalHeight);

let descent = font.descent;
if (isNaN(descent)) {
descent = 0;
}

const vPadding = defaultPadding + Math.abs(descent) * fontSize;
const defaultAppearance = this.data.defaultAppearance;

if (this.data.comb) {
const combWidth = (totalWidth / this.data.maxLen).toFixed(2);
let buf = `/Tx BMC q BT ${defaultAppearance} 1 0 0 1 ${hPadding} ${vPadding} Tm`;
let first = true;
for (const character of value) {
if (first) {
buf += ` (${character}) Tj`;
first = false;
} else {
buf += ` ${combWidth} 0 Td (${character}) Tj`;
}
}
buf += " ET Q EMC";
return buf;
}

if (this.data.multiLine) {
const renderedText = this.handleMultiline(
value,
font,
fontSize,
totalWidth,
alignment,
hPadding,
vPadding
);
return `/Tx BMC q BT ${defaultAppearance} 1 0 0 1 0 ${totalHeight} Tm ${renderedText} ET Q EMC`;
}

const alignment = this.data.textAlignment;
if (alignment === 0 || alignment > 2) {
// Left alignment: nothing to do
return `/Tx BMC q BT ${defaultAppearance} 1 0 0 1 ${hPadding} ${vPadding} Tm (${value}) Tj ET Q EMC`;
}

const renderedText = this.renderPDFText(
value,
font,
fontSize,
totalWidth,
alignment,
hPadding,
vPadding
);
return `/Tx BMC q BT ${defaultAppearance} 1 0 0 1 0 0 Tm ${renderedText} ET Q EMC`;
}

async getFontData(evaluator, task) {
const operatorList = new OperatorList();
const initialState = {
fontSize: 0,
font: null,
fontName: null,
clone() {
return this;
},
};

await evaluator.getOperatorList({
stream: new StringStream(this.data.defaultAppearance),
task,
resources: this.fieldResources,
operatorList,
initialState,
});

return [initialState.font, initialState.fontName, initialState.fontSize];
}

computeAutoSizedFont(font, fontName, fontSize, height) {
if (fontSize === null || fontSize === 0) {
const em = font.charsToGlyphs("M", true)[0].width / 1000;
// According to https://en.wikipedia.org/wiki/Em_(typography)
// an average cap height should be 70% of 1em
const capHeight = 0.7 * em;
// 1.5 * capHeight * fontSize seems to be a good value for lineHeight
fontSize = Math.max(1, Math.floor(height / (1.5 * capHeight)));

let re = new RegExp(`/${fontName}\\s+[0-9\.]+\\s+Tf`);
if (this.data.defaultAppearance.search(re) === -1) {
// The font size is missing
re = new RegExp(`/${fontName}\\s+Tf`);
}
this.data.defaultAppearance = this.data.defaultAppearance.replace(
re,
`/${fontName} ${fontSize} Tf`
);
}
return fontSize;
}

renderPDFText(
text,
font,
fontSize,
totalWidth,
alignment,
hPadding,
vPadding
) {
// We need to get the width of the text in order to align it correctly
const glyphs = font.charsToGlyphs(text);
const scale = fontSize / 1000;
let width = 0;
for (const glyph of glyphs) {
width += glyph.width * scale;
}

let shift;
if (alignment === 1) {
// Center
shift = (totalWidth - width) / 2;
} else if (alignment === 2) {
// Right
shift = totalWidth - width - hPadding;
} else {
shift = hPadding;
}
shift = shift.toFixed(2);
vPadding = vPadding.toFixed(2);

return `${shift} ${vPadding} Td (${text}) Tj`;
}

handleMultiline(text, font, fontSize, width, alignment, hPadding, vPadding) {
const lines = text.replace("\r\n", "\n").split("\n");
let buf = "";
const totalWidth = alignment === 1 ? width : width - 2 * hPadding;
for (const line of lines) {
const chunks = this.splitLine(line, font, fontSize, totalWidth);
for (const chunk of chunks) {
if (buf === "") {
buf = this.renderPDFText(
chunk,
font,
fontSize,
width,
alignment,
hPadding,
-fontSize
);
} else {
buf +=
"\n" +
this.renderPDFText(
chunk,
font,
fontSize,
width,
alignment,
0,
-fontSize
);
}
}
}

return buf;
}

splitLine(line, font, fontSize, width) {
const scale = fontSize / 1000;
const white = font.charsToGlyphs(" ", true)[0].width * scale;
const chunks = [];

let lastSpacePos = -1,
startChunk = 0,
currentWidth = 0;

for (let i = 0; i < line.length; i++) {
const character = line.charAt(i);
if (character === " ") {
if (currentWidth + white > width) {
// We can break here
chunks.push(line.substring(startChunk, i));
startChunk = i;
currentWidth = white;
lastSpacePos = -1;
} else {
currentWidth += white;
lastSpacePos = i;
}
} else {
const charWidth = font.charsToGlyphs(character, false)[0].width * scale;
if (currentWidth + charWidth > width) {
// We must break to the last white position (if one)
if (lastSpacePos !== -1) {
chunks.push(line.substring(startChunk, lastSpacePos + 1));
startChunk = i = lastSpacePos + 1;
lastSpacePos = -1;
currentWidth = 0;
} else {
// Just break in the middle of the word
chunks.push(line.substring(startChunk, i));
startChunk = i;
currentWidth = charWidth;
}
} else {
currentWidth += charWidth;
}
}
}

if (startChunk < line.length) {
chunks.push(line.substring(startChunk, line.length));
}

return chunks;
}
}

class TextWidgetAnnotation extends WidgetAnnotation {
Expand All @@ -924,6 +1220,8 @@ class TextWidgetAnnotation extends WidgetAnnotation {
maximumLength = null;
}
this.data.maxLen = maximumLength;
this.data.isPassword = this.hasFieldFlag(AnnotationFieldFlag.PASSWORD);
this.data.hasText = true;

// Process field flags for the display layer.
this.data.multiLine = this.hasFieldFlag(AnnotationFieldFlag.MULTILINE);
Expand All @@ -934,37 +1232,6 @@ class TextWidgetAnnotation extends WidgetAnnotation {
!this.hasFieldFlag(AnnotationFieldFlag.FILESELECT) &&
this.data.maxLen !== null;
}

getOperatorList(evaluator, task, renderForms, annotationStorage) {
if (renderForms || this.appearance) {
return super.getOperatorList(
evaluator,
task,
renderForms,
annotationStorage
);
}

const operatorList = new OperatorList();

// Even if there is an appearance stream, ignore it. This is the
// behaviour used by Adobe Reader.
if (!this.data.defaultAppearance) {
return Promise.resolve(operatorList);
}

const stream = new Stream(stringToBytes(this.data.defaultAppearance));
return evaluator
.getOperatorList({
stream,
task,
resources: this.fieldResources,
operatorList,
})
.then(function () {
return operatorList;
});
}
}

class ButtonWidgetAnnotation extends WidgetAnnotation {
Expand Down Expand Up @@ -1148,6 +1415,7 @@ class ChoiceWidgetAnnotation extends WidgetAnnotation {
// Process field flags for the display layer.
this.data.combo = this.hasFieldFlag(AnnotationFieldFlag.COMBO);
this.data.multiSelect = this.hasFieldFlag(AnnotationFieldFlag.MULTISELECT);
this.data.hasText = true;
}
}

Expand Down
Loading