Skip to content

Implement rendering square/circle annotations without appearance stream #13031

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

Merged
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
173 changes: 142 additions & 31 deletions src/core/annotation.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,33 @@ class AnnotationFactory {
}
}

function getRgbColor(color) {
const rgbColor = new Uint8ClampedArray(3);
if (!Array.isArray(color)) {
return rgbColor;
}

switch (color.length) {
case 0: // Transparent, which we indicate with a null value
return null;

case 1: // Convert grayscale to RGB
ColorSpace.singletons.gray.getRgbItem(color, 0, rgbColor, 0);
return rgbColor;

case 3: // Convert RGB percentages to RGB
ColorSpace.singletons.rgb.getRgbItem(color, 0, rgbColor, 0);
return rgbColor;

case 4: // Convert CMYK to RGB
ColorSpace.singletons.cmyk.getRgbItem(color, 0, rgbColor, 0);
return rgbColor;

default:
return rgbColor;
}
}

function getQuadPoints(dict, rect) {
if (!dict.has("QuadPoints")) {
return null;
Expand Down Expand Up @@ -462,36 +489,7 @@ class Annotation {
* 4 (CMYK) elements
*/
setColor(color) {
const rgbColor = new Uint8ClampedArray(3);
if (!Array.isArray(color)) {
this.color = rgbColor;
return;
}

switch (color.length) {
case 0: // Transparent, which we indicate with a null value
this.color = null;
break;

case 1: // Convert grayscale to RGB
ColorSpace.singletons.gray.getRgbItem(color, 0, rgbColor, 0);
this.color = rgbColor;
break;

case 3: // Convert RGB percentages to RGB
ColorSpace.singletons.rgb.getRgbItem(color, 0, rgbColor, 0);
this.color = rgbColor;
break;

case 4: // Convert CMYK to RGB
ColorSpace.singletons.cmyk.getRgbItem(color, 0, rgbColor, 0);
this.color = rgbColor;
break;

default:
this.color = rgbColor;
break;
}
this.color = getRgbColor(color);
}

/**
Expand Down Expand Up @@ -929,7 +927,22 @@ class MarkupAnnotation extends Annotation {
buffer.push(`${fillColor[0]} ${fillColor[1]} ${fillColor[2]} rg`);
}

for (const points of this.data.quadPoints) {
let pointsArray = this.data.quadPoints;
if (!pointsArray) {
// If there are no quadpoints, the rectangle should be used instead.
// Convert the rectangle definition to a points array similar to how the
// quadpoints are defined.
pointsArray = [
[
{ x: this.rectangle[0], y: this.rectangle[3] },
{ x: this.rectangle[2], y: this.rectangle[3] },
{ x: this.rectangle[0], y: this.rectangle[1] },
{ x: this.rectangle[2], y: this.rectangle[1] },
],
];
}

for (const points of pointsArray) {
const [mX, MX, mY, MY] = pointsCallback(buffer, points);
minX = Math.min(minX, mX);
maxX = Math.max(maxX, MX);
Expand Down Expand Up @@ -2278,6 +2291,43 @@ class SquareAnnotation extends MarkupAnnotation {
super(parameters);

this.data.annotationType = AnnotationType.SQUARE;

if (!this.appearance) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Just to help me understand the differences between Annotation types:

It seems that a few of existing .... extends MarkupAnnotation-cases only does this when quadPoints is defined, note e.g. HighlightAnnotation, hence I wonder what's different between the various Annotation types here?

Copy link
Contributor Author

@timvandermeij timvandermeij Feb 27, 2021

Choose a reason for hiding this comment

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

Good question! I looked into the specification and found that the existing annotation types that do the quadpoints check, namely highlight/squiggly/underline/strikethrough, are text markup annotations and those are required to have quadpoints; see https://www.adobe.com/content/dam/acom/en/devnet/pdf/pdfs/PDF32000_2008.pdf#page=411&zoom=auto,-215,424. Circle and square annotations on the other hand are regular markup annotations and for those quadpoints are not defined; see https://www.adobe.com/content/dam/acom/en/devnet/pdf/pdfs/PDF32000_2008.pdf#page=399&zoom=auto,-215,760.

In our implementation we don't make this distinction because for how we work with them this difference is irrelevant. The overview at https://www.adobe.com/content/dam/acom/en/devnet/pdf/pdfs/PDF32000_2008.pdf#page=398&zoom=auto,-215,775 shows which annotation types are markup annotations and it also shows that highlight/squiggly/underline/strikethrough annotations belong to the text markup annotation "subcategory". The reason is that they are always applied to text, whereas squares/circles/other markup annotations are not text-related and can be used anywhere in the document.

Hopefully this helps a bit to clear things up :-)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hopefully this helps a bit to clear things up :-)

That's indeed very helpful, thank you!

// The default stroke color is black.
const strokeColor = this.color
? Array.from(this.color).map(c => c / 255)
: [0, 0, 0];

// The default fill color is transparent.
let fillColor = null;
let interiorColor = parameters.dict.getArray("IC");
if (interiorColor) {
interiorColor = getRgbColor(interiorColor);
fillColor = interiorColor
? Array.from(interiorColor).map(c => c / 255)
: null;
}

this._setDefaultAppearance({
xref: parameters.xref,
extra: `${this.borderStyle.width} w`,
strokeColor,
fillColor,
pointsCallback: (buffer, points) => {
const x = points[2].x + this.borderStyle.width / 2;
const y = points[2].y + this.borderStyle.width / 2;
const width = points[3].x - points[2].x - this.borderStyle.width;
const height = points[1].y - points[3].y - this.borderStyle.width;
buffer.push(`${x} ${y} ${width} ${height} re`);
if (fillColor) {
buffer.push("B");
} else {
buffer.push("S");
}
return [points[0].x, points[1].x, points[3].y, points[1].y];
},
});
}
}
}

Expand All @@ -2286,6 +2336,67 @@ class CircleAnnotation extends MarkupAnnotation {
super(parameters);

this.data.annotationType = AnnotationType.CIRCLE;

if (!this.appearance) {
// The default stroke color is black.
const strokeColor = this.color
? Array.from(this.color).map(c => c / 255)
: [0, 0, 0];

// The default fill color is transparent.
let fillColor = null;
let interiorColor = parameters.dict.getArray("IC");
if (interiorColor) {
interiorColor = getRgbColor(interiorColor);
fillColor = interiorColor
? Array.from(interiorColor).map(c => c / 255)
: null;
}

// Circles are approximated by Bézier curves with four segments since
// there is no circle primitive in the PDF specification. For the control
// points distance, see https://stackoverflow.com/a/27863181.
const controlPointsDistance = (4 / 3) * Math.tan(Math.PI / (2 * 4));

this._setDefaultAppearance({
xref: parameters.xref,
extra: `${this.borderStyle.width} w`,
strokeColor,
fillColor,
pointsCallback: (buffer, points) => {
const x0 = points[0].x + this.borderStyle.width / 2;
const y0 = points[0].y - this.borderStyle.width / 2;
const x1 = points[3].x - this.borderStyle.width / 2;
const y1 = points[3].y + this.borderStyle.width / 2;
const xMid = x0 + (x1 - x0) / 2;
const yMid = y0 + (y1 - y0) / 2;
const xOffset = ((x1 - x0) / 2) * controlPointsDistance;
const yOffset = ((y1 - y0) / 2) * controlPointsDistance;

buffer.push(`${xMid} ${y1} m`);
buffer.push(
`${xMid + xOffset} ${y1} ${x1} ${yMid + yOffset} ${x1} ${yMid} c`
);
buffer.push(
`${x1} ${yMid - yOffset} ${xMid + xOffset} ${y0} ${xMid} ${y0} c`
);
buffer.push(
`${xMid - xOffset} ${y0} ${x0} ${yMid - yOffset} ${x0} ${yMid} c`
);
buffer.push(
`${x0} ${yMid + yOffset} ${xMid - xOffset} ${y1} ${xMid} ${y1} c`
);

buffer.push("h");
if (fillColor) {
buffer.push("B");
} else {
buffer.push("S");
}
return [points[0].x, points[1].x, points[3].y, points[1].y];
},
});
}
}
}

Expand Down
1 change: 1 addition & 0 deletions test/pdfs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@
!annotation-line.pdf
!bug1669099.pdf
!annotation-square-circle.pdf
!annotation-square-circle-without-appearance.pdf
!annotation-stamp.pdf
!annotation-fileattachment.pdf
!annotation-text-widget.pdf
Expand Down
Binary file not shown.
7 changes: 7 additions & 0 deletions test/test_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4724,6 +4724,13 @@
"type": "eq",
"annotations": true
},
{ "id": "annotation-square-circle-without-appearance",
"file": "pdfs/annotation-square-circle-without-appearance.pdf",
"md5": "5b2a5e1e918137993d26a0cd8b0947f6",
"rounds": 1,
"annotations": true,
"type": "eq"
},
{ "id": "annotation-stamp",
"file": "pdfs/annotation-stamp.pdf",
"md5": "0a04d7ce1ad103cb3c033d26855d6ec7",
Expand Down