mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-07-10 17:15:51 +02:00
Add support for Sound annotations playing embedded audio
Wrap uncompressed PCM sound streams (Raw/Signed, 8/16-bit, mono/stereo) in WAV and play them through the shared media overlay.
This commit is contained in:
parent
f2f3a7fdce
commit
3ccc3ec65c
@ -74,6 +74,7 @@ import { Catalog } from "./catalog.js";
|
|||||||
import { ColorSpaceUtils } from "./colorspace_utils.js";
|
import { ColorSpaceUtils } from "./colorspace_utils.js";
|
||||||
import { createImage } from "./editor/pdf_images.js";
|
import { createImage } from "./editor/pdf_images.js";
|
||||||
import { FileSpec } from "./file_spec.js";
|
import { FileSpec } from "./file_spec.js";
|
||||||
|
import { getSoundFormat } from "./sound.js";
|
||||||
import { JpegStream } from "./jpeg_stream.js";
|
import { JpegStream } from "./jpeg_stream.js";
|
||||||
import { ObjectLoader } from "./object_loader.js";
|
import { ObjectLoader } from "./object_loader.js";
|
||||||
import { OperatorList } from "./operator_list.js";
|
import { OperatorList } from "./operator_list.js";
|
||||||
@ -291,6 +292,9 @@ class AnnotationFactory {
|
|||||||
case "Screen":
|
case "Screen":
|
||||||
return new ScreenAnnotation(parameters);
|
return new ScreenAnnotation(parameters);
|
||||||
|
|
||||||
|
case "Sound":
|
||||||
|
return new SoundAnnotation(parameters);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
if (!collectFields) {
|
if (!collectFields) {
|
||||||
if (!subtype) {
|
if (!subtype) {
|
||||||
@ -1509,7 +1513,7 @@ class Annotation {
|
|||||||
* usually indirect; when it's inline its embedded-file stream still isn't
|
* usually indirect; when it's inline its embedded-file stream still isn't
|
||||||
* (streams are always indirect), so fall back to that ref.
|
* (streams are always indirect), so fall back to that ref.
|
||||||
*/
|
*/
|
||||||
_getAttachmentId(fsDict, fsRef, annotationGlobals) {
|
_getAttachmentId(fsDict, fsRef, annotationGlobals, isSound = false) {
|
||||||
if (!(fsDict instanceof Dict)) {
|
if (!(fsDict instanceof Dict)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -1517,7 +1521,7 @@ class Annotation {
|
|||||||
fsRef = FileSpec.pickPlatformItem(fsDict.get("EF"), /* raw = */ true);
|
fsRef = FileSpec.pickPlatformItem(fsDict.get("EF"), /* raw = */ true);
|
||||||
}
|
}
|
||||||
return fsRef instanceof Ref
|
return fsRef instanceof Ref
|
||||||
? annotationGlobals.catalog.getAttachmentIdForAnnotation(fsRef)
|
? annotationGlobals.catalog.getAttachmentIdForAnnotation(fsRef, isSound)
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5488,15 +5492,23 @@ class MediaAnnotation extends Annotation {
|
|||||||
* when `assetRef` isn't itself a reference.
|
* when `assetRef` isn't itself a reference.
|
||||||
* @param {string} asset.filename
|
* @param {string} asset.filename
|
||||||
* @param {string} asset.contentType
|
* @param {string} asset.contentType
|
||||||
|
* @param {boolean} [asset.wrapSound]
|
||||||
|
* When set, the embedded bytes are raw PDF sound samples that the catalog
|
||||||
|
* wraps in a WAV container when fetched (see `soundStreamToWav`).
|
||||||
* @param {Object} annotationGlobals
|
* @param {Object} annotationGlobals
|
||||||
*/
|
*/
|
||||||
_setMediaData(
|
_setMediaData(
|
||||||
{ assetRef, assetDict, filename, contentType },
|
{ assetRef, assetDict, filename, contentType, wrapSound = false },
|
||||||
annotationGlobals
|
annotationGlobals
|
||||||
) {
|
) {
|
||||||
this.data.noHTML = false;
|
this.data.noHTML = false;
|
||||||
this.data.richMedia = {
|
this.data.richMedia = {
|
||||||
fileId: this._getAttachmentId(assetDict, assetRef, annotationGlobals),
|
fileId: this._getAttachmentId(
|
||||||
|
assetDict,
|
||||||
|
assetRef,
|
||||||
|
annotationGlobals,
|
||||||
|
wrapSound
|
||||||
|
),
|
||||||
filename,
|
filename,
|
||||||
contentType,
|
contentType,
|
||||||
};
|
};
|
||||||
@ -5825,6 +5837,45 @@ class ScreenAnnotation extends MediaAnnotation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SoundAnnotation extends MediaAnnotation {
|
||||||
|
constructor(params) {
|
||||||
|
super(params);
|
||||||
|
|
||||||
|
const { dict, xref, annotationGlobals } = params;
|
||||||
|
const soundRef = dict.getRaw("Sound");
|
||||||
|
if (!(soundRef instanceof Ref)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let sound;
|
||||||
|
try {
|
||||||
|
sound = xref.fetch(soundRef);
|
||||||
|
} catch (ex) {
|
||||||
|
if (ex instanceof MissingDataException) {
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
// A corrupt sound stream: fall back to rendering the appearance.
|
||||||
|
warn(`SoundAnnotation: "${ex}".`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(sound instanceof BaseStream) || !getSoundFormat(sound.dict)) {
|
||||||
|
// No embedded samples, or an encoding we can't turn into a playable WAV
|
||||||
|
// (compressed, or an unusual bit depth); just render the appearance.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._setMediaData(
|
||||||
|
{
|
||||||
|
assetRef: soundRef,
|
||||||
|
assetDict: sound.dict,
|
||||||
|
filename: "sound.wav",
|
||||||
|
contentType: "audio/wav",
|
||||||
|
wrapSound: true,
|
||||||
|
},
|
||||||
|
annotationGlobals
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Annotation,
|
Annotation,
|
||||||
AnnotationBorderStyle,
|
AnnotationBorderStyle,
|
||||||
|
|||||||
@ -52,6 +52,7 @@ import { clearGlobalCaches } from "./cleanup_helper.js";
|
|||||||
import { ColorSpaceUtils } from "./colorspace_utils.js";
|
import { ColorSpaceUtils } from "./colorspace_utils.js";
|
||||||
import { FileSpec } from "./file_spec.js";
|
import { FileSpec } from "./file_spec.js";
|
||||||
import { MetadataParser } from "./metadata_parser.js";
|
import { MetadataParser } from "./metadata_parser.js";
|
||||||
|
import { soundStreamToWav } from "./sound.js";
|
||||||
import { stringToPDFString } from "./string_utils.js";
|
import { stringToPDFString } from "./string_utils.js";
|
||||||
import { StructTreeRoot } from "./struct_tree.js";
|
import { StructTreeRoot } from "./struct_tree.js";
|
||||||
|
|
||||||
@ -123,6 +124,8 @@ class Catalog {
|
|||||||
|
|
||||||
#annotationAttachmentRefById = new Map();
|
#annotationAttachmentRefById = new Map();
|
||||||
|
|
||||||
|
#soundAttachmentIds = new Set();
|
||||||
|
|
||||||
#catDict = null;
|
#catDict = null;
|
||||||
|
|
||||||
builtInCMapCache = new Map();
|
builtInCMapCache = new Map();
|
||||||
@ -171,28 +174,32 @@ class Catalog {
|
|||||||
*
|
*
|
||||||
* @param {Ref} ref
|
* @param {Ref} ref
|
||||||
* File-spec or embedded-file stream reference.
|
* File-spec or embedded-file stream reference.
|
||||||
|
* @param {boolean} [isSound]
|
||||||
|
* When set, the referenced stream holds raw PDF sound samples that
|
||||||
|
* `attachmentContent` wraps in a WAV container on fetch.
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
* Attachment id.
|
* Attachment id.
|
||||||
*/
|
*/
|
||||||
getAttachmentIdForAnnotation(ref) {
|
getAttachmentIdForAnnotation(ref, isSound = false) {
|
||||||
let id = this.#annotationAttachmentIdByRef.get(ref);
|
let id = this.#annotationAttachmentIdByRef.get(ref);
|
||||||
if (id) {
|
if (!id) {
|
||||||
return id;
|
const baseId = `attachmentRef:${ref.toString()}`;
|
||||||
|
id = baseId;
|
||||||
|
|
||||||
|
let i = 1;
|
||||||
|
while (
|
||||||
|
this.#annotationAttachmentRefById.has(id) ||
|
||||||
|
this.attachments?.has(id)
|
||||||
|
) {
|
||||||
|
id = `${baseId}-${i++}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#annotationAttachmentIdByRef.put(ref, id);
|
||||||
|
this.#annotationAttachmentRefById.set(id, ref);
|
||||||
}
|
}
|
||||||
|
if (isSound) {
|
||||||
const baseId = `attachmentRef:${ref.toString()}`;
|
this.#soundAttachmentIds.add(id);
|
||||||
id = baseId;
|
|
||||||
|
|
||||||
let i = 1;
|
|
||||||
while (
|
|
||||||
this.#annotationAttachmentRefById.has(id) ||
|
|
||||||
this.attachments?.has(id)
|
|
||||||
) {
|
|
||||||
id = `${baseId}-${i++}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#annotationAttachmentIdByRef.put(ref, id);
|
|
||||||
this.#annotationAttachmentRefById.set(id, ref);
|
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1198,7 +1205,11 @@ class Catalog {
|
|||||||
if (ref) {
|
if (ref) {
|
||||||
const target = this.xref.fetch(ref);
|
const target = this.xref.fetch(ref);
|
||||||
if (target instanceof BaseStream) {
|
if (target instanceof BaseStream) {
|
||||||
return FileSpec.readStreamContent(target);
|
const content = FileSpec.readStreamContent(target);
|
||||||
|
if (this.#soundAttachmentIds.has(id)) {
|
||||||
|
return soundStreamToWav(target, content) ?? content;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
return target instanceof Dict ? FileSpec.readContent(target) : null;
|
return target instanceof Dict ? FileSpec.readContent(target) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
144
src/core/sound.js
Normal file
144
src/core/sound.js
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Name } from "./primitives.js";
|
||||||
|
import { stringToBytes } from "../shared/util.js";
|
||||||
|
|
||||||
|
// Size, in bytes, of the canonical 44-byte WAV header (RIFF + fmt + data
|
||||||
|
// chunk headers) that precedes the sample data.
|
||||||
|
const WAV_HEADER_SIZE = 44;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers for PDF sound objects (ISO 32000-1, 12.5.6.16).
|
||||||
|
*
|
||||||
|
* Sound streams contain samples described by /R, /C, /B, /E, and optional /CO.
|
||||||
|
* We wrap supported uncompressed PCM (Raw/Signed, 8/16-bit, mono/stereo) in WAV
|
||||||
|
* for playback.
|
||||||
|
*
|
||||||
|
* @import { BaseStream } from "./base_stream.js";
|
||||||
|
* @import { Dict } from "./primitives.js";
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a supported uncompressed sample format.
|
||||||
|
*
|
||||||
|
* @param {Dict} [dict] The sound object's stream dictionary.
|
||||||
|
* @returns {{
|
||||||
|
* channels: number,
|
||||||
|
* sampleRate: number,
|
||||||
|
* bitsPerSample: number,
|
||||||
|
* encoding: string,
|
||||||
|
* } | null}
|
||||||
|
*/
|
||||||
|
function getSoundFormat(dict) {
|
||||||
|
// `/CO` compression is beyond PDF stream filters and isn't decoded here.
|
||||||
|
if (!dict || dict.has("CO")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sampleRate = dict.get("R");
|
||||||
|
if (!Number.isInteger(sampleRate) || sampleRate <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const channels = dict.get("C") ?? 1;
|
||||||
|
if (!Number.isInteger(channels) || channels < 1 || channels > 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const bitsPerSample = dict.get("B") ?? 8;
|
||||||
|
if (bitsPerSample !== 8 && bitsPerSample !== 16) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// `/E` is optional and defaults to Raw; a present-but-malformed value (one
|
||||||
|
// that isn't a name) is rejected rather than silently treated as Raw.
|
||||||
|
const e = dict.get("E");
|
||||||
|
let encoding = "Raw";
|
||||||
|
if (e !== undefined) {
|
||||||
|
encoding = e instanceof Name ? e.name : null;
|
||||||
|
}
|
||||||
|
if (encoding !== "Raw" && encoding !== "Signed") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { channels, sampleRate, bitsPerSample, encoding };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a WAV file from supported PDF sound samples.
|
||||||
|
*
|
||||||
|
* PDF 16-bit samples are big-endian; WAV uses little-endian, unsigned 8-bit
|
||||||
|
* samples, and signed 16-bit samples. The data chunk is trimmed to a whole
|
||||||
|
* number of frames (a multiple of the block alignment); a stream with no
|
||||||
|
* complete frame produces no WAV.
|
||||||
|
*
|
||||||
|
* @param {BaseStream} stream The sound object stream.
|
||||||
|
* @param {Uint8Array} samples Raw sample bytes from the stream.
|
||||||
|
* @returns {Uint8Array | null}
|
||||||
|
*/
|
||||||
|
function soundStreamToWav(stream, samples) {
|
||||||
|
const format = getSoundFormat(stream.dict);
|
||||||
|
if (!format) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { channels, sampleRate, bitsPerSample, encoding } = format;
|
||||||
|
const blockAlign = channels * (bitsPerSample >> 3);
|
||||||
|
// Keep only whole frames, dropping a trailing partial frame; bail out when
|
||||||
|
// there isn't a single complete frame to play.
|
||||||
|
const dataLength = samples.length - (samples.length % blockAlign);
|
||||||
|
if (dataLength === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wav = new Uint8Array(WAV_HEADER_SIZE + dataLength);
|
||||||
|
const view = new DataView(wav.buffer);
|
||||||
|
wav.set(stringToBytes("RIFF"), 0);
|
||||||
|
// File size minus the first 8 bytes (the "RIFF" tag and this field).
|
||||||
|
view.setUint32(4, WAV_HEADER_SIZE - 8 + dataLength, true);
|
||||||
|
wav.set(stringToBytes("WAVE"), 8);
|
||||||
|
wav.set(stringToBytes("fmt "), 12);
|
||||||
|
view.setUint32(16, 16, true); // PCM fmt-chunk size.
|
||||||
|
view.setUint16(20, 1 /* = WAVE_FORMAT_PCM */, true);
|
||||||
|
view.setUint16(22, channels, true);
|
||||||
|
view.setUint32(24, sampleRate, true);
|
||||||
|
view.setUint32(28, sampleRate * blockAlign, true); // Byte rate.
|
||||||
|
view.setUint16(32, blockAlign, true);
|
||||||
|
view.setUint16(34, bitsPerSample, true);
|
||||||
|
wav.set(stringToBytes("data"), 36);
|
||||||
|
view.setUint32(40, dataLength, true);
|
||||||
|
|
||||||
|
// Transcode the samples straight into the WAV data region (right after the
|
||||||
|
// header) so we never allocate or copy a separate sample buffer.
|
||||||
|
if (bitsPerSample === 16) {
|
||||||
|
const signed = encoding === "Signed";
|
||||||
|
for (let i = 0; i < dataLength; i += 2) {
|
||||||
|
let value = (samples[i] << 8) | samples[i + 1];
|
||||||
|
if (signed) {
|
||||||
|
if (value >= 0x8000) {
|
||||||
|
value -= 0x10000;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value -= 0x8000;
|
||||||
|
}
|
||||||
|
view.setInt16(WAV_HEADER_SIZE + i, value, /* littleEndian = */ true);
|
||||||
|
}
|
||||||
|
} else if (encoding === "Signed") {
|
||||||
|
for (let i = 0; i < dataLength; i++) {
|
||||||
|
wav[WAV_HEADER_SIZE + i] = (samples[i] + 128) & 0xff;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wav.set(samples.subarray(0, dataLength), WAV_HEADER_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return wav;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getSoundFormat, soundStreamToWav };
|
||||||
@ -158,10 +158,12 @@ class AnnotationElementFactory {
|
|||||||
case AnnotationType.FILEATTACHMENT:
|
case AnnotationType.FILEATTACHMENT:
|
||||||
return new FileAttachmentAnnotationElement(parameters);
|
return new FileAttachmentAnnotationElement(parameters);
|
||||||
|
|
||||||
// A Screen annotation with a rendition action plays embedded media the
|
// RichMedia, Screen (rendition action) and Sound annotations all expose a
|
||||||
// same way RichMedia does (see `MediaAnnotation` in the core layer).
|
// single embedded audio/video clip and play it the same way (see
|
||||||
|
// `MediaAnnotation` in the core layer).
|
||||||
case AnnotationType.RICHMEDIA:
|
case AnnotationType.RICHMEDIA:
|
||||||
case AnnotationType.SCREEN:
|
case AnnotationType.SCREEN:
|
||||||
|
case AnnotationType.SOUND:
|
||||||
return new MediaAnnotationElement(parameters);
|
return new MediaAnnotationElement(parameters);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -1081,3 +1081,39 @@ describe("Screen annotation (rendition)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Sound annotation", () => {
|
||||||
|
describe("multimedia_annotations.pdf", () => {
|
||||||
|
let pages;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
pages = await loadAndWait(
|
||||||
|
"multimedia_annotations.pdf",
|
||||||
|
getAnnotationSelector("7R")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await closePages(pages);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("must play the embedded sound when clicking the play button", async () => {
|
||||||
|
await Promise.all(
|
||||||
|
pages.map(async ([browserName, page]) => {
|
||||||
|
const annotationSelector = getAnnotationSelector("7R");
|
||||||
|
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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -30,12 +30,15 @@ import {
|
|||||||
bytesToString,
|
bytesToString,
|
||||||
DrawOPS,
|
DrawOPS,
|
||||||
OPS,
|
OPS,
|
||||||
|
PasswordException,
|
||||||
|
PasswordResponses,
|
||||||
RenderingIntentFlag,
|
RenderingIntentFlag,
|
||||||
stringToUTF8String,
|
stringToUTF8String,
|
||||||
} from "../../src/shared/util.js";
|
} from "../../src/shared/util.js";
|
||||||
import {
|
import {
|
||||||
CMAP_URL,
|
CMAP_URL,
|
||||||
createIdFactory,
|
createIdFactory,
|
||||||
|
createSoundDict,
|
||||||
DefaultBinaryDataFactory,
|
DefaultBinaryDataFactory,
|
||||||
fetchBuiltInCMapHelper,
|
fetchBuiltInCMapHelper,
|
||||||
STANDARD_FONT_DATA_URL,
|
STANDARD_FONT_DATA_URL,
|
||||||
@ -5238,6 +5241,139 @@ describe("annotation", function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("SoundAnnotation", function () {
|
||||||
|
function createSoundAnnotation(refNum, soundRef) {
|
||||||
|
const dict = new Dict();
|
||||||
|
dict.set("Type", Name.get("Annot"));
|
||||||
|
dict.set("Subtype", Name.get("Sound"));
|
||||||
|
if (soundRef) {
|
||||||
|
dict.set("Sound", soundRef);
|
||||||
|
}
|
||||||
|
return { ref: Ref.get(refNum, 0), data: dict };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should expose the embedded sound as a playable WAV asset", async function () {
|
||||||
|
const soundRef = Ref.get(300, 0);
|
||||||
|
const soundStream = new StringStream(
|
||||||
|
"\x00\x00\x01\x00",
|
||||||
|
createSoundDict({ type: true })
|
||||||
|
);
|
||||||
|
const annotation = createSoundAnnotation(301, soundRef);
|
||||||
|
|
||||||
|
const xref = new XRefMock([
|
||||||
|
{ ref: soundRef, data: soundStream },
|
||||||
|
annotation,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { data } = await AnnotationFactory.create(
|
||||||
|
xref,
|
||||||
|
annotation.ref,
|
||||||
|
annotationGlobalsMock,
|
||||||
|
idFactoryMock
|
||||||
|
);
|
||||||
|
expect(data.annotationType).toEqual(AnnotationType.SOUND);
|
||||||
|
expect(data.noHTML).toEqual(false);
|
||||||
|
expect(data.richMedia).toEqual({
|
||||||
|
fileId: "attachmentRef:300R",
|
||||||
|
filename: "sound.wav",
|
||||||
|
contentType: "audio/wav",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not create media data for a compressed sound", async function () {
|
||||||
|
const soundRef = Ref.get(310, 0);
|
||||||
|
const soundStream = new StringStream(
|
||||||
|
"\x00\x00",
|
||||||
|
createSoundDict({ type: true, CO: "ADPCM" })
|
||||||
|
);
|
||||||
|
const annotation = createSoundAnnotation(311, soundRef);
|
||||||
|
|
||||||
|
const xref = new XRefMock([
|
||||||
|
{ ref: soundRef, data: soundStream },
|
||||||
|
annotation,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { data } = await AnnotationFactory.create(
|
||||||
|
xref,
|
||||||
|
annotation.ref,
|
||||||
|
annotationGlobalsMock,
|
||||||
|
idFactoryMock
|
||||||
|
);
|
||||||
|
expect(data.annotationType).toEqual(AnnotationType.SOUND);
|
||||||
|
expect(data.noHTML).toEqual(true);
|
||||||
|
expect(data.richMedia).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not create media data for an unsupported bit depth", async function () {
|
||||||
|
const soundRef = Ref.get(320, 0);
|
||||||
|
const soundStream = new StringStream(
|
||||||
|
"\x00\x00\x00",
|
||||||
|
createSoundDict({ type: true, B: 24 })
|
||||||
|
);
|
||||||
|
const annotation = createSoundAnnotation(321, soundRef);
|
||||||
|
|
||||||
|
const xref = new XRefMock([
|
||||||
|
{ ref: soundRef, data: soundStream },
|
||||||
|
annotation,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { data } = await AnnotationFactory.create(
|
||||||
|
xref,
|
||||||
|
annotation.ref,
|
||||||
|
annotationGlobalsMock,
|
||||||
|
idFactoryMock
|
||||||
|
);
|
||||||
|
expect(data.annotationType).toEqual(AnnotationType.SOUND);
|
||||||
|
expect(data.noHTML).toEqual(true);
|
||||||
|
expect(data.richMedia).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not create media data without a sound object", async function () {
|
||||||
|
const annotation = createSoundAnnotation(331, null);
|
||||||
|
const xref = new XRefMock([annotation]);
|
||||||
|
|
||||||
|
const { data } = await AnnotationFactory.create(
|
||||||
|
xref,
|
||||||
|
annotation.ref,
|
||||||
|
annotationGlobalsMock,
|
||||||
|
idFactoryMock
|
||||||
|
);
|
||||||
|
expect(data.annotationType).toEqual(AnnotationType.SOUND);
|
||||||
|
expect(data.noHTML).toEqual(true);
|
||||||
|
expect(data.richMedia).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should request a password before wrapping encrypted sound content", function () {
|
||||||
|
const soundRef = Ref.get(340, 0);
|
||||||
|
const soundDict = createSoundDict({ type: true });
|
||||||
|
const soundStream = new StringStream("\x00\x00", soundDict);
|
||||||
|
const pagesDict = new Dict();
|
||||||
|
const catalogDict = new Dict();
|
||||||
|
catalogDict.set("Pages", pagesDict);
|
||||||
|
|
||||||
|
const xref = new XRefMock([{ ref: soundRef, data: soundStream }]);
|
||||||
|
xref.encrypt = { encryptionKey: null };
|
||||||
|
xref.getCatalogObj = () => catalogDict;
|
||||||
|
for (const dict of [soundDict, pagesDict, catalogDict]) {
|
||||||
|
dict.assignXref(xref);
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = new Catalog(pdfManagerMock, xref);
|
||||||
|
const soundId = catalog.getAttachmentIdForAnnotation(
|
||||||
|
soundRef,
|
||||||
|
/* isSound = */ true
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
catalog.attachmentContent(soundId);
|
||||||
|
expect(false).toEqual(true);
|
||||||
|
} catch (ex) {
|
||||||
|
expect(ex).toBeInstanceOf(PasswordException);
|
||||||
|
expect(ex.code).toEqual(PasswordResponses.NEED_PASSWORD);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("PopupAnnotation", function () {
|
describe("PopupAnnotation", function () {
|
||||||
it("should inherit properties from its parent", async function () {
|
it("should inherit properties from its parent", async function () {
|
||||||
const parentDict = new Dict();
|
const parentDict = new Dict();
|
||||||
|
|||||||
@ -49,6 +49,7 @@
|
|||||||
"postscript_spec.js",
|
"postscript_spec.js",
|
||||||
"primitives_spec.js",
|
"primitives_spec.js",
|
||||||
"scripting_utils_spec.js",
|
"scripting_utils_spec.js",
|
||||||
|
"sound_spec.js",
|
||||||
"stream_spec.js",
|
"stream_spec.js",
|
||||||
"string_utils_spec.js",
|
"string_utils_spec.js",
|
||||||
"struct_tree_spec.js",
|
"struct_tree_spec.js",
|
||||||
|
|||||||
@ -96,6 +96,7 @@ async function initializePDFJS(callback) {
|
|||||||
"pdfjs-test/unit/primitives_spec.js",
|
"pdfjs-test/unit/primitives_spec.js",
|
||||||
"pdfjs-test/unit/scripting_spec.js",
|
"pdfjs-test/unit/scripting_spec.js",
|
||||||
"pdfjs-test/unit/scripting_utils_spec.js",
|
"pdfjs-test/unit/scripting_utils_spec.js",
|
||||||
|
"pdfjs-test/unit/sound_spec.js",
|
||||||
"pdfjs-test/unit/stream_spec.js",
|
"pdfjs-test/unit/stream_spec.js",
|
||||||
"pdfjs-test/unit/string_utils_spec.js",
|
"pdfjs-test/unit/string_utils_spec.js",
|
||||||
"pdfjs-test/unit/struct_tree_spec.js",
|
"pdfjs-test/unit/struct_tree_spec.js",
|
||||||
|
|||||||
196
test/unit/sound_spec.js
Normal file
196
test/unit/sound_spec.js
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getSoundFormat, soundStreamToWav } from "../../src/core/sound.js";
|
||||||
|
import { createSoundDict } from "./test_utils.js";
|
||||||
|
import { StringStream } from "../../src/core/stream.js";
|
||||||
|
|
||||||
|
describe("sound", function () {
|
||||||
|
function createSoundStream(bytes, opts) {
|
||||||
|
return new StringStream(
|
||||||
|
String.fromCharCode(...bytes),
|
||||||
|
createSoundDict(opts)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWav(bytes, opts) {
|
||||||
|
const stream = createSoundStream(bytes, opts);
|
||||||
|
return soundStreamToWav(stream, stream.getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWav(wav) {
|
||||||
|
const view = new DataView(wav.buffer, wav.byteOffset, wav.byteLength);
|
||||||
|
const tag = offset =>
|
||||||
|
String.fromCharCode(
|
||||||
|
wav[offset],
|
||||||
|
wav[offset + 1],
|
||||||
|
wav[offset + 2],
|
||||||
|
wav[offset + 3]
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
riff: tag(0),
|
||||||
|
wave: tag(8),
|
||||||
|
fmt: tag(12),
|
||||||
|
dataTag: tag(36),
|
||||||
|
fmtSize: view.getUint32(16, true),
|
||||||
|
format: view.getUint16(20, true),
|
||||||
|
channels: view.getUint16(22, true),
|
||||||
|
sampleRate: view.getUint32(24, true),
|
||||||
|
byteRate: view.getUint32(28, true),
|
||||||
|
blockAlign: view.getUint16(32, true),
|
||||||
|
bitsPerSample: view.getUint16(34, true),
|
||||||
|
dataLength: view.getUint32(40, true),
|
||||||
|
data: Array.from(wav.subarray(44)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getSoundFormat", function () {
|
||||||
|
it("should read an explicit format", function () {
|
||||||
|
expect(getSoundFormat(createSoundDict())).toEqual({
|
||||||
|
channels: 1,
|
||||||
|
sampleRate: 22050,
|
||||||
|
bitsPerSample: 16,
|
||||||
|
encoding: "Signed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply the spec defaults", function () {
|
||||||
|
// Only `/R` is required; `/C`, `/B` and `/E` default to 1, 8 and Raw.
|
||||||
|
expect(
|
||||||
|
getSoundFormat(createSoundDict({ C: null, B: null, E: null }))
|
||||||
|
).toEqual({
|
||||||
|
channels: 1,
|
||||||
|
sampleRate: 22050,
|
||||||
|
bitsPerSample: 8,
|
||||||
|
encoding: "Raw",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject compressed sample data", function () {
|
||||||
|
expect(getSoundFormat(createSoundDict({ CO: "ADPCM" }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject an unsupported bit depth", function () {
|
||||||
|
expect(getSoundFormat(createSoundDict({ B: 24 }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject muLaw/ALaw encodings", function () {
|
||||||
|
expect(getSoundFormat(createSoundDict({ B: 8, E: "muLaw" }))).toBeNull();
|
||||||
|
expect(getSoundFormat(createSoundDict({ B: 8, E: "ALaw" }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject a present but non-name /E encoding", function () {
|
||||||
|
const dict = createSoundDict({ E: null });
|
||||||
|
dict.set("E", 0); // Not a name object (a malformed `/E`).
|
||||||
|
expect(getSoundFormat(dict)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject a missing or invalid sample rate", function () {
|
||||||
|
expect(getSoundFormat(createSoundDict({ R: null }))).toBeNull();
|
||||||
|
expect(getSoundFormat(createSoundDict({ R: 0 }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject a non-integer or non-finite sample rate", function () {
|
||||||
|
expect(getSoundFormat(createSoundDict({ R: 22050.5 }))).toBeNull();
|
||||||
|
expect(getSoundFormat(createSoundDict({ R: Infinity }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject an unsupported channel count", function () {
|
||||||
|
expect(getSoundFormat(createSoundDict({ C: 3 }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("soundStreamToWav", function () {
|
||||||
|
it("should wrap 16-bit signed samples in a little-endian WAV", function () {
|
||||||
|
// Big-endian source samples 0x1234 and 0xFFFE (-2).
|
||||||
|
const wav = parseWav(createWav([0x12, 0x34, 0xff, 0xfe]));
|
||||||
|
expect(wav.riff).toEqual("RIFF");
|
||||||
|
expect(wav.wave).toEqual("WAVE");
|
||||||
|
expect(wav.fmt).toEqual("fmt ");
|
||||||
|
expect(wav.dataTag).toEqual("data");
|
||||||
|
expect(wav.fmtSize).toEqual(16);
|
||||||
|
expect(wav.format).toEqual(1); // PCM
|
||||||
|
expect(wav.channels).toEqual(1);
|
||||||
|
expect(wav.sampleRate).toEqual(22050);
|
||||||
|
expect(wav.bitsPerSample).toEqual(16);
|
||||||
|
expect(wav.blockAlign).toEqual(2);
|
||||||
|
expect(wav.byteRate).toEqual(22050 * 2);
|
||||||
|
expect(wav.dataLength).toEqual(4);
|
||||||
|
// Bytes are swapped to little-endian, values unchanged.
|
||||||
|
expect(wav.data).toEqual([0x34, 0x12, 0xfe, 0xff]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should shift 16-bit raw (unsigned) samples into the signed range", function () {
|
||||||
|
// Big-endian unsigned 0x8000 (mid) and 0x0000 (min).
|
||||||
|
const wav = parseWav(createWav([0x80, 0x00, 0x00, 0x00], { E: "Raw" }));
|
||||||
|
// 0x8000 - 0x8000 = 0; 0x0000 - 0x8000 = -32768 (0x8000 little-endian).
|
||||||
|
expect(wav.data).toEqual([0x00, 0x00, 0x00, 0x80]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should copy 8-bit raw (unsigned) samples unchanged", function () {
|
||||||
|
const wav = parseWav(createWav([0x00, 0x7f, 0xff], { B: 8, E: "Raw" }));
|
||||||
|
expect(wav.bitsPerSample).toEqual(8);
|
||||||
|
expect(wav.blockAlign).toEqual(1);
|
||||||
|
expect(wav.data).toEqual([0x00, 0x7f, 0xff]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert 8-bit signed samples to unsigned", function () {
|
||||||
|
// Signed bytes 0, 127, -128, -1 -> unsigned 128, 255, 0, 127.
|
||||||
|
const wav = parseWav(
|
||||||
|
createWav([0x00, 0x7f, 0x80, 0xff], { B: 8, E: "Signed" })
|
||||||
|
);
|
||||||
|
expect(wav.data).toEqual([0x80, 0xff, 0x00, 0x7f]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should report stereo block alignment", function () {
|
||||||
|
const wav = parseWav(createWav([0, 0, 0, 0], { C: 2 }));
|
||||||
|
expect(wav.channels).toEqual(2);
|
||||||
|
expect(wav.blockAlign).toEqual(4); // 2 channels * 16 bits.
|
||||||
|
expect(wav.byteRate).toEqual(22050 * 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trim a trailing partial frame (16-bit stereo)", function () {
|
||||||
|
// blockAlign = 4; six bytes is one whole frame plus a partial one.
|
||||||
|
const wav = parseWav(createWav([1, 2, 3, 4, 5, 6], { C: 2 }));
|
||||||
|
expect(wav.blockAlign).toEqual(4);
|
||||||
|
expect(wav.dataLength).toEqual(4);
|
||||||
|
// Only the first frame, byte-swapped to little-endian.
|
||||||
|
expect(wav.data).toEqual([2, 1, 4, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trim a trailing partial frame (8-bit stereo)", function () {
|
||||||
|
// blockAlign = 2; three bytes is one whole frame plus a partial one.
|
||||||
|
const wav = parseWav(createWav([10, 20, 30], { C: 2, B: 8, E: "Raw" }));
|
||||||
|
expect(wav.blockAlign).toEqual(2);
|
||||||
|
expect(wav.dataLength).toEqual(2);
|
||||||
|
expect(wav.data).toEqual([10, 20]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when there is no complete frame", function () {
|
||||||
|
// An empty stream, and a stereo stream with only a partial frame.
|
||||||
|
expect(createWav([], {})).toBeNull();
|
||||||
|
expect(createWav([1, 2], { C: 2 })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for an unsupported format", function () {
|
||||||
|
expect(
|
||||||
|
soundStreamToWav(
|
||||||
|
createSoundStream([0, 0], { CO: "ADPCM" }),
|
||||||
|
new Uint8Array([0, 0])
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -14,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assert, isNodeJS } from "../../src/shared/util.js";
|
import { assert, isNodeJS } from "../../src/shared/util.js";
|
||||||
|
import { Dict, Name, Ref } from "../../src/core/primitives.js";
|
||||||
import {
|
import {
|
||||||
fetchData as fetchDataNode,
|
fetchData as fetchDataNode,
|
||||||
NodeBinaryDataFactory,
|
NodeBinaryDataFactory,
|
||||||
@ -22,7 +23,6 @@ import { NullStream, StringStream } from "../../src/core/stream.js";
|
|||||||
import { Page, PDFDocument } from "../../src/core/document.js";
|
import { Page, PDFDocument } from "../../src/core/document.js";
|
||||||
import { DOMBinaryDataFactory } from "../../src/display/binary_data_factory.js";
|
import { DOMBinaryDataFactory } from "../../src/display/binary_data_factory.js";
|
||||||
import { fetchData as fetchDataDOM } from "../../src/display/display_utils.js";
|
import { fetchData as fetchDataDOM } from "../../src/display/display_utils.js";
|
||||||
import { Ref } from "../../src/core/primitives.js";
|
|
||||||
|
|
||||||
const TEST_PDFS_PATH = isNodeJS ? "./test/pdfs/" : "../pdfs/";
|
const TEST_PDFS_PATH = isNodeJS ? "./test/pdfs/" : "../pdfs/";
|
||||||
|
|
||||||
@ -74,6 +74,39 @@ function buildGetDocumentParams(filename, options) {
|
|||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Builds a PDF sound object's stream dictionary (ISO 32000-1, 12.5.6.16). Pass
|
||||||
|
// a key as `null` to omit it (to exercise defaults/missing entries); pass
|
||||||
|
// `type: true` to add the optional `/Type /Sound` entry.
|
||||||
|
function createSoundDict({
|
||||||
|
R = 22050,
|
||||||
|
C = 1,
|
||||||
|
B = 16,
|
||||||
|
E = "Signed",
|
||||||
|
CO,
|
||||||
|
type = false,
|
||||||
|
} = {}) {
|
||||||
|
const dict = new Dict();
|
||||||
|
if (type) {
|
||||||
|
dict.set("Type", Name.get("Sound"));
|
||||||
|
}
|
||||||
|
if (R !== null) {
|
||||||
|
dict.set("R", R);
|
||||||
|
}
|
||||||
|
if (C !== null) {
|
||||||
|
dict.set("C", C);
|
||||||
|
}
|
||||||
|
if (B !== null) {
|
||||||
|
dict.set("B", B);
|
||||||
|
}
|
||||||
|
if (E !== null) {
|
||||||
|
dict.set("E", Name.get(E));
|
||||||
|
}
|
||||||
|
if (CO) {
|
||||||
|
dict.set("CO", Name.get(CO));
|
||||||
|
}
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
function getCrossOriginHostname(hostname) {
|
function getCrossOriginHostname(hostname) {
|
||||||
if (hostname === "localhost") {
|
if (hostname === "localhost") {
|
||||||
// Note: This does not work if localhost is listening on IPv6 only.
|
// Note: This does not work if localhost is listening on IPv6 only.
|
||||||
@ -254,6 +287,7 @@ export {
|
|||||||
buildGetDocumentParams,
|
buildGetDocumentParams,
|
||||||
CMAP_URL,
|
CMAP_URL,
|
||||||
createIdFactory,
|
createIdFactory,
|
||||||
|
createSoundDict,
|
||||||
DefaultBinaryDataFactory,
|
DefaultBinaryDataFactory,
|
||||||
DefaultFileReaderFactory,
|
DefaultFileReaderFactory,
|
||||||
fetchBuiltInCMapHelper,
|
fetchBuiltInCMapHelper,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user