Compare commits

...

6 Commits

Author SHA1 Message Date
Jonas Jenwald
5873e1cbc0
Merge pull request #21431 from Snuffleupagus/more-isDict
Use the `isDict` helper function in a few more places
2026-06-12 23:32:22 +02:00
Tim van der Meij
ca34359e1f
Merge pull request #21426 from Snuffleupagus/rm-convertToViewportRectangle
[api-minor] Remove the unused `convertToViewportRectangle` method in the `PageViewport` class
2026-06-12 22:06:19 +02:00
calixteman
35d275d3b1
Merge pull request #18907 from calixteman/bug1802506
Use the checkboxes and radio button appearances as defined in the pdf to render them in the annotation layer (bug 1802506)
2026-06-12 22:04:29 +02:00
Calixte Denizet
069b757998 Use the checkboxes and radio button appearances as defined in the pdf to render them in the annotation layer (bug 1802506)
The idea is to generate two operator lists for the Yes/Off states and render them on a separate canvas.
These canvases are then attached the annotation and we modify their display depending on the input state.

It fixes #18021.
2026-06-12 20:10:56 +02:00
Jonas Jenwald
3b628d59fb Use the isDict helper function in a few more places 2026-06-11 17:24:03 +02:00
Jonas Jenwald
a543d0a2e0 [api-minor] Remove the unused convertToViewportRectangle method in the PageViewport class
This method has been completely unused for many years, possibly as far back as PR 8030, hence we can avoid shipping a little bit of dead code.

*Note:* If there's any third-party code depending on it, updating it ought to be as simple as changing
```javascript
const r = viewport.convertToViewportRectangle(rect);
```
into
```javascript
const r = [
  ...viewport.convertToViewportPoint(rect[0], rect[1]),
  ...viewport.convertToViewportPoint(rect[2], rect[3])
];
```
2026-06-10 14:19:20 +02:00
19 changed files with 415 additions and 201 deletions

View File

