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

[api-minor] Add support for toggling of Optional Content in the viewer (issue 12096) #12170

Merged
merged 3 commits into from
Aug 30, 2020
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
5 changes: 4 additions & 1 deletion l10n/en-US/viewer.properties
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,20 @@ print_progress_close=Cancel
# (the _label strings are alt text for the buttons, the .title strings are
# tooltips)
toggle_sidebar.title=Toggle Sidebar
toggle_sidebar_notification.title=Toggle Sidebar (document contains outline/attachments)
toggle_sidebar_notification2.title=Toggle Sidebar (document contains outline/attachments/layers)
toggle_sidebar_label=Toggle Sidebar
document_outline.title=Show Document Outline (double-click to expand/collapse all items)
document_outline_label=Document Outline
attachments.title=Show Attachments
attachments_label=Attachments
layers.title=Show Layers (double-click to reset all layers to the default state)
layers_label=Layers
thumbs.title=Show Thumbnails
thumbs_label=Thumbnails
findbar.title=Find in Document
findbar_label=Find

additional_layers=Additional Layers
# LOCALIZATION NOTE (page_canvas): "{{page}}" will be replaced by the page number.
page_canvas=Page {{page}}
# Thumbnails panel item (tooltip and alt text for images)
Expand Down
5 changes: 4 additions & 1 deletion l10n/sv-SE/viewer.properties
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,20 @@ print_progress_close=Avbryt
# (the _label strings are alt text for the buttons, the .title strings are
# tooltips)
toggle_sidebar.title=Visa/dölj sidofält
toggle_sidebar_notification.title=Visa/dölj sidofält (dokument innehåller översikt/bilagor)
toggle_sidebar_notification2.title=Visa/dölj sidofält (dokument innehåller översikt/bilagor/lager)
toggle_sidebar_label=Visa/dölj sidofält
document_outline.title=Visa dokumentdisposition (dubbelklicka för att expandera/komprimera alla objekt)
document_outline_label=Dokumentöversikt
attachments.title=Visa Bilagor
attachments_label=Bilagor
layers.title=Visa lager (dubbelklicka för att återställa alla lager till ursrungligt läge)
layers_label=Lager
thumbs.title=Visa miniatyrer
thumbs_label=Miniatyrer
findbar.title=Sök i dokument
findbar_label=Sök

additional_layers=Ytterligare lager
# LOCALIZATION NOTE (page_canvas): "{{page}}" will be replaced by the page number.
page_canvas=Sida {{page}}
# Thumbnails panel item (tooltip and alt text for images)
Expand Down
63 changes: 63 additions & 0 deletions src/core/obj.js
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,67 @@ class Catalog {
return onParsed;
}

function parseOrder(refs, nestedLevels = 0) {
Copy link
Contributor

Choose a reason for hiding this comment

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

The first object in a subarray of the Order entry can be also an OCG instead of a string. The OCG becomes an expandable node. If it is set to invisible, all child nodes should get grayed out. See these files:
PDFill_layers.pdf
pdflib_hierarchical_layers.pdf
Also the mutually exclusive feature of radio buttons (RBGroups) and locked OCGs seems to be still unimplemented, if I'm right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The first object in a subarray of the Order entry can be also an OCG instead of a string. The OCG becomes an expandable node.

Very interesting, I hadn't seen those types of PDF documents when writing the patches.
I'll try to see how difficult implementing that would be; or if we could possibly defer that for a follow-up PR.

Also the mutually exclusive feature of radio buttons (RBGroups) and locked OCGs seems to be still unimplemented, if I'm right?

Yes, those weren't implemented in PR #12095 and here I simply implemented the bare minimum of additional "core" functionality to get the viewer integration to work OK.
I'd really prefer if those things were implemented separately though, since this PR is already quite large (and those features also don't seem that common); there's also the question if we'd even want to respect the "locked OCGs" in the viewer.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The first object in a subarray of the Order entry can be also an OCG instead of a string. The OCG becomes an expandable node.

