mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-06-24 17:05:47 +02:00
Add support for Screen annotations playing embedded media
Screen annotations whose rendition action resolves to an embedded audio/video file now play through the same play-button overlay as RichMedia. Factor the shared resolution logic into a MediaAnnotation base (used by both RichMedia and Screen). It fixes #6078 and #2787.
This commit is contained in:
parent
d71fe9025d
commit
d8ea2afe47
@ -19,6 +19,7 @@ import {
|
||||
AnnotationEditorType,
|
||||
AnnotationFieldFlag,
|
||||
AnnotationFlag,
|
||||
AnnotationRenditionOperation,
|
||||
AnnotationReplyType,
|
||||
AnnotationType,
|
||||
assert,
|
||||
@ -290,6 +291,9 @@ class AnnotationFactory {
|
||||
case "RichMedia":
|
||||
return new RichMediaAnnotation(parameters);
|
||||
|
||||
case "Screen":
|
||||
return new ScreenAnnotation(parameters);
|
||||
|
||||
default:
|
||||
if (!collectFields) {
|
||||
if (!subtype) {
|
||||
@ -5457,13 +5461,124 @@ class FileAttachmentAnnotation extends MarkupAnnotation {
|
||||
}
|
||||
}
|
||||
|
||||
class RichMediaAnnotation extends Annotation {
|
||||
/**
|
||||
* Shared base for annotations that play an embedded audio/video clip:
|
||||
* `RichMedia` (via `RichMediaContent`) and `Screen` (via a rendition action).
|
||||
* Both resolve a single embedded media file and expose it identically through
|
||||
* `data.richMedia`, so the display layer can render them with one element.
|
||||
*/
|
||||
class MediaAnnotation extends Annotation {
|
||||
// The MIME types we can build a `<video>`/`<audio>` element for.
|
||||
static #MEDIA_MIME_TYPE_RE = /^(?:video|audio)\//;
|
||||
|
||||
constructor(params) {
|
||||
super(params);
|
||||
|
||||
// No HTML element until a playable asset is found below by the subclass.
|
||||
this.data.noHTML = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose a resolved embedded media asset as `data.richMedia`.
|
||||
*
|
||||
* @param {Object} asset
|
||||
* @param {Ref | null} asset.assetRef
|
||||
* Reference to the file-spec dictionary (or, for an inline file-spec, the
|
||||
* embedded-file stream); used to lazily fetch the bytes on the main thread.
|
||||
* @param {Dict} asset.assetDict
|
||||
* The file-spec (or stream) dictionary, used to locate the embedded file
|
||||
* when `assetRef` isn't itself a reference.
|
||||
* @param {string} asset.filename
|
||||
* @param {string} asset.contentType
|
||||
* @param {Catalog} [catalog]
|
||||
*/
|
||||
_setMediaData({ assetRef, assetDict, filename, contentType }, catalog) {
|
||||
let contentRef = assetRef;
|
||||
if (!(contentRef instanceof Ref)) {
|
||||
contentRef = FileSpec.pickPlatformItem(
|
||||
assetDict.get("EF"),
|
||||
/* raw = */ true
|
||||
);
|
||||
}
|
||||
const fileId =
|
||||
contentRef instanceof Ref
|
||||
? catalog?.getAttachmentIdForAnnotation(contentRef)
|
||||
: undefined;
|
||||
|
||||
this.data.noHTML = false;
|
||||
this.data.richMedia = { fileId, filename, contentType };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the MIME type used to build the `<video>`/`<audio>` element.
|
||||
*
|
||||
* When the media dictionary provides an explicit type (e.g. a MediaClip
|
||||
* `/CT`), it's honored if it names an audio/video type. Otherwise, per the
|
||||
* spec (ISO 32000-2, 7.11.4) an embedded file stream declares its MIME type
|
||||
* through its own `/Subtype`, with characters not allowed in a name
|
||||
* hex-escaped (e.g. `/video#2Fmp4` -> `video/mp4`). We trust that when it
|
||||
* names an audio/video type, and otherwise fall back to mapping the filename
|
||||
* extension. Returns `null` when the asset isn't a recognized audio/video
|
||||
* type (e.g. Flash `.swf` or 3D models), so we don't build a player that
|
||||
* can't play anything.
|
||||
*
|
||||
* @param {Dict} assetDict
|
||||
* @param {string} filename
|
||||
* @param {string | null} [contentType]
|
||||
* @returns {string | null}
|
||||
*/
|
||||
static _getContentType(assetDict, filename, contentType = null) {
|
||||
if (
|
||||
typeof contentType === "string" &&
|
||||
MediaAnnotation.#MEDIA_MIME_TYPE_RE.test(contentType)
|
||||
) {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
// The embedded file stream is keyed like a file-spec platform item.
|
||||
const stream = FileSpec.pickPlatformItem(assetDict.get("EF"));
|
||||
const subtype =
|
||||
stream instanceof BaseStream ? stream.dict?.get("Subtype") : null;
|
||||
if (
|
||||
subtype instanceof Name &&
|
||||
MediaAnnotation.#MEDIA_MIME_TYPE_RE.test(subtype.name)
|
||||
) {
|
||||
return subtype.name;
|
||||
}
|
||||
|
||||
const ext = filename.split(".").at(-1)?.toLowerCase();
|
||||
switch (ext) {
|
||||
case "mp4":
|
||||
case "m4v":
|
||||
return "video/mp4";
|
||||
case "webm":
|
||||
return "video/webm";
|
||||
case "ogv":
|
||||
return "video/ogg";
|
||||
case "mov":
|
||||
return "video/quicktime";
|
||||
case "mp3":
|
||||
return "audio/mpeg";
|
||||
case "m4a":
|
||||
return "audio/mp4";
|
||||
case "wav":
|
||||
return "audio/wav";
|
||||
case "oga":
|
||||
case "ogg":
|
||||
return "audio/ogg";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RichMediaAnnotation extends MediaAnnotation {
|
||||
constructor(params) {
|
||||
super(params);
|
||||
|
||||
const { dict, xref, annotationGlobals } = params;
|
||||
/** @type {{catalog?: Catalog}} */
|
||||
const { catalog } = annotationGlobals.pdfManager.pdfDocument;
|
||||
|
||||
const content = dict.get("RichMediaContent");
|
||||
if (!(content instanceof Dict)) {
|
||||
@ -5475,24 +5590,8 @@ class RichMediaAnnotation extends Annotation {
|
||||
warn("RichMedia annotation has no playable asset.");
|
||||
return;
|
||||
}
|
||||
const { assetRef, assetDict, filename, contentType } = asset;
|
||||
|
||||
let contentRef = assetRef;
|
||||
if (!(contentRef instanceof Ref)) {
|
||||
contentRef = FileSpec.pickPlatformItem(
|
||||
assetDict.get("EF"),
|
||||
/* raw = */ true
|
||||
);
|
||||
}
|
||||
const fileId =
|
||||
contentRef instanceof Ref
|
||||
? annotationGlobals.pdfManager.pdfDocument.catalog?.getAttachmentIdForAnnotation(
|
||||
contentRef
|
||||
)
|
||||
: undefined;
|
||||
|
||||
this.data.noHTML = false;
|
||||
this.data.richMedia = { fileId, filename, contentType };
|
||||
this._setMediaData(asset, catalog);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -5543,11 +5642,12 @@ class RichMediaAnnotation extends Annotation {
|
||||
if (!(asset instanceof Dict)) {
|
||||
continue;
|
||||
}
|
||||
// Skip assets that only reference an external file we can't read.
|
||||
if (!FileSpec.hasEmbeddedFile(asset)) {
|
||||
continue;
|
||||
}
|
||||
const { filename } = new FileSpec(asset).serializable;
|
||||
const contentType = RichMediaAnnotation.#getContentType(
|
||||
asset,
|
||||
filename
|
||||
);
|
||||
const contentType = MediaAnnotation._getContentType(asset, filename);
|
||||
if (!contentType) {
|
||||
continue;
|
||||
}
|
||||
@ -5562,54 +5662,177 @@ class RichMediaAnnotation extends Annotation {
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class ScreenAnnotation extends MediaAnnotation {
|
||||
constructor(params) {
|
||||
super(params);
|
||||
|
||||
const { dict, xref, annotationGlobals } = params;
|
||||
const asset = ScreenAnnotation.#findAsset(dict, xref);
|
||||
if (!asset) {
|
||||
// Not every Screen annotation plays embedded media (e.g. a URL stream or
|
||||
// a /Movie); such ones simply render their appearance, so don't warn.
|
||||
return;
|
||||
}
|
||||
this._setMediaData(asset, annotationGlobals.pdfManager.pdfDocument.catalog);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the MIME type used to build the `<video>`/`<audio>` element.
|
||||
* Locate the embedded media played by the annotation's rendition action.
|
||||
*
|
||||
* Per the spec (ISO 32000-2, 7.11.4) an embedded file stream declares its
|
||||
* MIME type through its own `/Subtype`, with characters not allowed in a
|
||||
* name hex-escaped (e.g. `/video#2Fmp4` -> `video/mp4`). We trust that when
|
||||
* it names an audio/video type, and otherwise fall back to mapping the
|
||||
* filename extension. Returns `null` when the asset isn't a recognized
|
||||
* audio/video type (e.g. Flash `.swf` or 3D models), so we don't build a
|
||||
* player that can't play anything.
|
||||
* Per the spec (ISO 32000-1, 12.6.4.13 and 13.2) the chain is:
|
||||
* Screen `/A` (or `/AA`) rendition action -> `/R` rendition (`/MR`)
|
||||
* -> `/C` media clip (`/MCD`) -> `/D` file-spec -> `/EF` embedded file.
|
||||
* Selector renditions (`/SR`) are unwrapped to their first playable media
|
||||
* rendition. This mirrors `RichMediaAnnotation`, which also targets the
|
||||
* common single embedded-media case.
|
||||
*
|
||||
* @param {Dict} assetDict
|
||||
* @param {string} filename
|
||||
* @returns {string | null}
|
||||
* @returns {{
|
||||
* assetRef: Ref | null,
|
||||
* assetDict: Dict,
|
||||
* filename: string,
|
||||
* contentType: string,
|
||||
* } | null}
|
||||
*/
|
||||
static #getContentType(assetDict, filename) {
|
||||
// The embedded file stream is keyed like a file-spec platform item.
|
||||
const stream = FileSpec.pickPlatformItem(assetDict.get("EF"));
|
||||
const subtype =
|
||||
stream instanceof BaseStream ? stream.dict?.get("Subtype") : null;
|
||||
if (subtype instanceof Name && /^(?:video|audio)\//.test(subtype.name)) {
|
||||
return subtype.name;
|
||||
static #findAsset(dict, xref) {
|
||||
for (const action of this.#renditionActions(dict, xref)) {
|
||||
const asset = this.#findRenditionAsset(
|
||||
action.get("R"),
|
||||
xref,
|
||||
new RefSet()
|
||||
);
|
||||
if (asset) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static *#renditionActions(dict, xref) {
|
||||
// The rendition action may be the activation action (/A) or one of the
|
||||
// additional actions (/AA), e.g. page-open.
|
||||
const action = xref.fetchIfRef(dict.getRaw("A"));
|
||||
if (
|
||||
action instanceof Dict &&
|
||||
isName(action.get("S"), "Rendition") &&
|
||||
this.#isPlayAction(action)
|
||||
) {
|
||||
yield action;
|
||||
}
|
||||
const additionalActions = dict.get("AA");
|
||||
if (additionalActions instanceof Dict) {
|
||||
for (const key of additionalActions.getKeys()) {
|
||||
const aa = xref.fetchIfRef(additionalActions.getRaw(key));
|
||||
if (
|
||||
aa instanceof Dict &&
|
||||
isName(aa.get("S"), "Rendition") &&
|
||||
this.#isPlayAction(aa)
|
||||
) {
|
||||
yield aa;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static #isPlayAction(action) {
|
||||
// Rendition action /OP (ISO 32000-1, Table 214): PLAY_OR_RESUME and PLAY
|
||||
// play; STOP/PAUSE/RESUME don't start playback. When absent, the action is
|
||||
// JS-driven (/JS), which we can't run, so assume play.
|
||||
const operation = action.get("OP");
|
||||
return (
|
||||
operation === undefined ||
|
||||
operation === AnnotationRenditionOperation.PLAY_OR_RESUME ||
|
||||
operation === AnnotationRenditionOperation.PLAY
|
||||
);
|
||||
}
|
||||
|
||||
static #findRenditionAsset(rendition, xref, seen) {
|
||||
if (!(rendition instanceof Dict)) {
|
||||
return null;
|
||||
}
|
||||
const subtype = rendition.get("S");
|
||||
if (isName(subtype, "MR")) {
|
||||
return this.#findClipAsset(rendition.get("C"), xref);
|
||||
}
|
||||
if (isName(subtype, "SR")) {
|
||||
// A selector rendition lists candidate renditions; play the first that
|
||||
// resolves to embedded media.
|
||||
const renditions = rendition.get("R");
|
||||
if (Array.isArray(renditions)) {
|
||||
for (const ref of renditions) {
|
||||
// Guard against renditions referencing each other in a cycle.
|
||||
if (ref instanceof Ref) {
|
||||
if (seen.has(ref)) {
|
||||
continue;
|
||||
}
|
||||
seen.put(ref);
|
||||
}
|
||||
const asset = this.#findRenditionAsset(
|
||||
xref.fetchIfRef(ref),
|
||||
xref,
|
||||
seen
|
||||
);
|
||||
if (asset) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static #findClipAsset(clip, xref) {
|
||||
if (!(clip instanceof Dict) || !isName(clip.get("S"), "MCD")) {
|
||||
return null;
|
||||
}
|
||||
const rawData = clip.getRaw("D");
|
||||
const data = xref.fetchIfRef(rawData);
|
||||
const contentTypeHint = clip.get("CT");
|
||||
let explicitType =
|
||||
typeof contentTypeHint === "string" ? contentTypeHint : null;
|
||||
|
||||
let assetDict, filename;
|
||||
if (data instanceof BaseStream) {
|
||||
// `/D` is the embedded media stream directly.
|
||||
assetDict = data.dict;
|
||||
// `/N` is a human-readable label, not a filename, so it's an unreliable
|
||||
// source for a file extension. When `/CT` is absent, prefer the stream's
|
||||
// own `/Subtype` if it declares a media MIME type (as embedded-file
|
||||
// streams do); `_getContentType` ignores a non-media value.
|
||||
const name = clip.get("N");
|
||||
filename = typeof name === "string" ? stringToPDFString(name) : "";
|
||||
if (!explicitType) {
|
||||
const subtype = data.dict.get("Subtype");
|
||||
if (subtype instanceof Name) {
|
||||
explicitType = subtype.name;
|
||||
}
|
||||
}
|
||||
} else if (data instanceof Dict) {
|
||||
// `/D` is a file specification; require a readable embedded file.
|
||||
if (!FileSpec.hasEmbeddedFile(data)) {
|
||||
return null;
|
||||
}
|
||||
assetDict = data;
|
||||
({ filename } = new FileSpec(data).serializable);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ext = filename.split(".").at(-1)?.toLowerCase();
|
||||
switch (ext) {
|
||||
case "mp4":
|
||||
case "m4v":
|
||||
return "video/mp4";
|
||||
case "webm":
|
||||
return "video/webm";
|
||||
case "ogv":
|
||||
return "video/ogg";
|
||||
case "mov":
|
||||
return "video/quicktime";
|
||||
case "mp3":
|
||||
return "audio/mpeg";
|
||||
case "m4a":
|
||||
return "audio/mp4";
|
||||
case "wav":
|
||||
return "audio/wav";
|
||||
case "oga":
|
||||
case "ogg":
|
||||
return "audio/ogg";
|
||||
default:
|
||||
return null;
|
||||
const contentType = MediaAnnotation._getContentType(
|
||||
assetDict,
|
||||
filename,
|
||||
explicitType
|
||||
);
|
||||
if (!contentType) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
assetRef: rawData instanceof Ref ? rawData : null,
|
||||
assetDict,
|
||||
filename,
|
||||
contentType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -107,6 +107,16 @@ class FileSpec {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a file specification carries an embedded file we can read.
|
||||
*
|
||||
* @param {Dict} fileSpecDict
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static hasEmbeddedFile(fileSpecDict) {
|
||||
return this.pickPlatformItem(fileSpecDict.get("EF")) instanceof BaseStream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read attachment bytes from a file-spec dictionary.
|
||||
*
|
||||
|
||||
@ -158,8 +158,11 @@ class AnnotationElementFactory {
|
||||
case AnnotationType.FILEATTACHMENT:
|
||||
return new FileAttachmentAnnotationElement(parameters);
|
||||
|
||||
// A Screen annotation with a rendition action plays embedded media the
|
||||
// same way RichMedia does (see `MediaAnnotation` in the core layer).
|
||||
case AnnotationType.RICHMEDIA:
|
||||
return new RichMediaAnnotationElement(parameters);
|
||||
case AnnotationType.SCREEN:
|
||||
return new MediaAnnotationElement(parameters);
|
||||
|
||||
default:
|
||||
return new AnnotationElement(parameters);
|
||||
@ -403,7 +406,7 @@ class AnnotationElement {
|
||||
if (
|
||||
!(this instanceof WidgetAnnotationElement) &&
|
||||
!(this instanceof LinkAnnotationElement) &&
|
||||
!(this instanceof RichMediaAnnotationElement)
|
||||
!(this instanceof MediaAnnotationElement)
|
||||
) {
|
||||
container.tabIndex = 0;
|
||||
}
|
||||
@ -3800,7 +3803,7 @@ class FileAttachmentAnnotationElement extends AnnotationElement {
|
||||
}
|
||||
}
|
||||
|
||||
class RichMediaAnnotationElement extends AnnotationElement {
|
||||
class MediaAnnotationElement extends AnnotationElement {
|
||||
#abortController = new AbortController();
|
||||
|
||||
#contentUrl = null;
|
||||
@ -3812,14 +3815,14 @@ class RichMediaAnnotationElement extends AnnotationElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.classList.add("richMediaAnnotation");
|
||||
this.container.classList.add("mediaAnnotation");
|
||||
|
||||
const { filename } = this.data.richMedia;
|
||||
|
||||
// The annotation's appearance (a poster image) is painted on the canvas;
|
||||
// overlay a play button that loads the embedded media on demand.
|
||||
const button = document.createElement("button");
|
||||
button.className = "richMediaPlayButton";
|
||||
button.className = "mediaPlayButton";
|
||||
button.type = "button";
|
||||
button.title = button.ariaLabel = filename;
|
||||
button.addEventListener("click", () => this.#load(button), {
|
||||
@ -3853,7 +3856,7 @@ class RichMediaAnnotationElement extends AnnotationElement {
|
||||
const isAudio = contentType.startsWith("audio/");
|
||||
const media = document.createElement(isAudio ? "audio" : "video");
|
||||
this.#media = media;
|
||||
media.className = "richMediaContent";
|
||||
media.className = "mediaContent";
|
||||
this._setBackgroundColor(media);
|
||||
media.src = url;
|
||||
media.title = filename;
|
||||
|
||||
@ -175,6 +175,16 @@ const AnnotationReplyType = {
|
||||
REPLY: "R",
|
||||
};
|
||||
|
||||
// Rendition action operations from Table 214, Section 12.6.4.13 of the PDF
|
||||
// specification (ISO 32000-1).
|
||||
const AnnotationRenditionOperation = {
|
||||
PLAY_OR_RESUME: 0,
|
||||
STOP: 1,
|
||||
PAUSE: 2,
|
||||
RESUME: 3,
|
||||
PLAY: 4,
|
||||
};
|
||||
|
||||
const AnnotationFlag = {
|
||||
INVISIBLE: 0x01,
|
||||
HIDDEN: 0x02,
|
||||
@ -1152,6 +1162,7 @@ export {
|
||||
AnnotationFlag,
|
||||
AnnotationMode,
|
||||
AnnotationPrefix,
|
||||
AnnotationRenditionOperation,
|
||||
AnnotationReplyType,
|
||||
AnnotationType,
|
||||
assert,
|
||||
|
||||
@ -987,8 +987,8 @@ describe("RichMedia annotation", () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
const annotationSelector = getAnnotationSelector("4R");
|
||||
const buttonSelector = `${annotationSelector} .richMediaPlayButton`;
|
||||
const videoSelector = `${annotationSelector} video.richMediaContent`;
|
||||
const buttonSelector = `${annotationSelector} .mediaPlayButton`;
|
||||
const videoSelector = `${annotationSelector} video.mediaContent`;
|
||||
|
||||
// Initially only the play button (over the poster) is shown.
|
||||
await page.waitForSelector(buttonSelector, { timeout: 0 });
|
||||
@ -1008,8 +1008,8 @@ describe("RichMedia annotation", () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
const annotationSelector = getAnnotationSelector("5R");
|
||||
const buttonSelector = `${annotationSelector} .richMediaPlayButton`;
|
||||
const audioSelector = `${annotationSelector} audio.richMediaContent`;
|
||||
const buttonSelector = `${annotationSelector} .mediaPlayButton`;
|
||||
const audioSelector = `${annotationSelector} audio.mediaContent`;
|
||||
|
||||
// Initially only the play button is shown.
|
||||
await page.waitForSelector(buttonSelector, { timeout: 0 });
|
||||
@ -1026,3 +1026,58 @@ describe("RichMedia annotation", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Screen annotation (rendition)", () => {
|
||||
describe("multimedia_annotations.pdf", () => {
|
||||
let pages;
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait(
|
||||
"multimedia_annotations.pdf",
|
||||
getAnnotationSelector("30R")
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("must play the rendition video when clicking the play button", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
const annotationSelector = getAnnotationSelector("30R");
|
||||
const buttonSelector = `${annotationSelector} .mediaPlayButton`;
|
||||
const videoSelector = `${annotationSelector} video.mediaContent`;
|
||||
|
||||
await page.waitForSelector(buttonSelector, { visible: true });
|
||||
await page.click(buttonSelector);
|
||||
|
||||
await page.waitForSelector(videoSelector, { visible: true });
|
||||
const hasSource = await page.$eval(videoSelector, el =>
|
||||
el.src.startsWith("blob:")
|
||||
);
|
||||
expect(hasSource).withContext(`In ${browserName}`).toEqual(true);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("must play the rendition audio when clicking the play button", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
const annotationSelector = getAnnotationSelector("6R");
|
||||
const buttonSelector = `${annotationSelector} .mediaPlayButton`;
|
||||
const audioSelector = `${annotationSelector} audio.mediaContent`;
|
||||
|
||||
await page.waitForSelector(buttonSelector, { visible: true });
|
||||
await page.click(buttonSelector);
|
||||
|
||||
await page.waitForSelector(audioSelector, { visible: true });
|
||||
const hasSource = await page.$eval(audioSelector, el =>
|
||||
el.src.startsWith("blob:")
|
||||
);
|
||||
expect(hasSource).withContext(`In ${browserName}`).toEqual(true);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -214,7 +214,7 @@ describe("PDFPresentationMode", () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
const annotationSelector = getAnnotationSelector("4R");
|
||||
const videoSelector = `${annotationSelector} video.richMediaContent`;
|
||||
const videoSelector = `${annotationSelector} video.mediaContent`;
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__richMediaNavigationCalls = 0;
|
||||
@ -229,11 +229,10 @@ describe("PDFPresentationMode", () => {
|
||||
});
|
||||
|
||||
await enterPresentationMode(page);
|
||||
await page.waitForSelector(
|
||||
`${annotationSelector} .richMediaPlayButton`,
|
||||
{ visible: true }
|
||||
);
|
||||
await page.click(`${annotationSelector} .richMediaPlayButton`);
|
||||
await page.waitForSelector(`${annotationSelector} .mediaPlayButton`, {
|
||||
visible: true,
|
||||
});
|
||||
await page.click(`${annotationSelector} .mediaPlayButton`);
|
||||
await page.waitForSelector(videoSelector, { timeout: 0 });
|
||||
|
||||
const result = await page.$eval(videoSelector, el => ({
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
AnnotationEditorType,
|
||||
AnnotationFieldFlag,
|
||||
AnnotationFlag,
|
||||
AnnotationRenditionOperation,
|
||||
AnnotationType,
|
||||
bytesToString,
|
||||
DrawOPS,
|
||||
@ -145,6 +146,23 @@ describe("annotation", function () {
|
||||
partialEvaluator = null;
|
||||
});
|
||||
|
||||
function createAssetDict(filename, mimeSubtype = null) {
|
||||
let streamDict = null;
|
||||
if (mimeSubtype) {
|
||||
streamDict = new Dict();
|
||||
streamDict.set("Type", Name.get("EmbeddedFile"));
|
||||
streamDict.set("Subtype", Name.get(mimeSubtype));
|
||||
}
|
||||
const embeddedFileDict = new Dict();
|
||||
embeddedFileDict.set("F", new StringStream("", streamDict));
|
||||
|
||||
const fileSpecDict = new Dict();
|
||||
fileSpecDict.set("Type", Name.get("Filespec"));
|
||||
fileSpecDict.set("EF", embeddedFileDict);
|
||||
fileSpecDict.set("UF", filename);
|
||||
return fileSpecDict;
|
||||
}
|
||||
|
||||
describe("AnnotationFactory", function () {
|
||||
it("should get id for annotation", async function () {
|
||||
const annotationDict = new Dict();
|
||||
@ -4597,23 +4615,6 @@ describe("annotation", function () {
|
||||
});
|
||||
|
||||
describe("RichMediaAnnotation", function () {
|
||||
function createAssetDict(filename, mimeSubtype = null) {
|
||||
let streamDict = null;
|
||||
if (mimeSubtype) {
|
||||
streamDict = new Dict();
|
||||
streamDict.set("Type", Name.get("EmbeddedFile"));
|
||||
streamDict.set("Subtype", Name.get(mimeSubtype));
|
||||
}
|
||||
const embeddedFileDict = new Dict();
|
||||
embeddedFileDict.set("F", new StringStream("", streamDict));
|
||||
|
||||
const fileSpecDict = new Dict();
|
||||
fileSpecDict.set("Type", Name.get("Filespec"));
|
||||
fileSpecDict.set("EF", embeddedFileDict);
|
||||
fileSpecDict.set("UF", filename);
|
||||
return fileSpecDict;
|
||||
}
|
||||
|
||||
function createAnnotation(contentDict, refNum) {
|
||||
const dict = new Dict();
|
||||
dict.set("Type", Name.get("Annot"));
|
||||
@ -4905,6 +4906,336 @@ describe("annotation", function () {
|
||||
expect(data.noHTML).toEqual(true);
|
||||
expect(data.richMedia).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not create media data for an external (non-embedded) asset", async function () {
|
||||
const assetRef = Ref.get(180, 0);
|
||||
// A URL file specification with no `/EF`: the bytes aren't embedded, so
|
||||
// there's nothing pdf.js can serve.
|
||||
const assetDict = new Dict();
|
||||
assetDict.set("Type", Name.get("Filespec"));
|
||||
assetDict.set("FS", Name.get("URL"));
|
||||
assetDict.set("F", "https://example.com/demo.mp4");
|
||||
|
||||
const instanceDict = new Dict();
|
||||
instanceDict.set("Subtype", Name.get("Video"));
|
||||
instanceDict.set("Asset", assetRef);
|
||||
|
||||
const configDict = new Dict();
|
||||
configDict.set("Instances", [instanceDict]);
|
||||
|
||||
const contentDict = new Dict();
|
||||
contentDict.set("Configurations", [configDict]);
|
||||
|
||||
const annotation = createAnnotation(contentDict, 181);
|
||||
const xref = new XRefMock([
|
||||
{ ref: assetRef, data: assetDict },
|
||||
annotation,
|
||||
]);
|
||||
assetDict.assignXref(xref);
|
||||
|
||||
const { data } = await AnnotationFactory.create(
|
||||
xref,
|
||||
annotation.ref,
|
||||
annotationGlobalsMock,
|
||||
idFactoryMock
|
||||
);
|
||||
expect(data.annotationType).toEqual(AnnotationType.RICHMEDIA);
|
||||
expect(data.noHTML).toEqual(true);
|
||||
expect(data.richMedia).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScreenAnnotation", function () {
|
||||
function createMediaClipDict(fileSpecRef, contentType = null) {
|
||||
const clipDict = new Dict();
|
||||
clipDict.set("Type", Name.get("MediaClip"));
|
||||
clipDict.set("S", Name.get("MCD"));
|
||||
if (contentType) {
|
||||
clipDict.set("CT", contentType);
|
||||
}
|
||||
clipDict.set("D", fileSpecRef);
|
||||
return clipDict;
|
||||
}
|
||||
|
||||
function createRenditionDict(clipDict) {
|
||||
const renditionDict = new Dict();
|
||||
renditionDict.set("Type", Name.get("Rendition"));
|
||||
renditionDict.set("S", Name.get("MR"));
|
||||
renditionDict.set("C", clipDict);
|
||||
return renditionDict;
|
||||
}
|
||||
|
||||
function createRenditionAction(
|
||||
renditionDict,
|
||||
refNum,
|
||||
op = AnnotationRenditionOperation.PLAY_OR_RESUME
|
||||
) {
|
||||
const actionDict = new Dict();
|
||||
actionDict.set("Type", Name.get("Action"));
|
||||
actionDict.set("S", Name.get("Rendition"));
|
||||
actionDict.set("OP", op);
|
||||
actionDict.set("AN", Ref.get(refNum, 0));
|
||||
actionDict.set("R", renditionDict);
|
||||
return actionDict;
|
||||
}
|
||||
|
||||
function createScreenAnnotation(refNum, { action = null, aa = null } = {}) {
|
||||
const dict = new Dict();
|
||||
dict.set("Type", Name.get("Annot"));
|
||||
dict.set("Subtype", Name.get("Screen"));
|
||||
if (action) {
|
||||
dict.set("A", action);
|
||||
}
|
||||
if (aa) {
|
||||
dict.set("AA", aa);
|
||||
}
|
||||
return { ref: Ref.get(refNum, 0), data: dict };
|
||||
}
|
||||
|
||||
it("should parse the media clip from a rendition action", async function () {
|
||||
const fileSpecRef = Ref.get(200, 0);
|
||||
const fileSpecDict = createAssetDict("demo.mp3");
|
||||
const clipDict = createMediaClipDict(fileSpecRef, "audio/mpeg");
|
||||
const rendition = createRenditionDict(clipDict);
|
||||
const annotation = createScreenAnnotation(201, {
|
||||
action: createRenditionAction(rendition, 201),
|
||||
});
|
||||
|
||||
const xref = new XRefMock([
|
||||
{ ref: fileSpecRef, data: fileSpecDict },
|
||||
annotation,
|
||||
]);
|
||||
fileSpecDict.assignXref(xref);
|
||||
|
||||
const { data } = await AnnotationFactory.create(
|
||||
xref,
|
||||
annotation.ref,
|
||||
annotationGlobalsMock,
|
||||
idFactoryMock
|
||||
);
|
||||
expect(data.annotationType).toEqual(AnnotationType.SCREEN);
|
||||
expect(data.noHTML).toEqual(false);
|
||||
expect(data.richMedia).toEqual({
|
||||
fileId: "attachmentRef:200R",
|
||||
filename: "demo.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
});
|
||||
});
|
||||
|
||||
it("should derive the content type from the file extension", async function () {
|
||||
const fileSpecRef = Ref.get(210, 0);
|
||||
const fileSpecDict = createAssetDict("demo.mp4");
|
||||
// No `/CT`, so the type is inferred from the filename extension.
|
||||
const clipDict = createMediaClipDict(fileSpecRef);
|
||||
const rendition = createRenditionDict(clipDict);
|
||||
const annotation = createScreenAnnotation(211, {
|
||||
action: createRenditionAction(rendition, 211),
|
||||
});
|
||||
|
||||
const xref = new XRefMock([
|
||||
{ ref: fileSpecRef, data: fileSpecDict },
|
||||
annotation,
|
||||
]);
|
||||
fileSpecDict.assignXref(xref);
|
||||
|
||||
const { data } = await AnnotationFactory.create(
|
||||
xref,
|
||||
annotation.ref,
|
||||
annotationGlobalsMock,
|
||||
idFactoryMock
|
||||
);
|
||||
expect(data.richMedia).toEqual({
|
||||
fileId: "attachmentRef:210R",
|
||||
filename: "demo.mp4",
|
||||
contentType: "video/mp4",
|
||||
});
|
||||
});
|
||||
|
||||
it("should derive the content type from a direct media stream's Subtype", async function () {
|
||||
// `/D` is the embedded media stream itself (not a file spec), and `/N` is
|
||||
// a display label with no extension, so the type comes from the stream's
|
||||
// own `/Subtype`.
|
||||
const streamRef = Ref.get(250, 0);
|
||||
const streamDict = new Dict();
|
||||
streamDict.set("Subtype", Name.get("video/mp4"));
|
||||
const mediaStream = new StringStream("", streamDict);
|
||||
|
||||
const clipDict = new Dict();
|
||||
clipDict.set("Type", Name.get("MediaClip"));
|
||||
clipDict.set("S", Name.get("MCD"));
|
||||
clipDict.set("N", "Intro clip");
|
||||
clipDict.set("D", streamRef);
|
||||
|
||||
const rendition = createRenditionDict(clipDict);
|
||||
const annotation = createScreenAnnotation(251, {
|
||||
action: createRenditionAction(rendition, 251),
|
||||
});
|
||||
|
||||
const xref = new XRefMock([
|
||||
{ ref: streamRef, data: mediaStream },
|
||||
annotation,
|
||||
]);
|
||||
|
||||
const { data } = await AnnotationFactory.create(
|
||||
xref,
|
||||
annotation.ref,
|
||||
annotationGlobalsMock,
|
||||
idFactoryMock
|
||||
);
|
||||
expect(data.richMedia).toEqual({
|
||||
fileId: "attachmentRef:250R",
|
||||
filename: "Intro clip",
|
||||
contentType: "video/mp4",
|
||||
});
|
||||
});
|
||||
|
||||
it("should unwrap a selector rendition to the first playable media", async function () {
|
||||
// An empty media rendition (no clip) followed by a playable one.
|
||||
const emptyRendition = new Dict();
|
||||
emptyRendition.set("S", Name.get("MR"));
|
||||
|
||||
const fileSpecRef = Ref.get(220, 0);
|
||||
const fileSpecDict = createAssetDict("demo.mp4");
|
||||
const rendition = createRenditionDict(
|
||||
createMediaClipDict(fileSpecRef, "video/mp4")
|
||||
);
|
||||
|
||||
const selectorDict = new Dict();
|
||||
selectorDict.set("Type", Name.get("Rendition"));
|
||||
selectorDict.set("S", Name.get("SR"));
|
||||
selectorDict.set("R", [emptyRendition, rendition]);
|
||||
|
||||
const annotation = createScreenAnnotation(221, {
|
||||
action: createRenditionAction(selectorDict, 221),
|
||||
});
|
||||
|
||||
const xref = new XRefMock([
|
||||
{ ref: fileSpecRef, data: fileSpecDict },
|
||||
annotation,
|
||||
]);
|
||||
fileSpecDict.assignXref(xref);
|
||||
|
||||
const { data } = await AnnotationFactory.create(
|
||||
xref,
|
||||
annotation.ref,
|
||||
annotationGlobalsMock,
|
||||
idFactoryMock
|
||||
);
|
||||
expect(data.richMedia.filename).toEqual("demo.mp4");
|
||||
expect(data.richMedia.contentType).toEqual("video/mp4");
|
||||
});
|
||||
|
||||
it("should find a rendition action in the additional-actions dictionary", async function () {
|
||||
const fileSpecRef = Ref.get(230, 0);
|
||||
const fileSpecDict = createAssetDict("demo.mp4");
|
||||
const rendition = createRenditionDict(
|
||||
createMediaClipDict(fileSpecRef, "video/mp4")
|
||||
);
|
||||
|
||||
// No `/A`; the rendition action is a page-visible additional action.
|
||||
const aa = new Dict();
|
||||
aa.set("PV", createRenditionAction(rendition, 231));
|
||||
|
||||
const annotation = createScreenAnnotation(231, { aa });
|
||||
|
||||
const xref = new XRefMock([
|
||||
{ ref: fileSpecRef, data: fileSpecDict },
|
||||
annotation,
|
||||
]);
|
||||
fileSpecDict.assignXref(xref);
|
||||
|
||||
const { data } = await AnnotationFactory.create(
|
||||
xref,
|
||||
annotation.ref,
|
||||
annotationGlobalsMock,
|
||||
idFactoryMock
|
||||
);
|
||||
expect(data.richMedia.filename).toEqual("demo.mp4");
|
||||
expect(data.richMedia.contentType).toEqual("video/mp4");
|
||||
});
|
||||
|
||||
it("should ignore a non-play rendition action", async function () {
|
||||
const fileSpecRef = Ref.get(235, 0);
|
||||
const fileSpecDict = createAssetDict("demo.mp4");
|
||||
const rendition = createRenditionDict(
|
||||
createMediaClipDict(fileSpecRef, "video/mp4")
|
||||
);
|
||||
|
||||
const annotation = createScreenAnnotation(236, {
|
||||
action: createRenditionAction(
|
||||
rendition,
|
||||
236,
|
||||
AnnotationRenditionOperation.PAUSE
|
||||
),
|
||||
});
|
||||
|
||||
const xref = new XRefMock([
|
||||
{ ref: fileSpecRef, data: fileSpecDict },
|
||||
annotation,
|
||||
]);
|
||||
fileSpecDict.assignXref(xref);
|
||||
|
||||
const { data } = await AnnotationFactory.create(
|
||||
xref,
|
||||
annotation.ref,
|
||||
annotationGlobalsMock,
|
||||
idFactoryMock
|
||||
);
|
||||
expect(data.annotationType).toEqual(AnnotationType.SCREEN);
|
||||
expect(data.noHTML).toEqual(true);
|
||||
expect(data.richMedia).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not create media data for an external file specification", async function () {
|
||||
const fileSpecRef = Ref.get(237, 0);
|
||||
const fileSpecDict = new Dict();
|
||||
fileSpecDict.set("Type", Name.get("Filespec"));
|
||||
fileSpecDict.set("FS", Name.get("URL"));
|
||||
fileSpecDict.set("F", "https://example.com/demo.mp4");
|
||||
|
||||
const rendition = createRenditionDict(
|
||||
createMediaClipDict(fileSpecRef, "video/mp4")
|
||||
);
|
||||
const annotation = createScreenAnnotation(238, {
|
||||
action: createRenditionAction(rendition, 238),
|
||||
});
|
||||
|
||||
const xref = new XRefMock([
|
||||
{ ref: fileSpecRef, data: fileSpecDict },
|
||||
annotation,
|
||||
]);
|
||||
fileSpecDict.assignXref(xref);
|
||||
|
||||
const { data } = await AnnotationFactory.create(
|
||||
xref,
|
||||
annotation.ref,
|
||||
annotationGlobalsMock,
|
||||
idFactoryMock
|
||||
);
|
||||
expect(data.annotationType).toEqual(AnnotationType.SCREEN);
|
||||
expect(data.noHTML).toEqual(true);
|
||||
expect(data.richMedia).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not create media data without a rendition action", async function () {
|
||||
// A URI action, not a rendition; the Screen carries no playable media.
|
||||
const uriAction = new Dict();
|
||||
uriAction.set("S", Name.get("URI"));
|
||||
uriAction.set("URI", "https://example.com");
|
||||
const annotation = createScreenAnnotation(241, { action: uriAction });
|
||||
|
||||
const xref = new XRefMock([annotation]);
|
||||
|
||||
const { data } = await AnnotationFactory.create(
|
||||
xref,
|
||||
annotation.ref,
|
||||
annotationGlobalsMock,
|
||||
idFactoryMock
|
||||
);
|
||||
expect(data.annotationType).toEqual(AnnotationType.SCREEN);
|
||||
expect(data.noHTML).toEqual(true);
|
||||
expect(data.richMedia).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PopupAnnotation", function () {
|
||||
|
||||
@ -328,19 +328,19 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.richMediaAnnotation {
|
||||
.mediaAnnotation {
|
||||
pointer-events: auto;
|
||||
|
||||
.richMediaContent {
|
||||
.mediaContent {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
video.richMediaContent {
|
||||
video.mediaContent {
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
audio.richMediaContent {
|
||||
audio.mediaContent {
|
||||
/* An `<audio>` element has a fixed-height control bar; pin it to the
|
||||
bottom of the section so it lines up with where `<video>` shows its
|
||||
own bar. */
|
||||
@ -348,7 +348,7 @@
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.richMediaPlayButton {
|
||||
.mediaPlayButton {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
|
||||
@ -285,7 +285,7 @@ class AnnotationLayerBuilder {
|
||||
for (const section of this.div.childNodes) {
|
||||
if (
|
||||
section.hasAttribute("data-internal-link") ||
|
||||
section.classList.contains("richMediaAnnotation")
|
||||
section.classList.contains("mediaAnnotation")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -126,7 +126,7 @@ class PDFPresentationMode {
|
||||
if (!this.active) {
|
||||
return;
|
||||
}
|
||||
if (evt.target.closest?.(".richMediaAnnotation")) {
|
||||
if (evt.target.closest?.(".mediaAnnotation")) {
|
||||
return;
|
||||
}
|
||||
evt.preventDefault();
|
||||
@ -250,7 +250,7 @@ class PDFPresentationMode {
|
||||
return;
|
||||
}
|
||||
// Allow interacting with embedded media controls rather than advancing.
|
||||
if (evt.target.closest?.(".richMediaAnnotation")) {
|
||||
if (evt.target.closest?.(".mediaAnnotation")) {
|
||||
return;
|
||||
}
|
||||
// Unless an internal link was clicked, advance one page.
|
||||
@ -300,7 +300,7 @@ class PDFPresentationMode {
|
||||
if (!this.active) {
|
||||
return;
|
||||
}
|
||||
if (evt.target.closest?.(".richMediaAnnotation")) {
|
||||
if (evt.target.closest?.(".mediaAnnotation")) {
|
||||
this.touchSwipeState = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -237,7 +237,7 @@ body {
|
||||
}
|
||||
|
||||
.pdfPresentationMode:fullscreen
|
||||
section:not([data-internal-link], .richMediaAnnotation) {
|
||||
section:not([data-internal-link], .mediaAnnotation) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user