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:
Calixte Denizet 2026-06-23 12:04:09 +02:00
parent d71fe9025d
commit d8ea2afe47
11 changed files with 737 additions and 105 deletions

View File

@ -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,
};
}
}

View File

@ -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.
*

View File

@ -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;

View File

@ -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,

View File

@ -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);
})
);
});
});
});

View File

@ -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 => ({

View File

@ -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 () {

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -237,7 +237,7 @@ body {
}
.pdfPresentationMode:fullscreen
section:not([data-internal-link], .richMediaAnnotation) {
section:not([data-internal-link], .mediaAnnotation) {
pointer-events: none;
}