Very interesting, I hadn't seen those types of PDF documents when writing the patches.
I'll try to see how difficult implementing that would be; or if we could possibly defer that for a follow-up PR.

Having looking (quickly) at this, I'd really prefer if we could defer that to a follow-up patch instead.

if (!Array.isArray(refs)) {
return null;
}
const order = [];

for (const value of refs) {
if (isRef(value) && contentGroupRefs.includes(value)) {
parsedOrderRefs.put(value); // Handle "hidden" groups, see below.

order.push(value.toString());
continue;
}
// Handle nested /Order arrays (see e.g. issue 9462 and bug 1240641).
const nestedOrder = parseNestedOrder(value, nestedLevels);
if (nestedOrder) {
order.push(nestedOrder);
}
}

if (nestedLevels > 0) {
return order;
}
const hiddenGroups = [];
for (const groupRef of contentGroupRefs) {
if (parsedOrderRefs.has(groupRef)) {
continue;
}
hiddenGroups.push(groupRef.toString());
}
if (hiddenGroups.length) {
order.push({ name: null, order: hiddenGroups });
}

return order;
}

function parseNestedOrder(ref, nestedLevels) {
if (++nestedLevels > MAX_NESTED_LEVELS) {
warn("parseNestedOrder - reached MAX_NESTED_LEVELS.");
return null;
}
const value = xref.fetchIfRef(ref);
if (!Array.isArray(value)) {
return null;
}
const nestedName = xref.fetchIfRef(value[0]);
if (typeof nestedName !== "string") {
return null;
}
const nestedOrder = parseOrder(value.slice(1), nestedLevels);
if (!nestedOrder || !nestedOrder.length) {
return null;
}
return { name: stringToPDFString(nestedName), order: nestedOrder };
}

const xref = this.xref,
parsedOrderRefs = new RefSet(),
MAX_NESTED_LEVELS = 10;

return {
name: isString(config.get("Name"))
? stringToPDFString(config.get("Name"))
Expand All @@ -367,6 +428,8 @@ class Catalog {
: null,
on: parseOnOff(config.get("ON")),
off: parseOnOff(config.get("OFF")),
order: parseOrder(config.get("Order")),
groups: null,
};
}

Expand Down
6 changes: 3 additions & 3 deletions src/display/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -778,9 +778,9 @@ class PDFDocumentProxy {
}