@ -785,6 +785,14 @@ class Annotation {
this._needAppearances = false; this._needAppearances = false;
} }
_getOperatorListNoAppearance() {
return {
opList: new OperatorList(),
separateForm: false,
separateCanvas: false,
};
}
/** /**
* @private * @private
*/ */
@ -1213,28 +1221,32 @@ class Annotation {
return resources; return resources;
} }
// Whether the annotation should only be rendered on its own canvas when
// interactive forms are enabled. This is the case for checkbox/radio button
// widgets, whose checked/unchecked appearances are toggled in forms mode;
// other annotations (e.g. push buttons) keep their own canvas in any display
// mode.
get _ownCanvasRequiresForms() {
return false;
}
async getOperatorList(evaluator, task, intent, annotationStorage) { async getOperatorList(evaluator, task, intent, annotationStorage) {
const { hasOwnCanvas, id, rect } = this.data; const { hasOwnCanvas, id, rect } = this.data;
let appearance = this.appearance; let appearance = this.appearance;
const isUsingOwnCanvas = !!( const isUsingOwnCanvas = !!(
hasOwnCanvas && intent & RenderingIntentFlag.DISPLAY hasOwnCanvas &&
intent & RenderingIntentFlag.DISPLAY &&
(!this._ownCanvasRequiresForms ||
intent & RenderingIntentFlag.ANNOTATIONS_FORMS)
); );
if (isUsingOwnCanvas && (this.width === 0 || this.height === 0)) { if (isUsingOwnCanvas && (this.width === 0 || this.height === 0)) {
// Empty annotation, don't draw anything. // Empty annotation, don't draw anything.
this.data.hasOwnCanvas = false; this.data.hasOwnCanvas = false;
return { return this._getOperatorListNoAppearance();
opList: new OperatorList(),
separateForm: false,
separateCanvas: false,
};
} }
if (!appearance) { if (!appearance) {
if (!isUsingOwnCanvas) { if (!isUsingOwnCanvas) {
return { return this._getOperatorListNoAppearance();
opList: new OperatorList(),
separateForm: false,
separateCanvas: false,
};
} }
appearance = new StringStream("", new Dict()); appearance = new StringStream("", new Dict());
} }
@ -1244,7 +1256,12 @@ class Annotation {
RESOURCES_KEYS_OPERATOR_LIST, RESOURCES_KEYS_OPERATOR_LIST,
appearance appearance
); );
const bbox = lookupRect(appearanceDict.getArray("BBox"), [0, 0, 1, 1]); const bbox = lookupRect(appearanceDict.getArray("BBox"), [
0,
0,
this.width,
this.height,
]);
const matrix = lookupMatrix( const matrix = lookupMatrix(
appearanceDict.getArray("Matrix"), appearanceDict.getArray("Matrix"),
IDENTITY_MATRIX IDENTITY_MATRIX
@ -2092,11 +2109,9 @@ class WidgetAnnotation extends Annotation {
!this.data.noHTML && !this.data.noHTML &&
!this.data.hasOwnCanvas !this.data.hasOwnCanvas
) { ) {
return { const list = this._getOperatorListNoAppearance();
opList: new OperatorList(), list.separateForm = true;
separateForm: true, return list;
separateCanvas: false,
};
} }
if (!this._hasText) { if (!this._hasText) {
@ -3148,20 +3163,58 @@ class ButtonWidgetAnnotation extends WidgetAnnotation {
this.data.radioButton = isRadio && !isPushButton; this.data.radioButton = isRadio && !isPushButton;
this.data.pushButton = isPushButton; this.data.pushButton = isPushButton;
this.data.isTooltipOnly = false; this.data.isTooltipOnly = false;
this.data.hasOwnCanvas = true;
this.data.noHTML = false;
if (this.data.checkBox) { if (this.data.checkBox) {
this._processCheckBox(params); this._processCheckBox(params);
} else if (this.data.radioButton) { } else if (this.data.radioButton) {
this._processRadioButton(params); this._processRadioButton(params);
} else if (this.data.pushButton) { } else if (this.data.pushButton) {
this.data.hasOwnCanvas = true;
this.data.noHTML = false;
this._processPushButton(params); this._processPushButton(params);
} else { } else {
warn("Invalid field flags for button widget annotation"); warn("Invalid field flags for button widget annotation");
} }
} }
get _ownCanvasRequiresForms() {
return this.data.checkBox || this.data.radioButton;
}
#getOperatorListForAppearance(
evaluator,
task,
intent,
annotationStorage,
rotation,
appearance
) {
if (!appearance) {
return this._getOperatorListNoAppearance();
}
const savedAppearance = this.appearance;
const savedMatrix = lookupMatrix(
appearance.dict.getArray("Matrix"),
IDENTITY_MATRIX
);
if (rotation) {
appearance.dict.set("Matrix", this.getRotationMatrix(annotationStorage));
}
this.appearance = appearance;
const operatorList = super.getOperatorList(
evaluator,
task,
intent,
annotationStorage
);
this.appearance = savedAppearance;
appearance.dict.set("Matrix", savedMatrix);
return operatorList;
}
async getOperatorList(evaluator, task, intent, annotationStorage) { async getOperatorList(evaluator, task, intent, annotationStorage) {
if (this.data.pushButton) { if (this.data.pushButton) {
return super.getOperatorList( return super.getOperatorList(
@ -3173,6 +3226,45 @@ class ButtonWidgetAnnotation extends WidgetAnnotation {
); );
} }
if (
intent & RenderingIntentFlag.DISPLAY &&
intent & RenderingIntentFlag.ANNOTATIONS_FORMS &&
(this.data.checkBox || this.data.radioButton)
) {
// Tag the dedicated canvas with the state it represents. The appearance
// may start with other operators (e.g. an optional content marked-content
// sequence), so target the `beginAnnotation` operator directly rather
// than assuming it's the first one.
const setCanvasName = (operatorList, name) => {
const index = operatorList.fnArray.indexOf(OPS.beginAnnotation);
if (index !== -1) {
operatorList.argsArray[index].push(name);
}
};
const checked = await this.#getOperatorListForAppearance(
evaluator,
task,
intent,
annotationStorage,
null,
this.checkedAppearance
);
setCanvasName(checked.opList, "checked");
const unchecked = await this.#getOperatorListForAppearance(
evaluator,
task,
intent,
annotationStorage,
null,
this.uncheckedAppearance
);
setCanvasName(unchecked.opList, "unchecked");
checked.opList.addOpList(unchecked.opList);
checked.separateForm ||= unchecked.separateForm;
checked.separateCanvas ||= unchecked.separateCanvas;
return checked;
}
let value = null; let value = null;
let rotation = null; let rotation = null;
if (annotationStorage) { if (annotationStorage) {
@ -3195,41 +3287,14 @@ class ButtonWidgetAnnotation extends WidgetAnnotation {
: this.data.fieldValue === this.data.buttonValue; : this.data.fieldValue === this.data.buttonValue;
} }
const appearance = value return this.#getOperatorListForAppearance(
? this.checkedAppearance evaluator,
: this.uncheckedAppearance; task,
if (appearance) { intent,
const savedAppearance = this.appearance; annotationStorage,
const savedMatrix = lookupMatrix( rotation,
appearance.dict.getArray("Matrix"), value ? this.checkedAppearance : this.uncheckedAppearance
IDENTITY_MATRIX );
);
if (rotation) {
appearance.dict.set(
"Matrix",
this.getRotationMatrix(annotationStorage)
);
}
this.appearance = appearance;
const operatorList = super.getOperatorList(
evaluator,
task,
intent,
annotationStorage
);
this.appearance = savedAppearance;
appearance.dict.set("Matrix", savedMatrix);
return operatorList;
}
// No appearance
return {
opList: new OperatorList(),
separateForm: false,
separateCanvas: false,
};
} }
async save(evaluator, task, annotationStorage, changes) { async save(evaluator, task, annotationStorage, changes) {
@ -3422,13 +3487,11 @@ class ButtonWidgetAnnotation extends WidgetAnnotation {
_processCheckBox(params) { _processCheckBox(params) {
const customAppearance = params.dict.get("AP"); const customAppearance = params.dict.get("AP");
if (!(customAppearance instanceof Dict)) { let normalAppearance =
return; customAppearance instanceof Dict ? customAppearance.get("N") : null;
}
const normalAppearance = customAppearance.get("N");
if (!(normalAppearance instanceof Dict)) { if (!(normalAppearance instanceof Dict)) {
return; // Synthesize a default appearance below when the field defines none.
normalAppearance = null;
} }
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1722036. // See https://bugzilla.mozilla.org/show_bug.cgi?id=1722036.
@ -3444,7 +3507,9 @@ class ButtonWidgetAnnotation extends WidgetAnnotation {
: "Yes"; : "Yes";
// Don't decode the keys which are names. // Don't decode the keys which are names.
const exportValues = [...normalAppearance.getKeys()]; const exportValues = normalAppearance
? [...normalAppearance.getKeys()]
: [];
if (exportValues.length === 0) { if (exportValues.length === 0) {
exportValues.push("Off", yes); exportValues.push("Off", yes);
} else if (exportValues.length === 1) { } else if (exportValues.length === 1) {
@ -3470,10 +3535,10 @@ class ButtonWidgetAnnotation extends WidgetAnnotation {
this.data.exportValue = exportValues[1]; this.data.exportValue = exportValues[1];
const checkedAppearance = normalAppearance.get(this.data.exportValue); const checkedAppearance = normalAppearance?.get(this.data.exportValue);
this.checkedAppearance = this.checkedAppearance =
checkedAppearance instanceof BaseStream ? checkedAppearance : null; checkedAppearance instanceof BaseStream ? checkedAppearance : null;
const uncheckedAppearance = normalAppearance.get("Off"); const uncheckedAppearance = normalAppearance?.get("Off");
this.uncheckedAppearance = this.uncheckedAppearance =
uncheckedAppearance instanceof BaseStream ? uncheckedAppearance : null; uncheckedAppearance instanceof BaseStream ? uncheckedAppearance : null;

View File

@ -27,7 +27,15 @@ import {
getNewAnnotationsMap, getNewAnnotationsMap,
numberToString, numberToString,
} from "../core_utils.js"; } from "../core_utils.js";
import { Dict, isName, Name, Ref, RefSet, RefSetCache } from "../primitives.js"; import {
Dict,
isDict,
isName,
Name,
Ref,
RefSet,
RefSetCache,
} from "../primitives.js";
import { incrementalUpdate, writeValue } from "../writer.js"; import { incrementalUpdate, writeValue } from "../writer.js";
import { isArrayEqual, stringToBytes } from "../../shared/util.js"; import { isArrayEqual, stringToBytes } from "../../shared/util.js";
import { NameTree, NumberTree } from "../name_number_tree.js"; import { NameTree, NumberTree } from "../name_number_tree.js";
@ -280,11 +288,7 @@ class PDFEditor {
oldRefMapping.put(oldRef, newRef); oldRefMapping.put(oldRef, newRef);
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
if ( if (isDict(obj, "Page") && !this.currentDocument.pagesMap.has(oldRef)) {
obj instanceof Dict &&
isName(obj.get("Type"), "Page") &&
!this.currentDocument.pagesMap.has(oldRef)
) {
throw new Error( throw new Error(
"Add a deleted page to the document is not supported." "Add a deleted page to the document is not supported."
); );

View File

@ -19,7 +19,7 @@ import {
stringToUTF8String, stringToUTF8String,
warn, warn,
} from "../shared/util.js"; } from "../shared/util.js";
import { Dict, isName, Name, Ref, RefSetCache } from "./primitives.js"; import { Dict, isDict, isName, Name, Ref, RefSetCache } from "./primitives.js";
import { stringToAsciiOrUTF16BE, stringToPDFString } from "./string_utils.js"; import { stringToAsciiOrUTF16BE, stringToPDFString } from "./string_utils.js";
import { BaseStream } from "./base_stream.js"; import { BaseStream } from "./base_stream.js";
import { lookupNormalRect } from "./core_utils.js"; import { lookupNormalRect } from "./core_utils.js";
@ -594,10 +594,7 @@ class StructElementNode {
} }
for (let af of AFs) { for (let af of AFs) {
af = this.xref.fetchIfRef(af); af = this.xref.fetchIfRef(af);
if (!(af instanceof Dict)) { if (!isDict(af, "Filespec")) {
continue;
}
if (!isName(af.get("Type"), "Filespec")) {
continue; continue;
} }
if (!isName(af.get("AFRelationship"), "Supplement")) { if (!isName(af.get("AFRelationship"), "Supplement")) {

View File

@ -439,9 +439,6 @@ class AnnotationElement {
if (horizontalRadius > 0 || verticalRadius > 0) { if (horizontalRadius > 0 || verticalRadius > 0) {
const radius = `calc(${horizontalRadius}px * var(--total-scale-factor)) / calc(${verticalRadius}px * var(--total-scale-factor))`; const radius = `calc(${horizontalRadius}px * var(--total-scale-factor)) / calc(${verticalRadius}px * var(--total-scale-factor))`;
style.borderRadius = radius; style.borderRadius = radius;
} else if (this instanceof RadioButtonWidgetAnnotationElement) {
const radius = `calc(${width}px * var(--total-scale-factor)) / calc(${height}px * var(--total-scale-factor))`;
style.borderRadius = radius;
} }
switch (data.borderStyle.style) { switch (data.borderStyle.style) {
@ -2009,7 +2006,6 @@ class CheckboxWidgetAnnotationElement extends WidgetAnnotationElement {
); );
} }
this._setBackgroundColor(element);
this._setDefaultPropertiesFromJS(element); this._setDefaultPropertiesFromJS(element);
this.container.append(element); this.container.append(element);
@ -2028,7 +2024,9 @@ class RadioButtonWidgetAnnotationElement extends WidgetAnnotationElement {
const data = this.data; const data = this.data;
const id = data.id; const id = data.id;
let value = storage.getValue(id, { let value = storage.getValue(id, {
value: data.fieldValue === data.buttonValue, // A radio without an on-state (`buttonValue === null`, e.g. no /AP) must
// not be checked by default, otherwise `null === null` would select it.
value: data.buttonValue !== null && data.fieldValue === data.buttonValue,
}).value; }).value;
if (typeof value === "string") { if (typeof value === "string") {
// The value has been changed through js and set in annotationStorage. // The value has been changed through js and set in annotationStorage.
@ -2115,7 +2113,6 @@ class RadioButtonWidgetAnnotationElement extends WidgetAnnotationElement {
); );
} }
this._setBackgroundColor(element);
this._setDefaultPropertiesFromJS(element); this._setDefaultPropertiesFromJS(element);
this.container.append(element); this.container.append(element);
@ -4139,18 +4136,44 @@ class AnnotationLayer {
if (!element) { if (!element) {
continue; continue;
} }
if (Array.isArray(canvas)) {
canvas.className = "annotationContent"; for (const cvs of canvas) {
cvs.className = "annotationContent";
cvs.ariaHidden = true;
}
} else {
canvas.className = "annotationContent";
canvas.ariaHidden = true;
}
const toRemove = [];
for (const child of element.children) {
if (child.nodeName === "CANVAS") {
toRemove.push(child);
}
}
for (const child of toRemove) {
child.remove();
}
const firstCanvas = Array.isArray(canvas) ? canvas[0] : canvas;
const { firstChild } = element; const { firstChild } = element;
if (!firstChild) { if (!firstChild) {
element.append(canvas); element.append(firstCanvas);
} else if (firstChild.nodeName === "CANVAS") {
firstChild.replaceWith(canvas);
} else if (!firstChild.classList.contains("annotationContent")) { } else if (!firstChild.classList.contains("annotationContent")) {
firstChild.before(canvas); firstChild.before(firstCanvas);
} else { } else {
firstChild.after(canvas); firstChild.after(firstCanvas);
} }
if (Array.isArray(canvas)) {
let lastCanvas = firstCanvas;
for (let i = 1, ii = canvas.length; i < ii; i++) {
lastCanvas.after(canvas[i]);
lastCanvas = canvas[i];
}
}
// Drop only the entries we inserted; keep ones whose element isn't in the
// DOM yet so a later refresh can still pick them up instead of losing
// them.
this.#annotationCanvasMap.delete(id);
const editableAnnotation = this.#editableAnnotations.get(id); const editableAnnotation = this.#editableAnnotations.get(id);
if (!editableAnnotation) { if (!editableAnnotation) {
@ -4168,7 +4191,12 @@ class AnnotationLayer {
editableAnnotation.canvas = canvas; editableAnnotation.canvas = canvas;
} }
} }
this.#annotationCanvasMap.clear(); }
// Move any pending annotation canvases (e.g. higher-resolution ones rendered
// by the detail view) into their elements.
refreshCanvases() {
this.#setAnnotationCanvasMap();
} }
getEditableAnnotations() { getEditableAnnotations() {

View File

@ -3630,7 +3630,15 @@ class CanvasGraphics {
} }
} }
beginAnnotation(opIdx, id, rect, transform, matrix, hasOwnCanvas) { beginAnnotation(
opIdx,
id,
rect,
transform,
matrix,
hasOwnCanvas,
canvasName
) {
// The annotations are drawn just after the page content. // The annotations are drawn just after the page content.
// The page content drawing can potentially have set a transform, // The page content drawing can potentially have set a transform,
// a clipping path, whatever... // a clipping path, whatever...
@ -3673,7 +3681,26 @@ class CanvasGraphics {
canvasHeight canvasHeight
); );
const { canvas, context } = this.annotationCanvas; const { canvas, context } = this.annotationCanvas;
this.annotationCanvasMap.set(id, canvas); if (canvasName) {
let canvases = this.annotationCanvasMap.get(id);
if (!canvases) {
canvases = [];
this.annotationCanvasMap.set(id, canvases);
}
canvas.setAttribute("data-canvas-name", canvasName);
// Replace any same-named canvas from a previous render so stale
// low-resolution canvases don't pile up across zooms.
const index = canvases.findIndex(
c => c.getAttribute("data-canvas-name") === canvasName
);
if (index === -1) {
canvases.push(canvas);
} else {
canvases[index] = canvas;
}
} else {
this.annotationCanvasMap.set(id, canvas);
}
this.annotationCanvas.savedCtx = this.ctx; this.annotationCanvas.savedCtx = this.ctx;
this.ctx = context; this.ctx = context;
this.ctx.save(); this.ctx.save();

View File

@ -190,7 +190,6 @@ class PageViewport {
* @returns {Array} Array containing `x`- and `y`-coordinates of the * @returns {Array} Array containing `x`- and `y`-coordinates of the
* point in the viewport coordinate space. * point in the viewport coordinate space.
* @see {@link convertToPdfPoint} * @see {@link convertToPdfPoint}
* @see {@link convertToViewportRectangle}
*/ */
convertToViewportPoint(x, y) { convertToViewportPoint(x, y) {
const p = [x, y]; const p = [x, y];
@ -198,21 +197,6 @@ class PageViewport {
return p; return p;
} }
/**
* Converts PDF rectangle to the viewport coordinates.
* @param {Array} rect - The xMin, yMin, xMax and yMax coordinates.
* @returns {Array} Array containing corresponding coordinates of the
* rectangle in the viewport coordinate space.
* @see {@link convertToViewportPoint}
*/
convertToViewportRectangle(rect) {
const topLeft = [rect[0], rect[1]];
Util.applyTransform(topLeft, this.transform);
const bottomRight = [rect[2], rect[3]];
Util.applyTransform(bottomRight, this.transform);
return [topLeft[0], topLeft[1], bottomRight[0], bottomRight[1]];
}
/** /**
* Converts viewport coordinates to the PDF location. For examples, useful * Converts viewport coordinates to the PDF location. For examples, useful
* for converting canvas pixel location into PDF one. * for converting canvas pixel location into PDF one.

View File

@ -68,4 +68,26 @@
color: red; color: red;
font-size: 10px; font-size: 10px;
} }
.buttonWidgetAnnotation:is(.checkBox, .radioButton) {
img[data-canvas-name="checked"] {
&:has(~ input:checked) {
display: block;
}
&:has(~ input:not(:checked)) {
display: none;
}
}
img[data-canvas-name="unchecked"] {
&:has(~ input:checked) {
display: none;
}
&:has(~ input:not(:checked)) {
display: block;
}
}
}
} }

View File

@ -94,6 +94,7 @@ async function writeSVG(svgElement, ctx) {
setTimeout(resolve, 10); setTimeout(resolve, 10);
}); });
} }
return loadImage(svg_xml, ctx); return loadImage(svg_xml, ctx);
} }
@ -150,21 +151,40 @@ async function inlineImages(node, silentErrors = false) {
async function convertCanvasesToImages(annotationCanvasMap, outputScale) { async function convertCanvasesToImages(annotationCanvasMap, outputScale) {
const results = new Map(); const results = new Map();
const promises = []; const promises = [];
const canvasToImage = (canvas, key) => {
const { promise, resolve } = Promise.withResolvers();
promises.push(promise);
canvas.toBlob(blob => {
const image = document.createElement("img");
image.classList.add("wasCanvas");
image.onload = function () {
image.style.width = Math.floor(image.width / outputScale) + "px";
resolve();
};
const canvasName = canvas.getAttribute("data-canvas-name");
if (canvasName) {
image.setAttribute("data-canvas-name", canvasName);
let images = results.get(key);
if (!images) {
images = [];
results.set(key, images);
}
images.push(image);
} else {
results.set(key, image);
}
image.src = URL.createObjectURL(blob);
});
};
for (const [key, canvas] of annotationCanvasMap) { for (const [key, canvas] of annotationCanvasMap) {
promises.push( if (Array.isArray(canvas)) {
new Promise(resolve => { for (const canvasItem of canvas) {
canvas.toBlob(blob => { canvasToImage(canvasItem, key);
const image = document.createElement("img"); }
image.classList.add("wasCanvas"); } else {
image.onload = function () { canvasToImage(canvas, key);
image.style.width = Math.floor(image.width / outputScale) + "px"; }
resolve();
};
results.set(key, image);
image.src = URL.createObjectURL(blob);
});
})
);
} }
await Promise.all(promises); await Promise.all(promises);
return results; return results;

View File

@ -122,12 +122,12 @@ describe("Checkbox annotation", () => {
for (const selector of selectors) { for (const selector of selectors) {
await page.click(selector); await page.click(selector);
await page.waitForFunction( await page.waitForFunction(
`document.querySelector('${selector} > :first-child').checked` `document.querySelector('${selector} input').checked`
); );
for (const otherSelector of selectors) { for (const otherSelector of selectors) {
const checked = await page.$eval( const checked = await page.$eval(
`${otherSelector} > :first-child`, `${otherSelector} input`,
el => el.checked el => el.checked
); );
expect(checked) expect(checked)
@ -157,7 +157,7 @@ describe("Checkbox annotation", () => {
const selector = getAnnotationSelector("7R"); const selector = getAnnotationSelector("7R");
await page.click(selector); await page.click(selector);
await page.waitForFunction( await page.waitForFunction(
`document.querySelector('${selector} > :first-child').checked` `document.querySelector('${selector} input').checked`
); );
expect(true).withContext(`In ${browserName}`).toEqual(true); expect(true).withContext(`In ${browserName}`).toEqual(true);
}) })
@ -185,7 +185,7 @@ describe("Checkbox annotation", () => {
for (const selector of selectors) { for (const selector of selectors) {
await page.click(selector); await page.click(selector);
await page.waitForFunction( await page.waitForFunction(
`document.querySelector('${selector} > :first-child').checked` `document.querySelector('${selector} input').checked`
); );
} }
}) })

View File

@ -2413,49 +2413,6 @@ describe("Interaction", () => {
}); });
}); });
describe("Change radio property", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait("bug1922766.pdf", getAnnotationSelector("44R"));
});
afterEach(async () => {
await closePages(pages);
});
it("must check that a change on a radio implies the change on all the radio in the group", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await waitForScripting(page);
const checkColor = async color => {
await waitForSandboxTrip(page);
for (const i of [40, 41, 42, 43]) {
const bgColor = await page.$eval(
`[data-element-id='${i}R']`,
el => getComputedStyle(el).backgroundColor
);
expect(bgColor)
.withContext(`In ${browserName}`)
.toEqual(`rgb(${color.join(", ")})`);
}
};
await checkColor([255, 0, 0]);
await page.click(getAnnotationSelector("44R"));
await checkColor([0, 0, 255]);
await page.click(getAnnotationSelector("44R"));
await checkColor([255, 0, 0]);
await page.click(getAnnotationSelector("43R"));
await waitForSandboxTrip(page);
await page.click(getAnnotationSelector("44R"));
await checkColor([0, 0, 255]);
})
);
});
});
describe("Date creation must be timezone consistent", () => { describe("Date creation must be timezone consistent", () => {
let pages; let pages;

View File

@ -933,3 +933,5 @@
!cidfont_cmap_overflow.pdf !cidfont_cmap_overflow.pdf
!jbig2_file_header.pdf !jbig2_file_header.pdf
!text_field_own_canvas_calc.pdf !text_field_own_canvas_calc.pdf
!bug1802506.pdf
!checkbox_no_appearance.pdf

BIN
test/pdfs/bug1802506.pdf Normal file

Binary file not shown.

View File

@ -0,0 +1,30 @@
%PDF-1.7
%ÿÿÿÿ
1 0 obj
<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /NeedAppearances true >> >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 5 0 R] /Resources << >> >>
endobj
4 0 obj
<< /Type /Annot /Subtype /Widget /FT /Btn /T (cbOn) /Rect [30 140 70 180] /V /Yes /AS /Yes >>
endobj
5 0 obj
<< /Type /Annot /Subtype /Widget /FT /Btn /T (cbOff) /Rect [90 140 130 180] /V /Off /AS /Off >>
endobj
xref
0 6
0000000000 65535 f
0000000015 00000 n
0000000124 00000 n
0000000181 00000 n
0000000291 00000 n
0000000400 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
511
%%EOF

View File

@ -14387,5 +14387,23 @@
"rounds": 1, "rounds": 1,
"link": true, "link": true,
"type": "eq" "type": "eq"
},
{
"id": "bug1802506",
"file": "pdfs/bug1802506.pdf",
"md5": "ed56da1780b8480262c7329c4419fbb5",
"rounds": 1,
"type": "eq",
"annotations": true,
"forms": true
},
{
"id": "checkbox_no_appearance",
"file": "pdfs/checkbox_no_appearance.pdf",
"md5": "216ff8ea24c470421ff1c528d805caea",
"rounds": 1,
"type": "eq",
"annotations": true,
"forms": true
} }
] ]

View File

@ -1776,7 +1776,7 @@ describe("annotation", function () {
expect(opList.argsArray[0]).toEqual([ expect(opList.argsArray[0]).toEqual([
"271R", "271R",
[0, 0, 32, 10], [0, 0, 32, 10],
[32, 0, 0, 10, 0, 0], [1, 0, 0, 1, 0, 0],
[1, 0, 0, 1, 0, 0], [1, 0, 0, 1, 0, 0],
false, false,
]); ]);
@ -2607,6 +2607,44 @@ describe("annotation", function () {
expect(opList.argsArray[3][0][0].unicode).toEqual("4"); expect(opList.argsArray[3][0][0].unicode).toEqual("4");
}); });
it("should synthesize a checked appearance for checkboxes without an /AP", async function () {
buttonWidgetDict.set("V", Name.get("Checked"));
buttonWidgetDict.set("Rect", [0, 0, 20, 20]);
// Note: no /AP entry, so a default appearance must be synthesized.
const buttonWidgetRef = Ref.get(124, 0);
const xref = new XRefMock([
{ ref: buttonWidgetRef, data: buttonWidgetDict },
]);
const task = new WorkerTask("test checkbox without /AP");
const checkboxEvaluator = partialEvaluator.clone({ ignoreErrors: true });
const annotation = await AnnotationFactory.create(
xref,
buttonWidgetRef,
annotationGlobalsMock,
idFactoryMock
);
expect(annotation.data.checkBox).toEqual(true);
expect(annotation.data.exportValue).toEqual("Checked");
const { opList } = await annotation.getOperatorList(
checkboxEvaluator,
task,
RenderingIntentFlag.DISPLAY | RenderingIntentFlag.ANNOTATIONS_FORMS,
new Map()
);
// A checkmark is drawn on its own dedicated "checked" canvas.
expect(opList.fnArray[0]).toEqual(OPS.beginAnnotation);
expect(opList.fnArray.at(-1)).toEqual(OPS.endAnnotation);
expect(opList.fnArray).toContain(OPS.showText);
const [id, , , , isUsingOwnCanvas, canvasName] = opList.argsArray[0];
expect(id).toEqual("124R");
expect(isUsingOwnCanvas).toEqual(true);
expect(canvasName).toEqual("checked");
});
it("should render checkboxes for printing", async function () { it("should render checkboxes for printing", async function () {
const appearanceStatesDict = new Dict(); const appearanceStatesDict = new Dict();
const normalAppearanceDict = new Dict(); const normalAppearanceDict = new Dict();
@ -2685,7 +2723,7 @@ describe("annotation", function () {
expect(opList2.argsArray[0]).toEqual([ expect(opList2.argsArray[0]).toEqual([
"124R", "124R",
[0, 0, 0, 0], [0, 0, 0, 0],
[0, 0, 0, 0, 0, 0], [1, 0, 0, 1, 0, 0],
[1, 0, 0, 1, 0, 0], [1, 0, 0, 1, 0, 0],
false, false,
]); ]);
@ -3084,7 +3122,7 @@ describe("annotation", function () {
expect(opList2.argsArray[0]).toEqual([ expect(opList2.argsArray[0]).toEqual([
"124R", "124R",
[0, 0, 0, 0], [0, 0, 0, 0],
[0, 0, 0, 0, 0, 0], [1, 0, 0, 1, 0, 0],
[1, 0, 0, 1, 0, 0], [1, 0, 0, 1, 0, 0],
false, false,
]); ]);
@ -3147,7 +3185,7 @@ describe("annotation", function () {
expect(opList.argsArray[0]).toEqual([ expect(opList.argsArray[0]).toEqual([
"124R", "124R",
[0, 0, 0, 0], [0, 0, 0, 0],
[0, 0, 0, 0, 0, 0], [1, 0, 0, 1, 0, 0],
[1, 0, 0, 1, 0, 0], [1, 0, 0, 1, 0, 0],
false, false,
]); ]);

View File

@ -17,6 +17,7 @@
color-scheme: only light; color-scheme: only light;
--annotation-unfocused-field-background: url("data:image/svg+xml;charset=UTF-8,<svg width='1px' height='1px' xmlns='http://www.w3.org/2000/svg'><rect width='100%' height='100%' style='fill:rgba(0, 54, 255, 0.13);'/></svg>"); --annotation-unfocused-field-background: url("data:image/svg+xml;charset=UTF-8,<svg width='1px' height='1px' xmlns='http://www.w3.org/2000/svg'><rect width='100%' height='100%' style='fill:rgba(0, 54, 255, 0.13);'/></svg>");
--annotation-unfocused-field-filter: url("data:image/svg+xml;charset=UTF-8,<svg xmlns='http://www.w3.org/2000/svg'><filter id='pdfjsFillableField' x='0%' y='0%' width='100%' height='100%' color-interpolation-filters='sRGB'><feFlood flood-color='rgb(0,54,255)' flood-opacity='0.13' result='f'/><feComposite in='f' in2='SourceGraphic' operator='over'/></filter></svg>#pdfjsFillableField");
--input-focus-border-color: Highlight; --input-focus-border-color: Highlight;
--input-focus-outline: 1px solid Canvas; --input-focus-outline: 1px solid Canvas;
--input-unfocused-border-color: transparent; --input-unfocused-border-color: transparent;
@ -208,10 +209,6 @@
padding: 0; padding: 0;
} }
.buttonWidgetAnnotation.radioButton input {
border-radius: 50%;
}
.textWidgetAnnotation textarea { .textWidgetAnnotation textarea {
resize: none; resize: none;
} }
@ -259,36 +256,34 @@
outline: var(--input-focus-outline); outline: var(--input-focus-outline);
} }
.buttonWidgetAnnotation.checkBox input:checked::before, .buttonWidgetAnnotation:is(.checkBox, .radioButton) {
.buttonWidgetAnnotation.checkBox input:checked::after, [data-canvas-name] {
.buttonWidgetAnnotation.radioButton input:checked::before { filter: var(--annotation-unfocused-field-filter);
background-color: CanvasText; }
content: "";
display: block;
position: absolute;
}
.buttonWidgetAnnotation.checkBox input:checked::before, &:focus-within [data-canvas-name] {
.buttonWidgetAnnotation.checkBox input:checked::after { filter: none;
height: 80%; }
left: 45%;
width: 1px;
}
.buttonWidgetAnnotation.checkBox input:checked::before { [data-canvas-name="checked"] {
transform: rotate(45deg); &:has(~ input:checked) {
} display: block;
}
.buttonWidgetAnnotation.checkBox input:checked::after { &:has(~ input:not(:checked)) {
transform: rotate(-45deg); display: none;
} }
}
.buttonWidgetAnnotation.radioButton input:checked::before { [data-canvas-name="unchecked"] {
border-radius: 50%; &:has(~ input:checked) {
height: 50%; display: none;
left: 25%; }
top: 25%;
width: 50%; &:has(~ input:not(:checked)) {
display: block;
}
}
} }
.textWidgetAnnotation input.comb { .textWidgetAnnotation input.comb {
@ -317,6 +312,17 @@
appearance: none; appearance: none;
} }
.buttonWidgetAnnotation:is(.checkBox, .radioButton):has(
[data-canvas-name="checked"]
)
input:checked,
.buttonWidgetAnnotation:is(.checkBox, .radioButton):has(
[data-canvas-name="unchecked"]
)
input:not(:checked) {
background-image: none;
}
.fileAttachmentAnnotation .popupTriggerArea { .fileAttachmentAnnotation .popupTriggerArea {
height: 100%; height: 100%;
width: 100%; width: 100%;

View File

@ -224,6 +224,10 @@ class AnnotationLayerBuilder {
this.#eventAC = null; this.#eventAC = null;
} }
refreshCanvases() {
this.annotationLayer?.refreshCanvases();
}
hide() { hide() {
if (!this.div) { if (!this.div) {
return; return;

View File

@ -297,6 +297,7 @@ class PDFPageDetailView extends BasePDFPageView {
this.canvas = prevCanvas; this.canvas = prevCanvas;
}, },
() => { () => {
this.pageView._refreshAnnotationLayer();
this.dispatchPageRendered( this.dispatchPageRendered(
/* cssTransform */ false, /* cssTransform */ false,
/* isDetailView */ true /* isDetailView */ true

View File

@ -484,6 +484,17 @@ class PDFPageView extends BasePDFPageView {
} }
} }
// The detail view re-renders checkbox/radio appearances at a higher
// resolution into the shared `annotationCanvasMap`; move them into the
// annotation layer so they replace the lower-resolution ones. This only
// consumes pending canvases (no re-render), and is a no-op until their
// elements exist, so it's safe to call while the layer is still rendering.
_refreshAnnotationLayer() {
if (this._annotationCanvasMap?.size) {
this.annotationLayer?.refreshCanvases();
}
}
async #renderAnnotationEditorLayer() { async #renderAnnotationEditorLayer() {
let error = null; let error = null;
try { try {