/**
* @returns {Promise<OptionalContentConfig | null>} A promise that is resolved
* with an {@link OptionalContentConfig} that has all the optional content
* groups, or `null` if the document does not have any.
* @returns {Promise<OptionalContentConfig>} A promise that is resolved with
* an {@link OptionalContentConfig} that contains all the optional content
* groups (assuming that the document has any).
*/
getOptionalContentConfig() {
return this._transport.getOptionalContentConfig();
Expand Down
61 changes: 46 additions & 15 deletions src/display/optional_content_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,42 +26,44 @@ class OptionalContentConfig {
constructor(data) {
this.name = null;
this.creator = null;
this.groups = new Map();
this._order = null;
this._groups = new Map();

if (data === null) {
return;
}
this.name = data.name;
this.creator = data.creator;
this._order = data.order;
for (const group of data.groups) {
this.groups.set(
this._groups.set(
group.id,
new OptionalContentGroup(group.name, group.intent)
);
}

if (data.baseState === "OFF") {
for (const group of this.groups) {
for (const group of this._groups) {
group.visible = false;
}
}

for (const on of data.on) {
this.groups.get(on).visible = true;
this._groups.get(on).visible = true;
}

for (const off of data.off) {
this.groups.get(off).visible = false;
this._groups.get(off).visible = false;
}
}

isVisible(group) {
if (group.type === "OCG") {
if (!this.groups.has(group.id)) {
if (!this._groups.has(group.id)) {
warn(`Optional content group not found: ${group.id}`);
return true;
}
return this.groups.get(group.id).visible;
return this._groups.get(group.id).visible;
} else if (group.type === "OCMD") {
// Per the spec, the expression should be preferred if available. Until
// we implement this, just fallback to using the group policy for now.
Expand All @@ -71,44 +73,44 @@ class OptionalContentConfig {
if (!group.policy || group.policy === "AnyOn") {
// Default
for (const id of group.ids) {
if (!this.groups.has(id)) {
if (!this._groups.has(id)) {
warn(`Optional content group not found: ${id}`);
return true;
}
if (this.groups.get(id).visible) {
if (this._groups.get(id).visible) {
return true;
}
}
return false;
} else if (group.policy === "AllOn") {
for (const id of group.ids) {
if (!this.groups.has(id)) {
if (!this._groups.has(id)) {
warn(`Optional content group not found: ${id}`);
return true;
}
if (!this.groups.get(id).visible) {
if (!this._groups.get(id).visible) {
return false;
}
}
return true;
} else if (group.policy === "AnyOff") {
for (const id of group.ids) {
if (!this.groups.has(id)) {
if (!this._groups.has(id)) {
warn(`Optional content group not found: ${id}`);
return true;
}
if (!this.groups.get(id).visible) {
if (!this._groups.get(id).visible) {
return true;
}
}
return false;
} else if (group.policy === "AllOff") {
for (const id of group.ids) {
if (!this.groups.has(id)) {
if (!this._groups.has(id)) {
warn(`Optional content group not found: ${id}`);
return true;
}
if (this.groups.get(id).visible) {
if (this._groups.get(id).visible) {
return false;
}
}
Expand All @@ -120,6 +122,35 @@ class OptionalContentConfig {
warn(`Unknown group type ${group.type}.`);
return true;
}

setVisibility(id, visible = true) {
if (!this._groups.has(id)) {
warn(`Optional content group not found: ${id}`);
return;
}
this._groups.get(id).visible = !!visible;
}

getOrder() {
Copy link
Contributor

Choose a reason for hiding this comment

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

For this and getGroups returning empty arrays instead of null leaves a bit better experience for callers since they don't have to think about null check and empty arrays.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's already very common, in the API, for various methods to return null (or a Promise that resolves with null) in cases when no data is available. Hence I figured that simply following that pattern made sense here as well.

Furthermore, if an empty array was returned that wouldn't really help all that much as far as I'm concerned, since you'd then need to check for length === 0 when deciding e.g. if the layersView should be created (given that you don't want an empty one rendered in the viewer).
Please note https://github.com/mozilla/pdf.js/pull/12170/files#diff-b8f8b0db0a8e69f4dd1da2eb97199d01R126-R130 which nicely mirrors the existing code in

if (!outline) {
this._dispatchEvent(/* outlineCount = */ 0);
return;
}
and also
if (!attachments) {
this._dispatchEvent(/* attachmentsCount = */ 0);
return;
}

if (!this._groups.size) {
return null;
}
if (this._order) {
return this._order.slice();
}
return Array.from(this._groups.keys());
}

getGroups() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks to be unused, is this needed in the future?

Copy link
Collaborator Author

@Snuffleupagus Snuffleupagus Aug 22, 2020

Choose a reason for hiding this comment

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

I figured that it'd be helpful to provide a way to get all of the groups directly, in cases where an API user don't care about the /Order information; hence why I added this method.

if (!this._groups.size) {
return null;
}
return Object.fromEntries(this._groups);
}

getGroup(id) {
return this._groups.get(id) || null;
}
}

export { OptionalContentConfig };
3 changes: 3 additions & 0 deletions test/driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,8 @@ var Driver = (function DriverClosure() {
loadingTask.promise.then(
doc => {
task.pdfDoc = doc;
task.optionalContentConfigPromise = doc.getOptionalContentConfig();

this._nextPage(task, failure);
},
err => {
Expand Down Expand Up @@ -605,6 +607,7 @@ var Driver = (function DriverClosure() {
canvasContext: ctx,
viewport,
renderInteractiveForms: renderForms,
optionalContentConfigPromise: task.optionalContentConfigPromise,
};
if (renderPrint) {
const annotationStorage = task.annotationStorage;
Expand Down
Loading