mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-06-24 17:05:47 +02:00
Compare commits
75 Commits
bf9ae7622f
...
25c7d9eaac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25c7d9eaac | ||
|
|
d8c08b980a | ||
|
|
98e3a85a44 | ||
|
|
d6a2b91243 | ||
|
|
52d574c539 | ||
|
|
9b5cd3db64 | ||
|
|
93f01aa412 | ||
|
|
83c37357dc | ||
|
|
223170694c | ||
|
|
42db304268 | ||
|
|
78cc2e3d38 | ||
|
|
0f90987927 | ||
|
|
74471651c7 | ||
|
|
d79043b3af | ||
|
|
429b469ecb | ||
|
|
abe8b564a3 | ||
|
|
5a4d93a238 | ||
|
|
16a9f1cafc | ||
|
|
5b99173043 | ||
|
|
b13ec1fc3c | ||
|
|
957e004e38 | ||
|
|
60591388a8 | ||
|
|
f500cffd2e | ||
|
|
0d69cc4dcf | ||
|
|
00af75905f | ||
|
|
9ebee868c9 | ||
|
|
abb8e31408 | ||
|
|
1e62f01773 | ||
|
|
e05b6d6f59 | ||
|
|
1e9e8fad7e | ||
|
|
d1da73931a | ||
|
|
deb532334f | ||
|
|
cd8a78c4e2 | ||
|
|
2fb2bc13e0 | ||
|
|
f2bf57f444 | ||
|
|
b942714f45 | ||
|
|
2cef900834 | ||
|
|
e98b43879e | ||
|
|
af65d7f930 | ||
|
|
afa9b6ef9b | ||
|
|
f290da0e4b | ||
|
|
036436a0be | ||
|
|
600a4bb1ee | ||
|
|
c66f9f2497 | ||
|
|
62b88aa56e | ||
|
|
65b8aec420 | ||
|
|
7f2bb0e991 | ||
|
|
3450e95179 | ||
|
|
7c5087cc16 | ||
|
|
d27b9ab5fa | ||
|
|
e8f07d7ca3 | ||
|
|
eda97fe8fc | ||
|
|
26474b09cb | ||
|
|
77a2dc8532 | ||
|
|
367f994d94 | ||
|
|
69efba1ca2 | ||
|
|
e5330f06fa | ||
|
|
7a7e7049c1 | ||
|
|
153cef615e | ||
|
|
d9491ffce3 | ||
|
|
0fab33c2e6 | ||
|
|
5e18cfd8f0 | ||
|
|
cd4fd7563c | ||
|
|
d9665c4e0f | ||
|
|
195bfdcfbd | ||
|
|
b708f59d04 | ||
|
|
949497a3c4 | ||
|
|
16d82e094f | ||
|
|
7ade637449 | ||
|
|
f8f497a03a | ||
|
|
056837dace | ||
|
|
71e9eb25f6 | ||
|
|
94de952b65 | ||
|
|
26dc195a65 | ||
|
|
204f9203bd |
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@ -24,13 +24,13 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Autobuild CodeQL
|
||||
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
|
||||
- name: Perform CodeQL analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
|
||||
21
.github/workflows/integration_tests.yml
vendored
21
.github/workflows/integration_tests.yml
vendored
@ -41,6 +41,7 @@ jobs:
|
||||
skip: --noFirefox
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: code-coverage
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@ -75,13 +76,13 @@ jobs:
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
run: Set-DisplayResolution -Width 1920 -Height 1080 -Force
|
||||
|
||||
- name: Run integration tests (Windows)
|
||||
- name: Run integration tests with code coverage (Windows)
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
run: npx gulp integrationtest ${{ matrix.skip }}
|
||||
run: npx gulp integrationtest --coverage --coverage-output build/coverage/integration ${{ matrix.skip }}
|
||||
|
||||
- name: Run integration tests (Linux)
|
||||
- name: Run integration tests with code coverage (Linux)
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
run: xvfb-run -a --server-args="-screen 0, 1920x1080x24" npx gulp integrationtest ${{ matrix.skip }}
|
||||
run: xvfb-run -a --server-args="-screen 0, 1920x1080x24" npx gulp integrationtest --coverage --coverage-output build/coverage/integration ${{ matrix.skip }}
|
||||
|
||||
- name: Save cached PDF files
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@ -89,3 +90,15 @@ jobs:
|
||||
path: test/pdfs/*.pdf
|
||||
key: cached-pdf-files-${{ hashFiles('test/pdfs/*.pdf') }}
|
||||
enableCrossOsArchive: true
|
||||
|
||||
- name: Upload results to Codecov
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
files: ./build/coverage/integration/lcov.info
|
||||
flags: integrationtest
|
||||
name: codecov-umbrella
|
||||
disable_search: true
|
||||
disable_telem: true
|
||||
verbose: true
|
||||
|
||||
2
.github/workflows/notify-pdf-sync.yml
vendored
2
.github/workflows/notify-pdf-sync.yml
vendored
@ -46,7 +46,7 @@ jobs:
|
||||
- name: Generate app token
|
||||
if: steps.check.outputs.has_added == 'true'
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ secrets.CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
2
.github/workflows/update_locales.yml
vendored
2
.github/workflows/update_locales.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate app token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ secrets.CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
3
codecov.yml
Normal file
3
codecov.yml
Normal file
@ -0,0 +1,3 @@
|
||||
flag_management:
|
||||
default_rules:
|
||||
carryforward: true
|
||||
0
external/iccs/CGATS001Compat-v2-micro.icc
vendored
Executable file → Normal file
0
external/iccs/CGATS001Compat-v2-micro.icc
vendored
Executable file → Normal file
95
gulpfile.mjs
95
gulpfile.mjs
@ -113,6 +113,7 @@ const BABEL_PRESET_ENV_OPTS = Object.freeze({
|
||||
|
||||
const DEFINES = Object.freeze({
|
||||
SKIP_BABEL: true,
|
||||
WORKER_THREAD: false,
|
||||
TESTING: undefined,
|
||||
COVERAGE: undefined,
|
||||
// The main build targets:
|
||||
@ -536,8 +537,12 @@ function createSandboxBundle(defines, extraOptions = undefined) {
|
||||
}
|
||||
|
||||
function createWorkerBundle(defines) {
|
||||
const workerFileConfig = createWebpackConfig(defines, {
|
||||
filename: defines.MINIFIED ? "pdf.worker.min.mjs" : "pdf.worker.mjs",
|
||||
const workerDefines = {
|
||||
...defines,
|
||||
WORKER_THREAD: true,
|
||||
};
|
||||
const workerFileConfig = createWebpackConfig(workerDefines, {
|
||||
filename: workerDefines.MINIFIED ? "pdf.worker.min.mjs" : "pdf.worker.mjs",
|
||||
library: {
|
||||
type: "module",
|
||||
},
|
||||
@ -1209,6 +1214,10 @@ function discardCommentsCSS() {
|
||||
}
|
||||
|
||||
function preprocessHTML(source, defines) {
|
||||
defines = {
|
||||
...defines,
|
||||
TESTING: defines.TESTING ?? process.env.TESTING === "true",
|
||||
};
|
||||
const outName = getTempFile("~preprocess", ".html");
|
||||
preprocess(source, outName, defines);
|
||||
const out = fs.readFileSync(outName).toString();
|
||||
@ -2311,6 +2320,86 @@ gulp.task("lint-licenses", function (done) {
|
||||
});
|
||||
});
|
||||
|
||||
gulp.task("lint-chmod", function (done) {
|
||||
console.log("\n### Checking executable bit on tracked and untracked files");
|
||||
|
||||
// Files allowed to keep the executable bit (shebang scripts).
|
||||
const EXECUTABLE_FILES = new Set(["test/chromium/test-telemetry.js"]);
|
||||
|
||||
// Cover untracked-but-not-ignored files too: a `gulp lint` run before
|
||||
// `git add` would otherwise miss any 0755 file the developer just created.
|
||||
// `-z` is NUL-separated to tolerate whitespace in paths; `--exclude-standard`
|
||||
// honours .gitignore / .git/info/exclude / core.excludesFile.
|
||||
let lsFiles;
|
||||
try {
|
||||
lsFiles = execSync("git ls-files -coz --exclude-standard", {
|
||||
encoding: "utf8",
|
||||
});
|
||||
} catch (e) {
|
||||
done(e);
|
||||
return;
|
||||
}
|
||||
|
||||
const offenders = [];
|
||||
for (const file of lsFiles.split("\0")) {
|
||||
if (!file || EXECUTABLE_FILES.has(file)) {
|
||||
continue;
|
||||
}
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(file);
|
||||
} catch {
|
||||
// Tracked file removed from the working tree, broken symlink, etc.
|
||||
continue;
|
||||
}
|
||||
// Skip symlinks (stored as mode 120000) and directories (submodules show
|
||||
// up here as 160000 in the index, never as a regular file on disk).
|
||||
if (!stat.isFile()) {
|
||||
continue;
|
||||
}
|
||||
// Match git's own heuristic in `ce_permissions`: only the owner execute
|
||||
// bit matters when deciding between 100644 and 100755.
|
||||
if (stat.mode & 0o100) {
|
||||
offenders.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (offenders.length === 0) {
|
||||
console.log("files checked, no errors found");
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.argv.includes("--fix")) {
|
||||
for (const file of offenders.sort()) {
|
||||
console.log(` Unexpected executable bit: ${file}`);
|
||||
}
|
||||
done(
|
||||
new Error(
|
||||
"Executable-bit check failed (run `gulp lint-chmod --fix` to clear)."
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Working-tree-only fix, like every other --fix in `gulp lint`: clear the
|
||||
// bit on disk and let the user stage the change. On filesystems with
|
||||
// `core.filemode=false` (e.g. Windows) this is a no-op and the offending
|
||||
// mode must be cleared with `git update-index --chmod=-x` instead.
|
||||
for (const file of offenders.sort()) {
|
||||
try {
|
||||
const { mode } = fs.statSync(file);
|
||||
fs.chmodSync(file, mode & ~0o111);
|
||||
} catch (e) {
|
||||
done(e);
|
||||
return;
|
||||
}
|
||||
console.log(` cleared executable bit: ${file}`);
|
||||
}
|
||||
console.log(`done: ${offenders.length} file(s) updated`);
|
||||
done();
|
||||
});
|
||||
|
||||
gulp.task("lint", function (done) {
|
||||
console.log("\n### Linting JS/CSS/JSON/SVG/HTML files");
|
||||
|
||||
@ -2375,7 +2464,7 @@ gulp.task("lint", function (done) {
|
||||
return;
|
||||
}
|
||||
|
||||
gulp.task("lint-licenses")(done);
|
||||
gulp.series("lint-licenses", "lint-chmod")(done);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -661,12 +661,58 @@ pdfjs-views-manager-view-selector-button-label = Visninger
|
||||
pdfjs-views-manager-pages-title = Sider
|
||||
pdfjs-views-manager-attachments-title = Vedhæftede filer
|
||||
pdfjs-views-manager-pages-option-label = Sider
|
||||
pdfjs-views-manager-attachments-option-label = Vedhæftede filer
|
||||
pdfjs-views-manager-layers-option-label = Lag
|
||||
pdfjs-views-manager-add-file-button =
|
||||
.title = Tilføj fil
|
||||
pdfjs-views-manager-add-file-button-label = Tilføj fil
|
||||
# Variables:
|
||||
# $count (Number) - the number of selected pages.
|
||||
pdfjs-views-manager-pages-status-action-label =
|
||||
{ $count ->
|
||||
[one] { $count } valgt
|
||||
*[other] { $count } valgt
|
||||
}
|
||||
pdfjs-views-manager-pages-status-none-action-label = Vælg sider
|
||||
pdfjs-views-manager-pages-status-action-button-label = Håndter
|
||||
pdfjs-views-manager-pages-status-copy-button-label = Kopier
|
||||
pdfjs-views-manager-pages-status-cut-button-label = Klip
|
||||
pdfjs-views-manager-pages-status-delete-button-label = Slet
|
||||
# Variables:
|
||||
# $count (Number) - the number of selected pages to be cut.
|
||||
pdfjs-views-manager-status-undo-cut-label =
|
||||
{ $count ->
|
||||
[one] 1 side klippet
|
||||
*[other] { $count } sider klippet
|
||||
}
|
||||
# Variables:
|
||||
# $count (Number) - the number of selected pages to be copied.
|
||||
pdfjs-views-manager-pages-status-undo-copy-label =
|
||||
{ $count ->
|
||||
[one] 1 side kopieret
|
||||
*[other] { $count } sider kopieret
|
||||
}
|
||||
# Variables:
|
||||
# $count (Number) - the number of selected pages to be deleted.
|
||||
pdfjs-views-manager-pages-status-undo-delete-label =
|
||||
{ $count ->
|
||||
[one] 1 side slettet
|
||||
*[other] { $count } sider slettet
|
||||
}
|
||||
pdfjs-views-manager-status-undo-button-label = Fortryd
|
||||
pdfjs-views-manager-status-done-button-label = Færdig
|
||||
pdfjs-views-manager-status-close-button =
|
||||
.title = Luk
|
||||
pdfjs-views-manager-status-close-button-label = Luk
|
||||
pdfjs-views-manager-paste-button-label = Indsæt
|
||||
pdfjs-views-manager-paste-button-before =
|
||||
.title = Indsæt før første side
|
||||
# Variables:
|
||||
# $page (Number) - the page number after which the paste button is.
|
||||
pdfjs-views-manager-paste-button-after =
|
||||
.title = Indsæt efter side { $page }
|
||||
pdfjs-toggle-views-manager-button1 =
|
||||
.title = Håndter sider
|
||||
|
||||
## Main menu for adding/removing signatures
|
||||
|
||||
|
||||
@ -661,6 +661,8 @@ pdfjs-views-manager-view-selector-button =
|
||||
.title = Vistas
|
||||
pdfjs-views-manager-view-selector-button-label = Vistas
|
||||
pdfjs-views-manager-pages-title = Páginas
|
||||
pdfjs-views-manager-outlines-title1 = Esquema del documento
|
||||
.title = Esquema del documento (doble-clic para expandir/contraer todos los elementos)
|
||||
pdfjs-views-manager-attachments-title = Adjuntos
|
||||
pdfjs-views-manager-layers-title1 = Capas
|
||||
.title = Capas (doble clic para restablecer todas las capas a su estado predeterminado)
|
||||
@ -726,6 +728,7 @@ pdfjs-views-manager-paste-button-after =
|
||||
# Badge used to promote a new feature in the UI, keep it as short as possible.
|
||||
# It's spelled uppercase for English, but it can be translated as usual.
|
||||
pdfjs-new-badge-content = NUEVO
|
||||
pdfjs-views-manager-waiting-for-file = Subiendo el archivo…
|
||||
pdfjs-toggle-views-manager-button1 =
|
||||
.title = Administrar páginas
|
||||
|
||||
|
||||
@ -686,6 +686,10 @@ pdfjs-views-manager-pages-status-action-button-label = Upravljaj
|
||||
pdfjs-views-manager-pages-status-copy-button-label = Kopiraj
|
||||
pdfjs-views-manager-pages-status-cut-button-label = Izreži
|
||||
pdfjs-views-manager-pages-status-delete-button-label = Izbriši
|
||||
pdfjs-views-manager-status-warning-cut-label = Nije moguće izrezati. Osvježi stranicu i pokušaj ponovo.
|
||||
pdfjs-views-manager-status-warning-copy-label = Nije moguće kopirati. Osvježi stranicu i pokušaj ponovo.
|
||||
pdfjs-views-manager-status-warning-delete-label = Nije moguće izbrisati. Osvježi stranicu i pokušaj ponovo.
|
||||
pdfjs-views-manager-status-warning-save-label = Nije moguće spremiti. Osvježi stranicu i pokušaj ponovo.
|
||||
pdfjs-views-manager-status-undo-button-label = Poništi
|
||||
pdfjs-views-manager-status-done-button-label = Gotovo
|
||||
pdfjs-views-manager-status-close-button =
|
||||
|
||||
@ -488,8 +488,8 @@ pdfjs-editor-new-alt-text-error-close-button = Lat att
|
||||
# Variables:
|
||||
# $totalSize (Number) - the total size (in MB) of the AI model.
|
||||
# $downloadedSize (Number) - the downloaded size (in MB) of the AI model.
|
||||
pdfjs-editor-new-alt-text-ai-model-downloading-progress = Lastar ned AI-modell med alternativ tekst ({ $downloadedSize } av { $totalSize } MB)
|
||||
.aria-valuetext = Lastar ned AI-modell med alternativ tekst ({ $downloadedSize } av { $totalSize } MB)
|
||||
pdfjs-editor-new-alt-text-ai-model-downloading-progress = Lastar ned KI-modell med alternativ tekst ({ $downloadedSize } av { $totalSize } MB)
|
||||
.aria-valuetext = Lastar ned KI-modell med alternativ tekst ({ $downloadedSize } av { $totalSize } MB)
|
||||
# This is a button that users can click to edit the alt text they have already added.
|
||||
pdfjs-editor-new-alt-text-added-button =
|
||||
.aria-label = Alternativ tekst lagt til
|
||||
@ -518,7 +518,7 @@ pdfjs-editor-alt-text-settings-create-model-button-label = Opprett alternativ te
|
||||
pdfjs-editor-alt-text-settings-create-model-description = Foreslår skildringar for å hjelpe folk som ikkje kan sjå bildet eller når bildet ikkje blir lasta inn.
|
||||
# Variables:
|
||||
# $totalSize (Number) - the total size (in MB) of the AI model.
|
||||
pdfjs-editor-alt-text-settings-download-model-label = AI-modell for alternativ tekst ({ $totalSize } MB)
|
||||
pdfjs-editor-alt-text-settings-download-model-label = KI-modell for alternativ tekst ({ $totalSize } MB)
|
||||
pdfjs-editor-alt-text-settings-ai-model-description = Køyrer lokalt på eininga di slik at dataa dine blir verande private. Påkravd for automatisk alternativ tekst.
|
||||
pdfjs-editor-alt-text-settings-delete-model-button = Slett
|
||||
pdfjs-editor-alt-text-settings-download-model-button = Last ned
|
||||
|
||||
@ -728,6 +728,7 @@ pdfjs-views-manager-paste-button-after =
|
||||
# Badge used to promote a new feature in the UI, keep it as short as possible.
|
||||
# It's spelled uppercase for English, but it can be translated as usual.
|
||||
pdfjs-new-badge-content = ਨਵਾਂ
|
||||
pdfjs-views-manager-waiting-for-file = …ਫ਼ਾਇਲ ਨੂੰ ਅੱਪਲੋਡ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ
|
||||
pdfjs-toggle-views-manager-button1 =
|
||||
.title = ਸਫ਼ਿਆਂ ਦਾ ਇੰਤਜ਼ਾਮ
|
||||
|
||||
|
||||
@ -33,7 +33,6 @@ import {
|
||||
OPS,
|
||||
RenderingIntentFlag,
|
||||
shadow,
|
||||
stringToPDFString,
|
||||
unreachable,
|
||||
Util,
|
||||
warn,
|
||||
@ -53,8 +52,6 @@ import {
|
||||
numberToString,
|
||||
RESOURCES_KEYS_OPERATOR_LIST,
|
||||
RESOURCES_KEYS_TEXT_CONTENT,
|
||||
stringToAsciiOrUTF16BE,
|
||||
stringToUTF16String,
|
||||
} from "./core_utils.js";
|
||||
import {
|
||||
createDefaultAppearance,
|
||||
@ -66,6 +63,11 @@ import {
|
||||
import { DateFormats, TimeFormats } from "../shared/scripting_utils.js";
|
||||
import { Dict, isName, isRefsEqual, Name, Ref, RefSet } from "./primitives.js";
|
||||
import { Stream, StringStream } from "./stream.js";
|
||||
import {
|
||||
stringToAsciiOrUTF16BE,
|
||||
stringToPDFString,
|
||||
stringToUTF16String,
|
||||
} from "./string_utils.js";
|
||||
import { BaseStream } from "./base_stream.js";
|
||||
import { bidi } from "./bidi.js";
|
||||
import { Catalog } from "./catalog.js";
|
||||
|
||||
@ -22,7 +22,6 @@ import {
|
||||
objectSize,
|
||||
PermissionFlag,
|
||||
shadow,
|
||||
stringToPDFString,
|
||||
stringToUTF8String,
|
||||
warn,
|
||||
} from "../shared/util.js";
|
||||
@ -53,6 +52,7 @@ import { clearGlobalCaches } from "./cleanup_helper.js";
|
||||
import { ColorSpaceUtils } from "./colorspace_utils.js";
|
||||
import { FileSpec } from "./file_spec.js";
|
||||
import { MetadataParser } from "./metadata_parser.js";
|
||||
import { stringToPDFString } from "./string_utils.js";
|
||||
import { StructTreeRoot } from "./struct_tree.js";
|
||||
|
||||
const isRef = v => v instanceof Ref;
|
||||
|
||||
@ -108,6 +108,11 @@ const CFFStandardStrings = [
|
||||
|
||||
const NUM_STANDARD_CFF_STRINGS = 391;
|
||||
|
||||
const DEFAULT_BLUE_SCALE = 0.039625;
|
||||
const DEFAULT_BLUE_SHIFT = 7;
|
||||
const DEFAULT_BLUE_FUZZ = 1;
|
||||
const DEFAULT_EXPANSION_FACTOR = 0.06;
|
||||
|
||||
const CharstringValidationData = [
|
||||
/* 0 */ null,
|
||||
/* 1 */ { id: "hstem", min: 2, stackClearing: true, stem: true },
|
||||
@ -262,8 +267,16 @@ class CFFParser {
|
||||
properties.fontMatrix = fontMatrix;
|
||||
}
|
||||
|
||||
const fontBBox = topDict.getByName("FontBBox");
|
||||
if (fontBBox) {
|
||||
let fontBBox = topDict.getByName("FontBBox");
|
||||
if (fontBBox?.every(coord => coord === 0) && properties.bbox) {
|
||||
fontBBox = Util.normalizeRect(
|
||||
properties.bbox.map(coord =>
|
||||
coord > 0x7fff && coord <= 0xffff ? coord - 0x10000 : coord
|
||||
)
|
||||
);
|
||||
topDict.setByName("FontBBox", fontBBox);
|
||||
}
|
||||
if (fontBBox?.some(coord => coord !== 0)) {
|
||||
// adjusting ascent/descent
|
||||
properties.ascent = Math.max(fontBBox[3], fontBBox[1]);
|
||||
properties.descent = Math.min(fontBBox[1], fontBBox[3]);
|
||||
@ -785,10 +798,28 @@ class CFFParser {
|
||||
);
|
||||
parentDict.privateDict = privateDict;
|
||||
|
||||
if (privateDict.getByName("ExpansionFactor") === 0) {
|
||||
const blueScale = privateDict.getByName("BlueScale");
|
||||
const blueShift = privateDict.getByName("BlueShift");
|
||||
const blueFuzz = privateDict.getByName("BlueFuzz");
|
||||
const expansionFactor = privateDict.getByName("ExpansionFactor");
|
||||
if (
|
||||
blueScale === 0 &&
|
||||
blueShift === 0 &&
|
||||
blueFuzz === 0 &&
|
||||
expansionFactor === 0
|
||||
) {
|
||||
// Ghostscript can fail to initialize Private DICT defaults before
|
||||
// writing them, which leaves omitted blue zone values as explicit
|
||||
// zeroes. This has been seen in FDArray entries.
|
||||
privateDict.setByName("BlueScale", DEFAULT_BLUE_SCALE);
|
||||
privateDict.setByName("BlueShift", DEFAULT_BLUE_SHIFT);
|
||||
privateDict.setByName("BlueFuzz", DEFAULT_BLUE_FUZZ);
|
||||
}
|
||||
|
||||
if (expansionFactor === 0) {
|
||||
// Firefox doesn't render correctly such a font on Windows (see issue
|
||||
// 15289), hence we just reset it to its default value.
|
||||
privateDict.setByName("ExpansionFactor", 0.06);
|
||||
privateDict.setByName("ExpansionFactor", DEFAULT_EXPANSION_FACTOR);
|
||||
}
|
||||
|
||||
// Parse the Subrs index also since it's relative to the private dict.
|
||||
@ -1247,16 +1278,16 @@ const CFFPrivateDictLayout = [
|
||||
[7, "OtherBlues", "delta", null],
|
||||
[8, "FamilyBlues", "delta", null],
|
||||
[9, "FamilyOtherBlues", "delta", null],
|
||||
[[12, 9], "BlueScale", "num", 0.039625],
|
||||
[[12, 10], "BlueShift", "num", 7],
|
||||
[[12, 11], "BlueFuzz", "num", 1],
|
||||
[[12, 9], "BlueScale", "num", DEFAULT_BLUE_SCALE],
|
||||
[[12, 10], "BlueShift", "num", DEFAULT_BLUE_SHIFT],
|
||||
[[12, 11], "BlueFuzz", "num", DEFAULT_BLUE_FUZZ],
|
||||
[10, "StdHW", "num", null],
|
||||
[11, "StdVW", "num", null],
|
||||
[[12, 12], "StemSnapH", "delta", null],
|
||||
[[12, 13], "StemSnapV", "delta", null],
|
||||
[[12, 14], "ForceBold", "num", 0],
|
||||
[[12, 17], "LanguageGroup", "num", 0],
|
||||
[[12, 18], "ExpansionFactor", "num", 0.06],
|
||||
[[12, 18], "ExpansionFactor", "num", DEFAULT_EXPANSION_FACTOR],
|
||||
[[12, 19], "initialRandomSeed", "num", 0],
|
||||
[20, "defaultWidthX", "num", 0],
|
||||
[21, "nominalWidthX", "num", 0],
|
||||
|
||||
@ -19,12 +19,12 @@ import {
|
||||
BaseException,
|
||||
makeArr,
|
||||
objectSize,
|
||||
stringToPDFString,
|
||||
Util,
|
||||
warn,
|
||||
} from "../shared/util.js";
|
||||
import { Dict, isName, isRefsEqual, Name, Ref, RefSet } from "./primitives.js";
|
||||
import { BaseStream } from "./base_stream.js";
|
||||
import { stringToPDFString } from "./string_utils.js";
|
||||
|
||||
const PDF_VERSION_REGEXP = /^[1-9]\.\d$/;
|
||||
const MAX_INT_32 = 2 ** 31 - 1;
|
||||
@ -684,45 +684,6 @@ function getNewAnnotationsMap(annotationStorage) {
|
||||
return newAnnotationsByPage.size > 0 ? newAnnotationsByPage : null;
|
||||
}
|
||||
|
||||
// If the string is null or undefined then it is returned as is.
|
||||
function stringToAsciiOrUTF16BE(str) {
|
||||
if (str === null || str === undefined) {
|
||||
return str;
|
||||
}
|
||||
return isAscii(str) ? str : stringToUTF16String(str, /* bigEndian = */ true);
|
||||
}
|
||||
|
||||
function isAscii(str) {
|
||||
if (typeof str !== "string") {
|
||||
return false;
|
||||
}
|
||||
return !str || /^[\x00-\x7F]*$/.test(str);
|
||||
}
|
||||
|
||||
function stringToUTF16HexString(str) {
|
||||
const buf = [];
|
||||
for (let i = 0, ii = str.length; i < ii; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
buf.push(Util.hexNums[(char >> 8) & 0xff], Util.hexNums[char & 0xff]);
|
||||
}
|
||||
return buf.join("");
|
||||
}
|
||||
|
||||
function stringToUTF16String(str, bigEndian = false) {
|
||||
const buf = [];
|
||||
if (bigEndian) {
|
||||
buf.push("\xFE\xFF");
|
||||
}
|
||||
for (let i = 0, ii = str.length; i < ii; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
buf.push(
|
||||
String.fromCharCode((char >> 8) & 0xff),
|
||||
String.fromCharCode(char & 0xff)
|
||||
);
|
||||
}
|
||||
return buf.join("");
|
||||
}
|
||||
|
||||
function getModificationDate(date = new Date()) {
|
||||
if (!(date instanceof Date)) {
|
||||
date = new Date(date);
|
||||
@ -782,7 +743,6 @@ export {
|
||||
getRotationMatrix,
|
||||
getSizeInBytes,
|
||||
IDENTITY_MATRIX,
|
||||
isAscii,
|
||||
isBooleanArray,
|
||||
isNumberArray,
|
||||
isWhiteSpace,
|
||||
@ -798,9 +758,6 @@ export {
|
||||
recoverJsURL,
|
||||
RESOURCES_KEYS_OPERATOR_LIST,
|
||||
RESOURCES_KEYS_TEXT_CONTENT,
|
||||
stringToAsciiOrUTF16BE,
|
||||
stringToUTF16HexString,
|
||||
stringToUTF16String,
|
||||
toRomanNumerals,
|
||||
validateCSSFont,
|
||||
validateFontName,
|
||||
|
||||
@ -18,7 +18,6 @@ import {
|
||||
escapePDFName,
|
||||
getRotationMatrix,
|
||||
numberToString,
|
||||
stringToUTF16HexString,
|
||||
} from "./core_utils.js";
|
||||
import { Dict, Name } from "./primitives.js";
|
||||
import {
|
||||
@ -33,6 +32,7 @@ import { EvaluatorPreprocessor } from "./evaluator.js";
|
||||
import { LocalColorSpaceCache } from "./image_utils.js";
|
||||
import { PDFFunctionFactory } from "./function.js";
|
||||
import { StringStream } from "./stream.js";
|
||||
import { stringToUTF16HexString } from "./string_utils.js";
|
||||
|
||||
class DefaultAppearanceEvaluator extends EvaluatorPreprocessor {
|
||||
constructor(str) {
|
||||
|
||||
@ -26,7 +26,6 @@ import {
|
||||
RenderingIntentFlag,
|
||||
shadow,
|
||||
stringToBytes,
|
||||
stringToPDFString,
|
||||
stringToUTF8String,
|
||||
unreachable,
|
||||
Util,
|
||||
@ -76,6 +75,7 @@ import { OperatorList } from "./operator_list.js";
|
||||
import { PartialEvaluator } from "./evaluator.js";
|
||||
import { PDFImage } from "./image.js";
|
||||
import { StreamsSequenceStream } from "./decode_stream.js";
|
||||
import { stringToPDFString } from "./string_utils.js";
|
||||
import { StructTreePage } from "./struct_tree.js";
|
||||
import { XFAFactory } from "./xfa/factory.js";
|
||||
import { XRef } from "./xref.js";
|
||||
|
||||
@ -25,15 +25,15 @@ import {
|
||||
getInheritableProperty,
|
||||
getModificationDate,
|
||||
getNewAnnotationsMap,
|
||||
stringToAsciiOrUTF16BE,
|
||||
} from "../core_utils.js";
|
||||
import { Dict, isName, Name, Ref, RefSet, RefSetCache } from "../primitives.js";
|
||||
import { incrementalUpdate, writeValue } from "../writer.js";
|
||||
import { NameTree, NumberTree } from "../name_number_tree.js";
|
||||
import { stringToBytes, stringToPDFString } from "../../shared/util.js";
|
||||
import { stringToAsciiOrUTF16BE, stringToPDFString } from "../string_utils.js";
|
||||
import { AnnotationFactory } from "../annotation.js";
|
||||
import { BaseStream } from "../base_stream.js";
|
||||
import { StringStream } from "../stream.js";
|
||||
import { stringToBytes } from "../../shared/util.js";
|
||||
|
||||
const MAX_LEAVES_PER_PAGES_NODE = 16;
|
||||
const MAX_IN_NAME_TREE_NODE = 64;
|
||||
|
||||
@ -26,7 +26,6 @@ import {
|
||||
normalizeUnicode,
|
||||
OPS,
|
||||
shadow,
|
||||
stringToPDFString,
|
||||
TextRenderingMode,
|
||||
Util,
|
||||
warn,
|
||||
@ -90,6 +89,7 @@ import { getUnicodeForGlyph } from "./unicode.js";
|
||||
import { MurmurHash3_64 } from "../shared/murmurhash3.js";
|
||||
import { PDFImage } from "./image.js";
|
||||
import { Stream } from "./stream.js";
|
||||
import { stringToPDFString } from "./string_utils.js";
|
||||
|
||||
const DefaultPartialEvaluatorOptions = Object.freeze({
|
||||
maxImageSize: -1,
|
||||
|
||||
@ -13,9 +13,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { stringToPDFString, stripPath, warn } from "../shared/util.js";
|
||||
import { stripPath, warn } from "../shared/util.js";
|
||||
import { BaseStream } from "./base_stream.js";
|
||||
import { Dict } from "./primitives.js";
|
||||
import { stringToPDFString } from "./string_utils.js";
|
||||
|
||||
function pickPlatformItem(dict) {
|
||||
if (dict instanceof Dict) {
|
||||
|
||||
@ -163,6 +163,9 @@ function lookupCmap(ranges, unicode) {
|
||||
}
|
||||
|
||||
function compileGlyf(code, cmds, font, visitedGlyphs = new Set()) {
|
||||
if (!code?.length) {
|
||||
return;
|
||||
}
|
||||
if (visitedGlyphs.has(code)) {
|
||||
warn("compileGlyf: skipping recursive composite glyph reference.");
|
||||
return;
|
||||
|
||||
@ -55,13 +55,13 @@ import {
|
||||
getSupplementalGlyphMapForArialBlack,
|
||||
getSupplementalGlyphMapForCalibri,
|
||||
} from "./standard_fonts.js";
|
||||
import { GlyfTable, pruneCompositeGlyphCycles } from "./glyf.js";
|
||||
import { IdentityToUnicodeMap, ToUnicodeMap } from "./to_unicode_map.js";
|
||||
import { CFFFont } from "./cff_font.js";
|
||||
import { compileFontInfo } from "./obj_bin_transform_core.js";
|
||||
import { DataBuilder } from "./data_builder.js";
|
||||
import { FontRendererFactory } from "./font_renderer.js";
|
||||
import { getFontBasicMetrics } from "./metrics.js";
|
||||
import { GlyfTable } from "./glyf.js";
|
||||
import { OpenTypeFileBuilder } from "./opentype_file_builder.js";
|
||||
import { Stream } from "./stream.js";
|
||||
import { Type1Font } from "./type1_font.js";
|
||||
@ -720,6 +720,11 @@ function createCmapTable(glyphs, toUnicodeExtraMap, numGlyphs) {
|
||||
function validateOS2Table(os2, file) {
|
||||
file.pos = (file.start || 0) + os2.offset;
|
||||
const version = file.getUint16();
|
||||
// https://learn.microsoft.com/en-us/typography/opentype/spec/os2
|
||||
const minLength = [78, 86, 96, 96, 96, 100][version];
|
||||
if (minLength === undefined || os2.length < minLength) {
|
||||
return false;
|
||||
}
|
||||
// TODO verify all OS/2 tables fields, but currently we validate only those
|
||||
// that give us issues
|
||||
file.skip(60); // skipping type, misc sizes, panose, unicode ranges
|
||||
@ -2195,18 +2200,25 @@ class Font {
|
||||
last.endOffset = oldGlyfDataLength;
|
||||
}
|
||||
|
||||
const droppedGlyphs = pruneCompositeGlyphCycles(
|
||||
oldGlyfData,
|
||||
locaEntries,
|
||||
numGlyphs
|
||||
);
|
||||
const missingGlyphs = Object.create(null);
|
||||
let writeOffset = 0;
|
||||
itemEncode(locaData, 0, writeOffset);
|
||||
for (i = 0, j = itemSize; i < numGlyphs; i++, j += itemSize) {
|
||||
const glyphProfile = sanitizeGlyph(
|
||||
oldGlyfData,
|
||||
locaEntries[i].offset,
|
||||
locaEntries[i].endOffset,
|
||||
newGlyfData,
|
||||
writeOffset,
|
||||
hintsValid
|
||||
);
|
||||
const glyphProfile = droppedGlyphs.has(i)
|
||||
? { length: 0, sizeOfInstructions: 0 }
|
||||
: sanitizeGlyph(
|
||||
oldGlyfData,
|
||||
locaEntries[i].offset,
|
||||
locaEntries[i].endOffset,
|
||||
newGlyfData,
|
||||
writeOffset,
|
||||
hintsValid
|
||||
);
|
||||
const newLength = glyphProfile.length;
|
||||
if (newLength === 0) {
|
||||
missingGlyphs[i] = true;
|
||||
@ -2837,6 +2849,19 @@ class Font {
|
||||
maxFunctionDefs = font.getUint16();
|
||||
font.pos += 4;
|
||||
maxSizeOfInstructions = font.getUint16();
|
||||
} else if (isTrueType && version === 0x00005000) {
|
||||
const newMaxp = new Uint8Array(32);
|
||||
writeUint32(newMaxp, 0, 0x00010000);
|
||||
newMaxp[4] = (numGlyphs >> 8) & 0xff;
|
||||
newMaxp[5] = numGlyphs & 0xff;
|
||||
newMaxp.fill(0xff, 6, 14);
|
||||
newMaxp[15] = 2;
|
||||
newMaxp[28] = 0xff;
|
||||
newMaxp[29] = 0xff;
|
||||
newMaxp[31] = 0x10;
|
||||
tables.maxp.data = newMaxp;
|
||||
tables.maxp.length = 32;
|
||||
version = 0x00010000;
|
||||
}
|
||||
|
||||
tables.maxp.data[4] = numGlyphsOut >> 8;
|
||||
|
||||
122
src/core/glyf.js
122
src/core/glyf.js
@ -34,6 +34,8 @@ const WE_HAVE_INSTRUCTIONS = 1 << 8;
|
||||
// const SCALED_COMPONENT_OFFSET = 1 << 11;
|
||||
// const UNSCALED_COMPONENT_OFFSET = 1 << 12;
|
||||
|
||||
const GLYPH_HEADER_SIZE = 10;
|
||||
|
||||
/**
|
||||
* GlyfTable object represents a glyf table containing glyph information:
|
||||
* - glyph header (xMin, yMin, xMax, yMax);
|
||||
@ -218,7 +220,7 @@ class GlyphHeader {
|
||||
|
||||
static parse(pos, glyf) {
|
||||
return [
|
||||
10,
|
||||
GLYPH_HEADER_SIZE,
|
||||
new GlyphHeader({
|
||||
numberOfContours: glyf.getInt16(pos),
|
||||
xMin: glyf.getInt16(pos + 2),
|
||||
@ -230,7 +232,7 @@ class GlyphHeader {
|
||||
}
|
||||
|
||||
getSize() {
|
||||
return 10;
|
||||
return GLYPH_HEADER_SIZE;
|
||||
}
|
||||
|
||||
write(pos, buf) {
|
||||
@ -240,7 +242,7 @@ class GlyphHeader {
|
||||
buf.setInt16(pos + 6, this.xMax);
|
||||
buf.setInt16(pos + 8, this.yMax);
|
||||
|
||||
return 10;
|
||||
return GLYPH_HEADER_SIZE;
|
||||
}
|
||||
|
||||
scale(x, factor) {
|
||||
@ -696,4 +698,116 @@ class CompositeGlyph {
|
||||
scale(x, factor) {}
|
||||
}
|
||||
|
||||
export { GlyfTable };
|
||||
function pruneCompositeGlyphCycles(glyfTable, locaEntries, numGlyphs) {
|
||||
const glyf = new DataView(
|
||||
glyfTable.buffer,
|
||||
glyfTable.byteOffset,
|
||||
glyfTable.byteLength
|
||||
);
|
||||
const components = new Array(numGlyphs);
|
||||
for (let i = 0; i < numGlyphs; i++) {
|
||||
const offset = locaEntries[i].offset;
|
||||
const endOffset = Math.min(locaEntries[i].endOffset, glyf.byteLength);
|
||||
if (endOffset - offset <= GLYPH_HEADER_SIZE || glyf.getInt16(offset) >= 0) {
|
||||
continue;
|
||||
}
|
||||
const comps = [];
|
||||
let p = offset + GLYPH_HEADER_SIZE;
|
||||
while (p + 4 <= endOffset) {
|
||||
const flags = glyf.getUint16(p);
|
||||
const gid = glyf.getUint16(p + 2);
|
||||
let size = 4 + (flags & ARG_1_AND_2_ARE_WORDS ? 4 : 2);
|
||||
if (flags & WE_HAVE_A_SCALE) {
|
||||
size += 2;
|
||||
} else if (flags & WE_HAVE_AN_X_AND_Y_SCALE) {
|
||||
size += 4;
|
||||
} else if (flags & WE_HAVE_A_TWO_BY_TWO) {
|
||||
size += 8;
|
||||
}
|
||||
comps.push({ gid, offset: p, size, flags });
|
||||
p += size;
|
||||
if (!(flags & MORE_COMPONENTS)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (comps.length) {
|
||||
components[i] = comps;
|
||||
}
|
||||
}
|
||||
|
||||
const WHITE = 0,
|
||||
GRAY = 1,
|
||||
BLACK = 2;
|
||||
const state = new Uint8Array(numGlyphs);
|
||||
const backEdges = new Map();
|
||||
for (let start = 0; start < numGlyphs; start++) {
|
||||
if (state[start] !== WHITE || !components[start]) {
|
||||
continue;
|
||||
}
|
||||
const stack = [{ node: start, idx: 0 }];
|
||||
state[start] = GRAY;
|
||||
while (stack.length > 0) {
|
||||
const top = stack.at(-1);
|
||||
const comps = components[top.node];
|
||||
if (!comps || top.idx >= comps.length) {
|
||||
state[top.node] = BLACK;
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
const compIdx = top.idx++;
|
||||
const next = comps[compIdx].gid;
|
||||
if (next >= numGlyphs || state[next] === BLACK) {
|
||||
continue;
|
||||
}
|
||||
if (state[next] === WHITE) {
|
||||
state[next] = GRAY;
|
||||
stack.push({ node: next, idx: 0 });
|
||||
continue;
|
||||
}
|
||||
|
||||
let removeSet = backEdges.get(top.node);
|
||||
if (!removeSet) {
|
||||
removeSet = new Set();
|
||||
backEdges.set(top.node, removeSet);
|
||||
}
|
||||
removeSet.add(compIdx);
|
||||
}
|
||||
}
|
||||
|
||||
const droppedGlyphs = new Set();
|
||||
for (const [gIdx, removeSet] of backEdges) {
|
||||
const comps = components[gIdx];
|
||||
const remaining = [];
|
||||
for (let ci = 0; ci < comps.length; ci++) {
|
||||
if (!removeSet.has(ci)) {
|
||||
remaining.push(comps[ci]);
|
||||
}
|
||||
}
|
||||
if (remaining.length === 0) {
|
||||
droppedGlyphs.add(gIdx);
|
||||
continue;
|
||||
}
|
||||
const start = locaEntries[gIdx].offset;
|
||||
const endOffset = Math.min(locaEntries[gIdx].endOffset, glyf.byteLength);
|
||||
let writePos = start + GLYPH_HEADER_SIZE;
|
||||
for (let ci = 0; ci < remaining.length; ci++) {
|
||||
const c = remaining[ci];
|
||||
const isLast = ci === remaining.length - 1;
|
||||
let newFlags = c.flags & ~WE_HAVE_INSTRUCTIONS;
|
||||
newFlags = isLast
|
||||
? newFlags & ~MORE_COMPONENTS
|
||||
: newFlags | MORE_COMPONENTS;
|
||||
if (writePos !== c.offset) {
|
||||
glyfTable.copyWithin(writePos, c.offset, c.offset + c.size);
|
||||
}
|
||||
glyf.setUint16(writePos, newFlags);
|
||||
writePos += c.size;
|
||||
}
|
||||
if (writePos < endOffset) {
|
||||
glyfTable.fill(0, writePos, endOffset);
|
||||
}
|
||||
}
|
||||
return droppedGlyphs;
|
||||
}
|
||||
|
||||
export { GlyfTable, pruneCompositeGlyphCycles };
|
||||
|
||||
121
src/core/string_utils.js
Normal file
121
src/core/string_utils.js
Normal file
@ -0,0 +1,121 @@
|
||||
/* Copyright 2019 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 { stringToBytes, Util, warn } from "../shared/util.js";
|
||||
|
||||
function isAscii(str) {
|
||||
return typeof str === "string" && (!str || /^[\x00-\x7F]*$/.test(str));
|
||||
}
|
||||
|
||||
// If the string is null or undefined then it is returned as is.
|
||||
function stringToAsciiOrUTF16BE(str) {
|
||||
if (str === null || str === undefined) {
|
||||
return str;
|
||||
}
|
||||
return isAscii(str) ? str : stringToUTF16String(str, /* bigEndian = */ true);
|
||||
}
|
||||
|
||||
function stringToUTF16HexString(str) {
|
||||
const buf = [];
|
||||
for (let i = 0, ii = str.length; i < ii; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
buf.push(Util.hexNums[(char >> 8) & 0xff], Util.hexNums[char & 0xff]);
|
||||
}
|
||||
return buf.join("");
|
||||
}
|
||||
|
||||
function stringToUTF16String(str, bigEndian = false) {
|
||||
const buf = [];
|
||||
if (bigEndian) {
|
||||
buf.push("\xFE\xFF");
|
||||
}
|
||||
for (let i = 0, ii = str.length; i < ii; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
buf.push(
|
||||
String.fromCharCode((char >> 8) & 0xff),
|
||||
String.fromCharCode(char & 0xff)
|
||||
);
|
||||
}
|
||||
return buf.join("");
|
||||
}
|
||||
|
||||
const PDFStringTranslateTable = [
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x2d8,
|
||||
0x2c7, 0x2c6, 0x2d9, 0x2dd, 0x2db, 0x2da, 0x2dc, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0x2022, 0x2020, 0x2021, 0x2026, 0x2014, 0x2013, 0x192,
|
||||
0x2044, 0x2039, 0x203a, 0x2212, 0x2030, 0x201e, 0x201c, 0x201d, 0x2018,
|
||||
0x2019, 0x201a, 0x2122, 0xfb01, 0xfb02, 0x141, 0x152, 0x160, 0x178, 0x17d,
|
||||
0x131, 0x142, 0x153, 0x161, 0x17e, 0, 0x20ac,
|
||||
];
|
||||
|
||||
function stringToPDFString(str, keepEscapeSequence = false) {
|
||||
// See section 7.9.2.2 Text String Type.
|
||||
// The string can contain some language codes bracketed with 0x1b,
|
||||
// so we must remove them.
|
||||
if (str[0] >= "\xEF") {
|
||||
let encoding;
|
||||
if (str[0] === "\xFE" && str[1] === "\xFF") {
|
||||
encoding = "utf-16be";
|
||||
if (str.length % 2 === 1) {
|
||||
str = str.slice(0, -1);
|
||||
}
|
||||
} else if (str[0] === "\xFF" && str[1] === "\xFE") {
|
||||
encoding = "utf-16le";
|
||||
if (str.length % 2 === 1) {
|
||||
str = str.slice(0, -1);
|
||||
}
|
||||
} else if (str[0] === "\xEF" && str[1] === "\xBB" && str[2] === "\xBF") {
|
||||
encoding = "utf-8";
|
||||
}
|
||||
|
||||
if (encoding) {
|
||||
try {
|
||||
const decoder = new TextDecoder(encoding, { fatal: true });
|
||||
const buffer = stringToBytes(str);
|
||||
const decoded = decoder.decode(buffer);
|
||||
if (keepEscapeSequence || !decoded.includes("\x1b")) {
|
||||
return decoded;
|
||||
}
|
||||
return decoded.replaceAll(/\x1b[^\x1b]*(?:\x1b|$)/g, "");
|
||||
} catch (ex) {
|
||||
warn(`stringToPDFString: "${ex}".`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ISO Latin 1
|
||||
const strBuf = [];
|
||||
for (let i = 0, ii = str.length; i < ii; i++) {
|
||||
const charCode = str.charCodeAt(i);
|
||||
if (!keepEscapeSequence && charCode === 0x1b) {
|
||||
// eslint-disable-next-line no-empty
|
||||
while (++i < ii && str.charCodeAt(i) !== 0x1b) {}
|
||||
continue;
|
||||
}
|
||||
const code = PDFStringTranslateTable[charCode];
|
||||
strBuf.push(code ? String.fromCharCode(code) : str.charAt(i));
|
||||
}
|
||||
return strBuf.join("");
|
||||
}
|
||||
|
||||
export {
|
||||
isAscii,
|
||||
stringToAsciiOrUTF16BE,
|
||||
stringToPDFString,
|
||||
stringToUTF16HexString,
|
||||
stringToUTF16String,
|
||||
};
|
||||
@ -16,13 +16,13 @@
|
||||
import {
|
||||
AnnotationPrefix,
|
||||
makeArr,
|
||||
stringToPDFString,
|
||||
stringToUTF8String,
|
||||
warn,
|
||||
} from "../shared/util.js";
|
||||
import { Dict, isName, Name, Ref, RefSetCache } from "./primitives.js";
|
||||
import { lookupNormalRect, stringToAsciiOrUTF16BE } from "./core_utils.js";
|
||||
import { stringToAsciiOrUTF16BE, stringToPDFString } from "./string_utils.js";
|
||||
import { BaseStream } from "./base_stream.js";
|
||||
import { lookupNormalRect } from "./core_utils.js";
|
||||
import { NumberTree } from "./name_number_tree.js";
|
||||
|
||||
const MAX_DEPTH = 40;
|
||||
|
||||
@ -21,7 +21,6 @@ import {
|
||||
isNodeJS,
|
||||
PasswordException,
|
||||
setVerbosityLevel,
|
||||
stringToPDFString,
|
||||
VerbosityLevel,
|
||||
warn,
|
||||
} from "../shared/util.js";
|
||||
@ -38,6 +37,7 @@ import { clearGlobalCaches } from "./cleanup_helper.js";
|
||||
import { incrementalUpdate } from "./writer.js";
|
||||
import { PDFEditor } from "./editor/pdf_editor.js";
|
||||
import { PDFWorkerStream } from "./worker_stream.js";
|
||||
import { stringToPDFString } from "./string_utils.js";
|
||||
import { StructTreeRoot } from "./struct_tree.js";
|
||||
|
||||
class WorkerTask {
|
||||
@ -1036,6 +1036,9 @@ class WorkerMessageHandler {
|
||||
.getPage(data.pageIndex)
|
||||
.then(page => page.annotations.map(a => a.toString()));
|
||||
});
|
||||
handler.on("GetWorkerCoverage", function () {
|
||||
return globalThis.__coverage__ ?? {};
|
||||
});
|
||||
}
|
||||
|
||||
return workerHandlerName;
|
||||
|
||||
@ -102,13 +102,12 @@ import {
|
||||
getStringOption,
|
||||
HTMLResult,
|
||||
} from "./utils.js";
|
||||
import { Util, warn } from "../../shared/util.js";
|
||||
import { SVG_NS, Util, warn } from "../../shared/util.js";
|
||||
import { getMetrics } from "./fonts.js";
|
||||
import { recoverJsURL } from "../core_utils.js";
|
||||
import { searchNode } from "./som.js";
|
||||
|
||||
const TEMPLATE_NS_ID = NamespaceIds.template.id;
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
// In case of lr-tb (and rl-tb) layouts, we try:
|
||||
// - to put the container at the end of a line
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
/** @typedef {import("./api").PDFPageProxy} PDFPageProxy */
|
||||
/** @typedef {import("./display_utils").PageViewport} PageViewport */
|
||||
/** @typedef {import("./page_viewport").PageViewport} PageViewport */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../../web/text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
|
||||
// eslint-disable-next-line max-len
|
||||
@ -38,6 +38,7 @@ import {
|
||||
LINE_FACTOR,
|
||||
makeArr,
|
||||
shadow,
|
||||
SVG_NS,
|
||||
unreachable,
|
||||
Util,
|
||||
warn,
|
||||
@ -666,8 +667,7 @@ class AnnotationElement {
|
||||
style.borderWidth = 0;
|
||||
svgBuffer = [
|
||||
"url('data:image/svg+xml;utf8,",
|
||||
`<svg xmlns="http://www.w3.org/2000/svg"`,
|
||||
` preserveAspectRatio="none" viewBox="0 0 1 1">`,
|
||||
`<svg xmlns="${SVG_NS}" preserveAspectRatio="none" viewBox="0 0 1 1">`,
|
||||
`<g fill="transparent" stroke="${borderColor}" stroke-width="${borderWidth}">`,
|
||||
];
|
||||
this.container.classList.add("hasBorder");
|
||||
|
||||
@ -57,7 +57,6 @@ import {
|
||||
import {
|
||||
isDataScheme,
|
||||
isValidFetchUrl,
|
||||
PageViewport,
|
||||
RenderingCancelledException,
|
||||
StatTimer,
|
||||
} from "./display_utils.js";
|
||||
@ -78,6 +77,7 @@ import { MathClamp } from "../shared/math_clamp.js";
|
||||
import { Metadata } from "./metadata.js";
|
||||
import { OptionalContentConfig } from "./optional_content_config.js";
|
||||
import { PagesMapper } from "./pages_mapper.js";
|
||||
import { PageViewport } from "./page_viewport.js";
|
||||
import { PDFDataTransportStream } from "./transport_stream.js";
|
||||
import { PDFObjects } from "./pdf_objects.js";
|
||||
import { TextLayer } from "./text_layer.js";
|
||||
|
||||
@ -1850,17 +1850,13 @@ class CanvasGraphics {
|
||||
return;
|
||||
}
|
||||
|
||||
// Whatever was drawn has been moved to the suspended canvas, now clear it
|
||||
// out of the current canvas. Only the dirty box region needs clearing;
|
||||
// everything outside it is already transparent.
|
||||
// Clear the full scratch canvas, not just the dirty box. Pixels left
|
||||
// outside dirtyBox can leak into a later compose() whose destination-in
|
||||
// pass doesn't overwrite them, producing stale output -- this is what
|
||||
// breaks `firefox-issue17779-partial` (issue #21276).
|
||||
this.ctx.save();
|
||||
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
this.ctx.clearRect(
|
||||
dirtyBox[0],
|
||||
dirtyBox[1],
|
||||
dirtyBox[2] - dirtyBox[0],
|
||||
dirtyBox[3] - dirtyBox[1]
|
||||
);
|
||||
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import { BBOX_INIT, FeatureTest, Util } from "../shared/util.js";
|
||||
import { getCurrentTransform } from "./display_utils.js";
|
||||
import { MathClamp } from "../shared/math_clamp.js";
|
||||
|
||||
const FORCED_DEPENDENCY_LABEL = "__forcedDependency";
|
||||
@ -27,6 +28,56 @@ function expandBBox(array, index, minX, minY, maxX, maxY) {
|
||||
array[index * 4 + 3] = Math.max(array[index * 4 + 3], maxY);
|
||||
}
|
||||
|
||||
// Apply a scaling matrix to some min/max values.
|
||||
// If a scaling factor is negative then min and max must be swapped.
|
||||
function scaleMinMax(transform, minMax) {
|
||||
let temp;
|
||||
if (transform[0]) {
|
||||
if (transform[0] < 0) {
|
||||
temp = minMax[0];
|
||||
minMax[0] = minMax[2];
|
||||
minMax[2] = temp;
|
||||
}
|
||||
minMax[0] *= transform[0];
|
||||
minMax[2] *= transform[0];
|
||||
|
||||
if (transform[3] < 0) {
|
||||
temp = minMax[1];
|
||||
minMax[1] = minMax[3];
|
||||
minMax[3] = temp;
|
||||
}
|
||||
minMax[1] *= transform[3];
|
||||
minMax[3] *= transform[3];
|
||||
} else {
|
||||
temp = minMax[0];
|
||||
minMax[0] = minMax[1];
|
||||
minMax[1] = temp;
|
||||
temp = minMax[2];
|
||||
minMax[2] = minMax[3];
|
||||
minMax[3] = temp;
|
||||
|
||||
if (transform[1] < 0) {
|
||||
temp = minMax[1];
|
||||
minMax[1] = minMax[3];
|
||||
minMax[3] = temp;
|
||||
}
|
||||
minMax[1] *= transform[1];
|
||||
minMax[3] *= transform[1];
|
||||
|
||||
if (transform[2] < 0) {
|
||||
temp = minMax[0];
|
||||
minMax[0] = minMax[2];
|
||||
minMax[2] = temp;
|
||||
}
|
||||
minMax[0] *= transform[2];
|
||||
minMax[2] *= transform[2];
|
||||
}
|
||||
minMax[0] += transform[4];
|
||||
minMax[1] += transform[5];
|
||||
minMax[2] += transform[4];
|
||||
minMax[3] += transform[5];
|
||||
}
|
||||
|
||||
// This is computed rathter than hard-coded to keep into
|
||||
// account the platform's endianess.
|
||||
const EMPTY_BBOX = new Uint32Array(new Uint8Array([255, 255, 0, 0]).buffer)[0];
|
||||
@ -612,7 +663,7 @@ class CanvasDependencyTracker {
|
||||
computedBBox = [0, 0, 0, 0];
|
||||
Util.axialAlignedBoundingBox(fontBBox, font.fontMatrix, computedBBox);
|
||||
if (scale !== 1 || x !== 0 || y !== 0) {
|
||||
Util.scaleMinMax([scale, 0, 0, -scale, x, y], computedBBox);
|
||||
scaleMinMax([scale, 0, 0, -scale, x, y], computedBBox);
|
||||
}
|
||||
|
||||
if (isBBoxTrustworthy) {
|
||||
@ -1121,7 +1172,7 @@ class CanvasImagesTracker {
|
||||
this.#coords = newCoords;
|
||||
}
|
||||
|
||||
const transform = Util.domMatrixToTransform(ctx.getTransform());
|
||||
const transform = getCurrentTransform(ctx);
|
||||
|
||||
// We want top left, bottom left, top right.
|
||||
// (0, 0) is the bottom left corner.
|
||||
|
||||
@ -22,10 +22,9 @@ import {
|
||||
warn,
|
||||
} from "../shared/util.js";
|
||||
import { MathClamp } from "../shared/math_clamp.js";
|
||||
import { PageViewport } from "./page_viewport.js";
|
||||
import { XfaLayer } from "./xfa_layer.js";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
class PixelsPerInch {
|
||||
static CSS = 96.0;
|
||||
|
||||
@ -84,220 +83,6 @@ async function fetchData(url, type = "text") {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} PageViewportParameters
|
||||
* @property {Array<number>} viewBox - The xMin, yMin, xMax and
|
||||
* yMax coordinates.
|
||||
* @property {number} userUnit - The size of units.
|
||||
* @property {number} scale - The scale of the viewport.
|
||||
* @property {number} rotation - The rotation, in degrees, of the viewport.
|
||||
* @property {number} [offsetX] - The horizontal, i.e. x-axis, offset. The
|
||||
* default value is `0`.
|
||||
* @property {number} [offsetY] - The vertical, i.e. y-axis, offset. The
|
||||
* default value is `0`.
|
||||
* @property {boolean} [dontFlip] - If true, the y-axis will not be flipped.
|
||||
* The default value is `false`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PageViewportCloneParameters
|
||||
* @property {number} [scale] - The scale, overriding the one in the cloned
|
||||
* viewport. The default value is `this.scale`.
|
||||
* @property {number} [rotation] - The rotation, in degrees, overriding the one
|
||||
* in the cloned viewport. The default value is `this.rotation`.
|
||||
* @property {number} [offsetX] - The horizontal, i.e. x-axis, offset.
|
||||
* The default value is `this.offsetX`.
|
||||
* @property {number} [offsetY] - The vertical, i.e. y-axis, offset.
|
||||
* The default value is `this.offsetY`.
|
||||
* @property {boolean} [dontFlip] - If true, the x-axis will not be flipped.
|
||||
* The default value is `false`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* PDF page viewport created based on scale, rotation and offset.
|
||||
*/
|
||||
class PageViewport {
|
||||
/**
|
||||
* @param {PageViewportParameters}
|
||||
*/
|
||||
constructor({
|
||||
viewBox,
|
||||
userUnit,
|
||||
scale,
|
||||
rotation,
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
dontFlip = false,
|
||||
}) {
|
||||
this.viewBox = viewBox;
|
||||
this.userUnit = userUnit;
|
||||
this.scale = scale;
|
||||
this.rotation = rotation;
|
||||
this.offsetX = offsetX;
|
||||
this.offsetY = offsetY;
|
||||
|
||||
scale *= userUnit; // Take the userUnit into account.
|
||||
|
||||
// creating transform to convert pdf coordinate system to the normal
|
||||
// canvas like coordinates taking in account scale and rotation
|
||||
const centerX = (viewBox[2] + viewBox[0]) / 2;
|
||||
const centerY = (viewBox[3] + viewBox[1]) / 2;
|
||||
let rotateA, rotateB, rotateC, rotateD;
|
||||
// Normalize the rotation, by clamping it to the [0, 360) range.
|
||||
rotation %= 360;
|
||||
if (rotation < 0) {
|
||||
rotation += 360;
|
||||
}
|
||||
switch (rotation) {
|
||||
case 180:
|
||||
rotateA = -1;
|
||||
rotateB = 0;
|
||||
rotateC = 0;
|
||||
rotateD = 1;
|
||||
break;
|
||||
case 90:
|
||||
rotateA = 0;
|
||||
rotateB = 1;
|
||||
rotateC = 1;
|
||||
rotateD = 0;
|
||||
break;
|
||||
case 270:
|
||||
rotateA = 0;
|
||||
rotateB = -1;
|
||||
rotateC = -1;
|
||||
rotateD = 0;
|
||||
break;
|
||||
case 0:
|
||||
rotateA = 1;
|
||||
rotateB = 0;
|
||||
rotateC = 0;
|
||||
rotateD = -1;
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
"PageViewport: Invalid rotation, must be a multiple of 90 degrees."
|
||||
);
|
||||
}
|
||||
|
||||
if (dontFlip) {
|
||||
rotateC = -rotateC;
|
||||
rotateD = -rotateD;
|
||||
}
|
||||
|
||||
let offsetCanvasX, offsetCanvasY;
|
||||
let width, height;
|
||||
if (rotateA === 0) {
|
||||
offsetCanvasX = Math.abs(centerY - viewBox[1]) * scale + offsetX;
|
||||
offsetCanvasY = Math.abs(centerX - viewBox[0]) * scale + offsetY;
|
||||
width = (viewBox[3] - viewBox[1]) * scale;
|
||||
height = (viewBox[2] - viewBox[0]) * scale;
|
||||
} else {
|
||||
offsetCanvasX = Math.abs(centerX - viewBox[0]) * scale + offsetX;
|
||||
offsetCanvasY = Math.abs(centerY - viewBox[1]) * scale + offsetY;
|
||||
width = (viewBox[2] - viewBox[0]) * scale;
|
||||
height = (viewBox[3] - viewBox[1]) * scale;
|
||||
}
|
||||
// creating transform for the following operations:
|
||||
// translate(-centerX, -centerY), rotate and flip vertically,
|
||||
// scale, and translate(offsetCanvasX, offsetCanvasY)
|
||||
this.transform = [
|
||||
rotateA * scale,
|
||||
rotateB * scale,
|
||||
rotateC * scale,
|
||||
rotateD * scale,
|
||||
offsetCanvasX - rotateA * scale * centerX - rotateC * scale * centerY,
|
||||
offsetCanvasY - rotateB * scale * centerX - rotateD * scale * centerY,
|
||||
];
|
||||
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
/**
|
||||
* The original, un-scaled, viewport dimensions.
|
||||
* @type {Object}
|
||||
*/
|
||||
get rawDims() {
|
||||
const dims = this.viewBox;
|
||||
|
||||
return shadow(this, "rawDims", {
|
||||
pageWidth: dims[2] - dims[0],
|
||||
pageHeight: dims[3] - dims[1],
|
||||
pageX: dims[0],
|
||||
pageY: dims[1],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones viewport, with optional additional properties.
|
||||
* @param {PageViewportCloneParameters} [params]
|
||||
* @returns {PageViewport} Cloned viewport.
|
||||
*/
|
||||
clone({
|
||||
scale = this.scale,
|
||||
rotation = this.rotation,
|
||||
offsetX = this.offsetX,
|
||||
offsetY = this.offsetY,
|
||||
dontFlip = false,
|
||||
} = {}) {
|
||||
return new PageViewport({
|
||||
viewBox: this.viewBox.slice(),
|
||||
userUnit: this.userUnit,
|
||||
scale,
|
||||
rotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
dontFlip,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts PDF point to the viewport coordinates. For examples, useful for
|
||||
* converting PDF location into canvas pixel coordinates.
|
||||
* @param {number} x - The x-coordinate.
|
||||
* @param {number} y - The y-coordinate.
|
||||
* @returns {Array} Array containing `x`- and `y`-coordinates of the
|
||||
* point in the viewport coordinate space.
|
||||
* @see {@link convertToPdfPoint}
|
||||
* @see {@link convertToViewportRectangle}
|
||||
*/
|
||||
convertToViewportPoint(x, y) {
|
||||
const p = [x, y];
|
||||
Util.applyTransform(p, this.transform);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts PDF rectangle to the viewport coordinates.
|
||||
* @param {Array} rect - The xMin, yMin, xMax and yMax coordinates.
|
||||
* @returns {Array} Array containing corresponding coordinates of the
|
||||
* rectangle in the viewport coordinate space.
|
||||
* @see {@link convertToViewportPoint}
|
||||
*/
|
||||
convertToViewportRectangle(rect) {
|
||||
const topLeft = [rect[0], rect[1]];
|
||||
Util.applyTransform(topLeft, this.transform);
|
||||
const bottomRight = [rect[2], rect[3]];
|
||||
Util.applyTransform(bottomRight, this.transform);
|
||||
return [topLeft[0], topLeft[1], bottomRight[0], bottomRight[1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts viewport coordinates to the PDF location. For examples, useful
|
||||
* for converting canvas pixel location into PDF one.
|
||||
* @param {number} x - The x-coordinate.
|
||||
* @param {number} y - The y-coordinate.
|
||||
* @returns {Array} Array containing `x`- and `y`-coordinates of the
|
||||
* point in the PDF coordinate space.
|
||||
* @see {@link convertToViewportPoint}
|
||||
*/
|
||||
convertToPdfPoint(x, y) {
|
||||
const p = [x, y];
|
||||
Util.applyInverseTransform(p, this.transform);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
class RenderingCancelledException extends BaseException {
|
||||
constructor(msg, extraDelay = 0) {
|
||||
super(msg, "RenderingCancelledException");
|
||||
@ -561,21 +346,6 @@ class PDFDateString {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: This is (mostly) intended to support printing of XFA forms.
|
||||
*/
|
||||
function getXfaPageViewport(xfaPage, { scale = 1, rotation = 0 }) {
|
||||
const { width, height } = xfaPage.attributes.style;
|
||||
const viewBox = [0, 0, parseInt(width, 10), parseInt(height, 10)];
|
||||
|
||||
return new PageViewport({
|
||||
viewBox,
|
||||
userUnit: 1,
|
||||
scale,
|
||||
rotation,
|
||||
});
|
||||
}
|
||||
|
||||
function getRGBA(color) {
|
||||
if (color.startsWith("#")) {
|
||||
// #RRGGBB or #RRGGBBAA
|
||||
@ -1058,14 +828,12 @@ export {
|
||||
getPdfFilenameFromUrl,
|
||||
getRGB,
|
||||
getRGBA,
|
||||
getXfaPageViewport,
|
||||
isDataScheme,
|
||||
isPdfFile,
|
||||
isValidFetchUrl,
|
||||
makePathFromDrawOPS,
|
||||
noContextMenu,
|
||||
OutputScale,
|
||||
PageViewport,
|
||||
PDFDateString,
|
||||
PixelsPerInch,
|
||||
RenderingCancelledException,
|
||||
@ -1074,5 +842,4 @@ export {
|
||||
StatTimer,
|
||||
stopEvent,
|
||||
SupportedImageMimeTypes,
|
||||
SVG_NS,
|
||||
};
|
||||
|
||||
@ -16,6 +16,174 @@
|
||||
import { DOMSVGFactory } from "./svg_factory.js";
|
||||
import { shadow } from "../shared/util.js";
|
||||
|
||||
/**
|
||||
* @typedef DrawLayerOptions
|
||||
* Configuration for {@linkcode DrawLayer}.
|
||||
* @property {Object | null} [filterFactory]
|
||||
* Filter factory used to style selections (optional).
|
||||
* @property {Object | null} [pageColors]
|
||||
* Page foreground/background colors for HCM (optional).
|
||||
* @property {number} pageIndex
|
||||
* Zero-based page index.
|
||||
* @property {Element | null} [textLayer]
|
||||
* Text layer element (optional).
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef EdgeBoundaryResult
|
||||
* Result of {@linkcode normalizeEdgeBoundary}.
|
||||
* @property {Node} container
|
||||
* Normalized container.
|
||||
* @property {number} offset
|
||||
* Normalized offset.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef SelectionRotatorResult
|
||||
* Result of {@linkcode SelectionRotator}.
|
||||
* @property {number} x
|
||||
* Rotated X coordinate.
|
||||
* @property {number} y
|
||||
* Rotated Y coordinate.
|
||||
* @property {number} width
|
||||
* Rotated width.
|
||||
* @property {number} height
|
||||
* Rotated height.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback SelectionRotator
|
||||
* Rotate the coordinates of a rectangle according to the position of the
|
||||
* text layer in the viewport.
|
||||
* @param {number} x
|
||||
* X coordinate.
|
||||
* @param {number} y
|
||||
* Y coordinate.
|
||||
* @param {number} width
|
||||
* Width.
|
||||
* @param {number} height
|
||||
* Height.
|
||||
* @returns {SelectionRotatorResult}
|
||||
* Rotated coordinates.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef TextLayerSelectionData
|
||||
* Data related to the selection for a text layer.
|
||||
* @property {DrawLayer} drawLayer
|
||||
* Draw layer associated with the text layer.
|
||||
* @property {SVGPathElement | null} [path]
|
||||
* Node (SVG path element) used to draw the selection.
|
||||
* @property {HTMLDivElement | null} [selectionDiv]
|
||||
* Node (div element) used to display the selection.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compare the document position of two text layers.
|
||||
*
|
||||
* @param {Element} a
|
||||
* Text layer.
|
||||
* @param {Element} b
|
||||
* Other text layer.
|
||||
* @returns {-1 | 0 | 1}
|
||||
* `-1` if the `a` is before `b`, `1` if after, or `0`.
|
||||
*/
|
||||
function compareTextLayers(a, b) {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
? -1
|
||||
: 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest text layer upwards.
|
||||
*
|
||||
* @param {Node | null} node
|
||||
* Node.
|
||||
* @returns {Element | null}
|
||||
* Closest ancestral text layer or `null`.
|
||||
*/
|
||||
function getTextLayer(node) {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
return node.closest(".textLayer");
|
||||
}
|
||||
return node.parentElement?.closest(".textLayer") || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the position of two points in the document order.
|
||||
*
|
||||
* @param {Node} nodeA
|
||||
* Node.
|
||||
* @param {number} offsetA
|
||||
* Offset.
|
||||
* @param {Node} nodeB
|
||||
* Other node.
|
||||
* @param {number} offsetB
|
||||
* Other offset.
|
||||
* @returns {boolean | null}
|
||||
* Whether the first point is before the second one, or `null` if they are
|
||||
* not comparable.
|
||||
*/
|
||||
function isPointBefore(nodeA, offsetA, nodeB, offsetB) {
|
||||
if (nodeA === nodeB) {
|
||||
return offsetA <= offsetB;
|
||||
}
|
||||
const relation = nodeA.compareDocumentPosition(nodeB);
|
||||
if (relation & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
return true;
|
||||
}
|
||||
if (relation & Node.DOCUMENT_POSITION_PRECEDING) {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the position of a boundary point when it's at the end of a text
|
||||
* layer.
|
||||
* In that case, we want to move it to the last valid position within
|
||||
* the text layer, which can be either the end of the last text node or the end
|
||||
* of the last text node before the endOfContent element if it exists.
|
||||
*
|
||||
* @param {Node} container
|
||||
* Container.
|
||||
* @param {number} offset
|
||||
* Offset.
|
||||
* @param {Element} textLayer
|
||||
* Text layer.
|
||||
* @returns {EdgeBoundaryResult | null}
|
||||
* Normalized position or `null` if the position is not valid.
|
||||
*/
|
||||
function normalizeEdgeBoundary(container, offset, textLayer) {
|
||||
if (
|
||||
container.nodeType !== Node.ELEMENT_NODE ||
|
||||
!container.classList.contains("textLayer") ||
|
||||
offset !== container.childNodes.length
|
||||
) {
|
||||
return { container, offset };
|
||||
}
|
||||
let lastNode = container.lastChild;
|
||||
if (
|
||||
lastNode?.nodeType === Node.ELEMENT_NODE &&
|
||||
lastNode.classList.contains("endOfContent")
|
||||
) {
|
||||
lastNode = lastNode.previousSibling;
|
||||
}
|
||||
if (!lastNode || !textLayer.contains(lastNode)) {
|
||||
return null;
|
||||
}
|
||||
if (lastNode.nodeType === Node.TEXT_NODE) {
|
||||
return { container: lastNode, offset: lastNode.textContent.length };
|
||||
}
|
||||
return { container: lastNode, offset: lastNode.childNodes.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage the SVGs drawn on top of the page canvas.
|
||||
* It's important to have them directly on top of the canvas because we want to
|
||||
@ -26,13 +194,131 @@ class DrawLayer {
|
||||
|
||||
#mapping = new Map();
|
||||
|
||||
/** @type {Element | null} */
|
||||
#textLayer = null;
|
||||
|
||||
/** @type {Object | null} */
|
||||
#filterFactory = null;
|
||||
|
||||
/** @type {Object | null} */
|
||||
#pageColors = null;
|
||||
|
||||
/** @type {MutationObserver | null} */
|
||||
#textLayerObserver = null;
|
||||
|
||||
#toUpdate = new Map();
|
||||
|
||||
static #id = 0;
|
||||
|
||||
static #selectionId = 0;
|
||||
|
||||
/** @type {AbortController | null} */
|
||||
static #selectionChangeAC = null;
|
||||
|
||||
/** @type {Set<HTMLDivElement>} */
|
||||
static #selections = new Set();
|
||||
|
||||
/** @type {boolean} */
|
||||
static #isSelecting = false;
|
||||
|
||||
/** @type {Set<Element>} */
|
||||
static #textLayerSet = new Set();
|
||||
|
||||
/** @type {WeakMap<Element, TextLayerSelectionData>} */
|
||||
static #textLayers = new WeakMap();
|
||||
|
||||
/**
|
||||
* @param {DrawLayerOptions} options
|
||||
* Configuration.
|
||||
* @returns
|
||||
* Instance.
|
||||
*/
|
||||
constructor({
|
||||
filterFactory = null,
|
||||
pageColors = null,
|
||||
pageIndex,
|
||||
textLayer = null,
|
||||
}) {
|
||||
this.pageIndex = pageIndex;
|
||||
this.#filterFactory = filterFactory;
|
||||
this.#pageColors = pageColors;
|
||||
if (textLayer) {
|
||||
const previousData = DrawLayer.#textLayers.get(textLayer);
|
||||
if (previousData?.selectionDiv) {
|
||||
previousData.selectionDiv.remove();
|
||||
DrawLayer.#selections.delete(previousData.selectionDiv);
|
||||
}
|
||||
DrawLayer.#textLayers.set(textLayer, { drawLayer: this });
|
||||
DrawLayer.#textLayerSet.add(textLayer);
|
||||
this.#textLayer = textLayer;
|
||||
this.#textLayerObserver = new MutationObserver(records => {
|
||||
if (
|
||||
!this.#parent ||
|
||||
!this.#textLayer?.isConnected ||
|
||||
!DrawLayer.#hasSelection()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const { addedNodes } of records) {
|
||||
for (const node of addedNodes) {
|
||||
if (
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
node.classList.contains("endOfContent")
|
||||
) {
|
||||
DrawLayer.#selectionChange();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
this.#textLayerObserver.observe(textLayer, { childList: true });
|
||||
|
||||
if (DrawLayer.#selectionChangeAC === null) {
|
||||
DrawLayer.#selectionChangeAC = new AbortController();
|
||||
const { signal } = DrawLayer.#selectionChangeAC;
|
||||
document.addEventListener(
|
||||
"selectionchange",
|
||||
DrawLayer.#selectionChange.bind(DrawLayer),
|
||||
{ signal }
|
||||
);
|
||||
// Track pointer selection state to preserve selections during
|
||||
// cross-boundary drags.
|
||||
document.addEventListener(
|
||||
"pointerdown",
|
||||
() => {
|
||||
DrawLayer.#isSelecting = true;
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
document.addEventListener(
|
||||
"pointerup",
|
||||
() => {
|
||||
DrawLayer.#isSelecting = false;
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
// If the pointer is released outside the window, we may not get a
|
||||
// corresponding `pointerup` event.
|
||||
window.addEventListener(
|
||||
"blur",
|
||||
() => {
|
||||
DrawLayer.#isSelecting = false;
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setParent(parent) {
|
||||
if (!this.#parent) {
|
||||
this.#parent = parent;
|
||||
// A new text layer just became live (e.g. its page was scrolled into
|
||||
// view). If a selection already exists, redraw overlays so that the
|
||||
// selection extends into this newly-rendered text layer.
|
||||
if (this.#textLayer?.isConnected && DrawLayer.#hasSelection()) {
|
||||
DrawLayer.#selectionChange();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@ -47,6 +333,321 @@ class DrawLayer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the selection for a text layer.
|
||||
*
|
||||
* @param {Element} textLayer
|
||||
* Text layer.
|
||||
* @returns {undefined}
|
||||
* Nothing.
|
||||
*/
|
||||
static #cleanupTextLayerSelection(textLayer) {
|
||||
const textLayerData = this.#textLayers.get(textLayer);
|
||||
if (!textLayerData?.selectionDiv) {
|
||||
return;
|
||||
}
|
||||
textLayerData.selectionDiv.remove();
|
||||
this.#selections.delete(textLayerData.selectionDiv);
|
||||
textLayerData.selectionDiv = null;
|
||||
textLayerData.path = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
* Whether there is a non-collapsed document selection.
|
||||
*/
|
||||
static #hasSelection() {
|
||||
const selection = document.getSelection();
|
||||
return !!selection && !selection.isCollapsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array<Element>}
|
||||
* Connected text layers sorted in document order.
|
||||
*/
|
||||
static #getOrderedTextLayers() {
|
||||
return [...this.#textLayerSet]
|
||||
.filter(textLayer => textLayer.isConnected)
|
||||
.sort(compareTextLayers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `selectionchange` to update the selection display for text layers.
|
||||
* We want to display the selection in a separate layer on top of the text
|
||||
* layer because the text layer has `mix-blend-mode: multiply` and we want
|
||||
* the selection to have a different blend mode.
|
||||
*
|
||||
* @returns {undefined}
|
||||
* Nothing.
|
||||
*/
|
||||
static #selectionChange() {
|
||||
const selection = document.getSelection();
|
||||
if (!selection || selection.isCollapsed) {
|
||||
for (const root of this.#selections) {
|
||||
root.remove();
|
||||
}
|
||||
this.#selections.clear();
|
||||
return;
|
||||
}
|
||||
/** @type {WeakMap<Node, SelectionRotator>} */
|
||||
const rotators = new WeakMap();
|
||||
const orderedTextLayers = this.#getOrderedTextLayers();
|
||||
/** @type {Array<[Range, Element]>} */
|
||||
const ranges = [];
|
||||
for (let i = 0, ii = selection.rangeCount; i < ii; i++) {
|
||||
const range = selection.getRangeAt(i);
|
||||
if (range.collapsed) {
|
||||
continue;
|
||||
}
|
||||
let { startContainer, startOffset, endContainer, endOffset } = range;
|
||||
let startTextLayer = getTextLayer(startContainer);
|
||||
let endTextLayer = getTextLayer(endContainer);
|
||||
const startMissing = startTextLayer === null;
|
||||
const endMissing = endTextLayer === null;
|
||||
|
||||
// XOR case: exactly one boundary is outside tracked text layers.
|
||||
// In Firefox/Safari this can happen transiently while dragging outside
|
||||
// the page. Preserve the current overlay and exit early.
|
||||
if (this.#isSelecting && startMissing !== endMissing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection.rangeCount === 1) {
|
||||
const { anchorNode, anchorOffset, focusNode, focusOffset } = selection;
|
||||
const anchorLayer = getTextLayer(anchorNode);
|
||||
const focusLayer = getTextLayer(focusNode);
|
||||
const anchorBeforeFocus = isPointBefore(
|
||||
anchorNode,
|
||||
anchorOffset,
|
||||
focusNode,
|
||||
focusOffset
|
||||
);
|
||||
if (anchorLayer && focusLayer && anchorBeforeFocus !== null) {
|
||||
if (anchorBeforeFocus) {
|
||||
startContainer = anchorNode;
|
||||
startOffset = anchorOffset;
|
||||
startTextLayer = anchorLayer;
|
||||
endContainer = focusNode;
|
||||
endOffset = focusOffset;
|
||||
endTextLayer = focusLayer;
|
||||
} else {
|
||||
startContainer = focusNode;
|
||||
startOffset = focusOffset;
|
||||
startTextLayer = focusLayer;
|
||||
endContainer = anchorNode;
|
||||
endOffset = anchorOffset;
|
||||
endTextLayer = anchorLayer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeTextLayers = orderedTextLayers.filter(textLayer =>
|
||||
range.intersectsNode(textLayer)
|
||||
);
|
||||
if (activeTextLayers.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If a boundary is outside any text layer, use the selected live text
|
||||
// layers as the range edges. This handles Select All, whose DOM range can
|
||||
// span ancestors of the text layers.
|
||||
let boundarySubstituted = false;
|
||||
if (!startTextLayer) {
|
||||
startTextLayer = activeTextLayers[0];
|
||||
startContainer = startTextLayer;
|
||||
startOffset = 0;
|
||||
boundarySubstituted = true;
|
||||
}
|
||||
if (!endTextLayer) {
|
||||
endTextLayer = activeTextLayers.at(-1);
|
||||
endContainer = endTextLayer;
|
||||
endOffset = endTextLayer.childNodes.length;
|
||||
boundarySubstituted = true;
|
||||
}
|
||||
|
||||
if (endContainer.nodeType === Node.ELEMENT_NODE) {
|
||||
if (endContainer.classList.contains("endOfContent")) {
|
||||
const previousNode = endContainer.previousSibling;
|
||||
if (!previousNode) {
|
||||
continue;
|
||||
}
|
||||
endContainer = previousNode;
|
||||
endOffset =
|
||||
previousNode.nodeType === Node.TEXT_NODE
|
||||
? previousNode.textContent.length
|
||||
: previousNode.childNodes.length;
|
||||
} else if (
|
||||
endContainer.classList.contains("textLayer") &&
|
||||
endContainer.childNodes.length === endOffset
|
||||
) {
|
||||
const normalizedEnd = normalizeEdgeBoundary(
|
||||
endContainer,
|
||||
endOffset,
|
||||
endTextLayer
|
||||
);
|
||||
if (!normalizedEnd) {
|
||||
continue;
|
||||
}
|
||||
endContainer = normalizedEnd.container;
|
||||
endOffset = normalizedEnd.offset;
|
||||
}
|
||||
}
|
||||
|
||||
if (startContainer.nodeType === Node.ELEMENT_NODE) {
|
||||
const normalizedStart = normalizeEdgeBoundary(
|
||||
startContainer,
|
||||
startOffset,
|
||||
startTextLayer
|
||||
);
|
||||
if (!normalizedStart) {
|
||||
continue;
|
||||
}
|
||||
startContainer = normalizedStart.container;
|
||||
startOffset = normalizedStart.offset;
|
||||
}
|
||||
|
||||
if (
|
||||
startTextLayer === endTextLayer &&
|
||||
!boundarySubstituted &&
|
||||
activeTextLayers.includes(startTextLayer)
|
||||
) {
|
||||
ranges.push([range, startTextLayer]);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const textLayer of activeTextLayers) {
|
||||
const firstNode = textLayer.firstChild;
|
||||
if (!firstNode) {
|
||||
continue;
|
||||
}
|
||||
const subRange = document.createRange();
|
||||
if (textLayer === startTextLayer) {
|
||||
subRange.setStart(startContainer, startOffset);
|
||||
} else {
|
||||
subRange.setStartBefore(firstNode);
|
||||
}
|
||||
|
||||
if (textLayer === endTextLayer) {
|
||||
subRange.setEnd(endContainer, endOffset);
|
||||
} else {
|
||||
const lastNode = textLayer.lastChild;
|
||||
if (!lastNode) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
lastNode.nodeType === Node.ELEMENT_NODE &&
|
||||
lastNode.classList.contains("endOfContent")
|
||||
) {
|
||||
const lastTextNode = lastNode.previousSibling;
|
||||
if (!lastTextNode) {
|
||||
continue;
|
||||
}
|
||||
subRange.setEndAfter(lastTextNode);
|
||||
} else {
|
||||
subRange.setEndAfter(lastNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (!subRange.collapsed) {
|
||||
ranges.push([subRange, textLayer]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Set<Element>} */
|
||||
const selectedTextLayers = new Set(ranges.map(range => range[1]));
|
||||
|
||||
for (const textLayer of this.#textLayerSet) {
|
||||
if (!selectedTextLayers.has(textLayer)) {
|
||||
this.#cleanupTextLayerSelection(textLayer);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [range, textLayer] of ranges) {
|
||||
const textLayerData = DrawLayer.#textLayers.get(textLayer);
|
||||
if (!textLayerData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rotator = rotators.get(textLayer);
|
||||
if (!rotator) {
|
||||
const clientRect = textLayer.getBoundingClientRect();
|
||||
|
||||
rotator = (x, y, w, h) => ({
|
||||
x: (x - clientRect.x) / clientRect.width,
|
||||
y: (y - clientRect.y) / clientRect.height,
|
||||
width: w / clientRect.width,
|
||||
height: h / clientRect.height,
|
||||
});
|
||||
rotators.set(textLayer, rotator);
|
||||
}
|
||||
/** @type {Array<string>} */
|
||||
const boxes = [];
|
||||
for (let { x, y, width, height } of range.getClientRects()) {
|
||||
if (width === 0 || height === 0) {
|
||||
continue;
|
||||
}
|
||||
({ x, y, width, height } = rotator(x, y, width, height));
|
||||
if (width === 1 && height === 1) {
|
||||
// The entire page is selected.
|
||||
continue;
|
||||
}
|
||||
boxes.push(`M${x} ${y} h${width} v${height} h-${width} Z`);
|
||||
}
|
||||
if (boxes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const drawLayer = textLayerData.drawLayer;
|
||||
let div = textLayerData.selectionDiv;
|
||||
let path = textLayerData.path;
|
||||
if (!div) {
|
||||
const clipPathId = `clip_selection_${DrawLayer.#selectionId++}`;
|
||||
|
||||
div = document.createElement("div");
|
||||
div.className = "selection";
|
||||
div.style.clipPath = `url(#${clipPathId})`;
|
||||
|
||||
const selectionStyle = drawLayer.#filterFactory?.createSelectionStyle(
|
||||
drawLayer.#pageColors
|
||||
);
|
||||
if (selectionStyle) {
|
||||
for (const [name, value] of Object.entries(selectionStyle)) {
|
||||
div.style.setProperty(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
const svg = DrawLayer._svgFactory.create(
|
||||
1,
|
||||
1,
|
||||
/* skipDimensions = */ true
|
||||
);
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.setAttribute("width", "100%");
|
||||
svg.setAttribute("height", "100%");
|
||||
|
||||
const clipPath = DrawLayer._svgFactory.createElement("clipPath");
|
||||
clipPath.setAttribute("id", clipPathId);
|
||||
clipPath.setAttribute("clipPathUnits", "objectBoundingBox");
|
||||
|
||||
path = DrawLayer._svgFactory.createElement("path");
|
||||
|
||||
clipPath.append(path);
|
||||
svg.append(clipPath);
|
||||
div.append(svg);
|
||||
|
||||
textLayerData.path = path;
|
||||
textLayerData.selectionDiv = div;
|
||||
}
|
||||
|
||||
if (!div.parentNode && drawLayer.#parent) {
|
||||
drawLayer.#parent.append(div);
|
||||
this.#selections.add(div);
|
||||
}
|
||||
|
||||
path.setAttribute("d", boxes.join(" "));
|
||||
}
|
||||
}
|
||||
|
||||
static get _svgFactory() {
|
||||
return shadow(this, "_svgFactory", new DOMSVGFactory());
|
||||
}
|
||||
@ -62,7 +663,7 @@ class DrawLayer {
|
||||
#createSVG() {
|
||||
const svg = DrawLayer._svgFactory.create(1, 1, /* skipDimensions = */ true);
|
||||
this.#parent.append(svg);
|
||||
svg.setAttribute("aria-hidden", true);
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
|
||||
return svg;
|
||||
}
|
||||
@ -239,6 +840,22 @@ class DrawLayer {
|
||||
}
|
||||
this.#mapping.clear();
|
||||
this.#toUpdate.clear();
|
||||
this.#textLayerObserver?.disconnect();
|
||||
this.#textLayerObserver = null;
|
||||
if (this.#textLayer) {
|
||||
const data = DrawLayer.#textLayers.get(this.#textLayer);
|
||||
if (data?.drawLayer === this) {
|
||||
DrawLayer.#cleanupTextLayerSelection(this.#textLayer);
|
||||
DrawLayer.#textLayers.delete(this.#textLayer);
|
||||
DrawLayer.#textLayerSet.delete(this.#textLayer);
|
||||
if (DrawLayer.#textLayerSet.size === 0) {
|
||||
DrawLayer.#selectionChangeAC?.abort();
|
||||
DrawLayer.#selectionChangeAC = null;
|
||||
DrawLayer.#isSelecting = false;
|
||||
}
|
||||
}
|
||||
this.#textLayer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("./tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */
|
||||
/** @typedef {import("../display_utils.js").PageViewport} PageViewport */
|
||||
/** @typedef {import("../page_viewport.js").PageViewport} PageViewport */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../../../web/text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
|
||||
// eslint-disable-next-line max-len
|
||||
|
||||
@ -85,19 +85,6 @@ class Outline {
|
||||
}
|
||||
}
|
||||
|
||||
static _normalizePagePoint(x, y, rotation) {
|
||||
switch (rotation) {
|
||||
case 90:
|
||||
return [1 - y, x];
|
||||
case 180:
|
||||
return [1 - x, 1 - y];
|
||||
case 270:
|
||||
return [y, 1 - x];
|
||||
default:
|
||||
return [x, y];
|
||||
}
|
||||
}
|
||||
|
||||
static createBezierPoints(x1, y1, x2, y2, x3, y3) {
|
||||
return [
|
||||
(x1 + 5 * x2) / 6,
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
FeatureTest,
|
||||
getUuid,
|
||||
shadow,
|
||||
SVG_NS,
|
||||
Util,
|
||||
warn,
|
||||
} from "../../shared/util.js";
|
||||
@ -170,7 +171,7 @@ class ImageManager {
|
||||
// The "workaround" is to append "svgView(preserveAspectRatio(none))" to the
|
||||
// url, but according to comment #15, it seems that it leads to unexpected
|
||||
// behavior in Safari.
|
||||
const svg = `data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 1 1" width="1" height="1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" style="fill:red;"/></svg>`;
|
||||
const svg = `data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 1 1" width="1" height="1" xmlns="${SVG_NS}"><rect width="1" height="1" style="fill:red;"/></svg>`;
|
||||
const canvas = new OffscreenCanvas(1, 3);
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
const image = new Image();
|
||||
|
||||
@ -13,8 +13,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getRGB, isDataScheme, SVG_NS } from "./display_utils.js";
|
||||
import { unreachable, updateUrlHash, Util, warn } from "../shared/util.js";
|
||||
import {
|
||||
FeatureTest,
|
||||
SVG_NS,
|
||||
unreachable,
|
||||
updateUrlHash,
|
||||
Util,
|
||||
warn,
|
||||
} from "../shared/util.js";
|
||||
import { getRGB, getRGBA, isDataScheme } from "./display_utils.js";
|
||||
|
||||
class BaseFilterFactory {
|
||||
constructor() {
|
||||
@ -50,6 +57,36 @@ class BaseFilterFactory {
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filter for the selection of text, given colors.
|
||||
*
|
||||
* @param {string} fgColor
|
||||
* @param {string} bgColor
|
||||
* @returns {string}
|
||||
*/
|
||||
addSelectionHCMFilter(fgColor, bgColor) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filter for the selection of text.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
addSelectionFilter() {
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} [pageColors]
|
||||
* @param {string} [pageColors.background]
|
||||
* @param {string} [pageColors.foreground]
|
||||
* @returns {Record<string, string> | null}
|
||||
*/
|
||||
createSelectionStyle(pageColors = null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
destroy(keepHCM = false) {}
|
||||
}
|
||||
|
||||
@ -269,6 +306,66 @@ class DOMFilterFactory extends BaseFilterFactory {
|
||||
return info.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filter for the selection of text, given colors.
|
||||
*
|
||||
* @param {string} fgColor
|
||||
* @param {string} bgColor
|
||||
* @returns {string}
|
||||
*/
|
||||
addSelectionHCMFilter(fgColor, bgColor) {
|
||||
return this.addHighlightHCMFilter(
|
||||
"selection",
|
||||
fgColor,
|
||||
bgColor,
|
||||
// Background becomes foreground so these are flipped.
|
||||
"HighlightText",
|
||||
"Highlight"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filter for the selection of text.
|
||||
*
|
||||
* @param {string} fgColor
|
||||
* @param {string} bgColor
|
||||
* @returns {string}
|
||||
*/
|
||||
addSelectionFilter() {
|
||||
return this.addHighlightHCMFilter(
|
||||
"selection_default",
|
||||
"black",
|
||||
"white",
|
||||
"HighlightText",
|
||||
"Highlight"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} [pageColors]
|
||||
* @param {string} [pageColors.background]
|
||||
* @param {string} [pageColors.foreground]
|
||||
* @returns {Record<string, string> | null}
|
||||
*/
|
||||
createSelectionStyle(pageColors = null) {
|
||||
const filter = pageColors
|
||||
? this.addSelectionHCMFilter(pageColors.foreground, pageColors.background)
|
||||
: this.addSelectionFilter();
|
||||
|
||||
// Safari does not supported SVG filters in `backdrop-filter`:
|
||||
// <https://bugs.webkit.org/show_bug.cgi?id=245510>.
|
||||
// Chrome *and* Safari do not use the user’s preferred text selection color.
|
||||
// So this is Firefox-specific for now.
|
||||
if (filter === "none" || !FeatureTest.platform.isFirefox) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
"backdrop-filter": filter,
|
||||
"background-color": "transparent",
|
||||
};
|
||||
}
|
||||
|
||||
addAlphaFilter(map) {
|
||||
// When a page is zoomed the page is re-drawn but the maps are likely
|
||||
// the same.
|
||||
@ -397,7 +494,7 @@ class DOMFilterFactory extends BaseFilterFactory {
|
||||
0.2126 * bgRGB[0] + 0.7152 * bgRGB[1] + 0.0722 * bgRGB[2]
|
||||
);
|
||||
let [newFgRGB, newBgRGB] = [newFgColor, newBgColor].map(
|
||||
this.#getRGB.bind(this)
|
||||
this.#getOpaqueTextColor.bind(this)
|
||||
);
|
||||
if (bgGray < fgGray) {
|
||||
[fgGray, bgGray, newFgRGB, newBgRGB] = [
|
||||
@ -539,6 +636,62 @@ class DOMFilterFactory extends BaseFilterFactory {
|
||||
this.#defs.style.color = color;
|
||||
return getRGB(getComputedStyle(this.#defs).getPropertyValue("color"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the RGBA channels of a color.
|
||||
*
|
||||
* @param {string} color
|
||||
* Color in any valid CSS format (such as `x` in `color: x`).
|
||||
* @returns {[number, number, number, number]}
|
||||
* RGBA values of the color;
|
||||
* the RGB channels are in the range `[0, 255]`;
|
||||
* the alpha channel is in the range `[0, 1]`.
|
||||
*/
|
||||
#getRGBA(color) {
|
||||
this.#defs.style.color = color;
|
||||
return getRGBA(getComputedStyle(this.#defs).getPropertyValue("color"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the opaque text color by, if it has an alpha layer, blending it with
|
||||
* the `Canvas` background.
|
||||
*
|
||||
* @param {string} color
|
||||
* Color in any valid CSS format (such as `x` in `color: x`).
|
||||
* @returns {[number, number, number]}
|
||||
* RGB values of the opaque color.
|
||||
*/
|
||||
#getOpaqueTextColor(color) {
|
||||
const [r, g, b, alpha] = this.#getRGBA(color);
|
||||
|
||||
if (alpha === 1) {
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
const [canvasR, canvasG, canvasB] = this.#getRGB("Canvas");
|
||||
|
||||
return [
|
||||
blend(r, canvasR, alpha),
|
||||
blend(g, canvasG, alpha),
|
||||
blend(b, canvasB, alpha),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blend a foreground color with a background color using the alpha value.
|
||||
*
|
||||
* @param {number} fg
|
||||
* Foreground color channel value in the range `[0, 255]`.
|
||||
* @param {number} bg
|
||||
* Background color channel value in the range `[0, 255]`.
|
||||
* @param {number} alpha
|
||||
* Alpha value in the range `[0, 1]`.
|
||||
* @returns {number}
|
||||
* Blended color channel value in the range `[0, 255]`.
|
||||
*/
|
||||
function blend(fg, bg, alpha) {
|
||||
return Math.round(alpha * fg + (1 - alpha) * bg);
|
||||
}
|
||||
|
||||
export { BaseFilterFactory, DOMFilterFactory };
|
||||
|
||||
232
src/display/page_viewport.js
Normal file
232
src/display/page_viewport.js
Normal file
@ -0,0 +1,232 @@
|
||||
/* Copyright 2015 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 { shadow, Util } from "../shared/util.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} PageViewportParameters
|
||||
* @property {Array<number>} viewBox - The xMin, yMin, xMax and
|
||||
* yMax coordinates.
|
||||
* @property {number} userUnit - The size of units.
|
||||
* @property {number} scale - The scale of the viewport.
|
||||
* @property {number} rotation - The rotation, in degrees, of the viewport.
|
||||
* @property {number} [offsetX] - The horizontal, i.e. x-axis, offset. The
|
||||
* default value is `0`.
|
||||
* @property {number} [offsetY] - The vertical, i.e. y-axis, offset. The
|
||||
* default value is `0`.
|
||||
* @property {boolean} [dontFlip] - If true, the y-axis will not be flipped.
|
||||
* The default value is `false`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PageViewportCloneParameters
|
||||
* @property {number} [scale] - The scale, overriding the one in the cloned
|
||||
* viewport. The default value is `this.scale`.
|
||||
* @property {number} [rotation] - The rotation, in degrees, overriding the one
|
||||
* in the cloned viewport. The default value is `this.rotation`.
|
||||
* @property {number} [offsetX] - The horizontal, i.e. x-axis, offset.
|
||||
* The default value is `this.offsetX`.
|
||||
* @property {number} [offsetY] - The vertical, i.e. y-axis, offset.
|
||||
* The default value is `this.offsetY`.
|
||||
* @property {boolean} [dontFlip] - If true, the x-axis will not be flipped.
|
||||
* The default value is `false`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* PDF page viewport created based on scale, rotation and offset.
|
||||
*/
|
||||
class PageViewport {
|
||||
/**
|
||||
* @param {PageViewportParameters}
|
||||
*/
|
||||
constructor({
|
||||
viewBox,
|
||||
userUnit,
|
||||
scale,
|
||||
rotation,
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
dontFlip = false,
|
||||
}) {
|
||||
this.viewBox = viewBox;
|
||||
this.userUnit = userUnit;
|
||||
this.scale = scale;
|
||||
this.rotation = rotation;
|
||||
this.offsetX = offsetX;
|
||||
this.offsetY = offsetY;
|
||||
|
||||
scale *= userUnit; // Take the userUnit into account.
|
||||
|
||||
// creating transform to convert pdf coordinate system to the normal
|
||||
// canvas like coordinates taking in account scale and rotation
|
||||
const centerX = (viewBox[2] + viewBox[0]) / 2;
|
||||
const centerY = (viewBox[3] + viewBox[1]) / 2;
|
||||
let rotateA, rotateB, rotateC, rotateD;
|
||||
// Normalize the rotation, by clamping it to the [0, 360) range.
|
||||
rotation %= 360;
|
||||
if (rotation < 0) {
|
||||
rotation += 360;
|
||||
}
|
||||
switch (rotation) {
|
||||
case 180:
|
||||
rotateA = -1;
|
||||
rotateB = 0;
|
||||
rotateC = 0;
|
||||
rotateD = 1;
|
||||
break;
|
||||
case 90:
|
||||
rotateA = 0;
|
||||
rotateB = 1;
|
||||
rotateC = 1;
|
||||
rotateD = 0;
|
||||
break;
|
||||
case 270:
|
||||
rotateA = 0;
|
||||
rotateB = -1;
|
||||
rotateC = -1;
|
||||
rotateD = 0;
|
||||
break;
|
||||
case 0:
|
||||
rotateA = 1;
|
||||
rotateB = 0;
|
||||
rotateC = 0;
|
||||
rotateD = -1;
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
"PageViewport: Invalid rotation, must be a multiple of 90 degrees."
|
||||
);
|
||||
}
|
||||
|
||||
if (dontFlip) {
|
||||
rotateC = -rotateC;
|
||||
rotateD = -rotateD;
|
||||
}
|
||||
|
||||
let offsetCanvasX, offsetCanvasY;
|
||||
let width, height;
|
||||
if (rotateA === 0) {
|
||||
offsetCanvasX = Math.abs(centerY - viewBox[1]) * scale + offsetX;
|
||||
offsetCanvasY = Math.abs(centerX - viewBox[0]) * scale + offsetY;
|
||||
width = (viewBox[3] - viewBox[1]) * scale;
|
||||
height = (viewBox[2] - viewBox[0]) * scale;
|
||||
} else {
|
||||
offsetCanvasX = Math.abs(centerX - viewBox[0]) * scale + offsetX;
|
||||
offsetCanvasY = Math.abs(centerY - viewBox[1]) * scale + offsetY;
|
||||
width = (viewBox[2] - viewBox[0]) * scale;
|
||||
height = (viewBox[3] - viewBox[1]) * scale;
|
||||
}
|
||||
// creating transform for the following operations:
|
||||
// translate(-centerX, -centerY), rotate and flip vertically,
|
||||
// scale, and translate(offsetCanvasX, offsetCanvasY)
|
||||
this.transform = [
|
||||
rotateA * scale,
|
||||
rotateB * scale,
|
||||
rotateC * scale,
|
||||
rotateD * scale,
|
||||
offsetCanvasX - rotateA * scale * centerX - rotateC * scale * centerY,
|
||||
offsetCanvasY - rotateB * scale * centerX - rotateD * scale * centerY,
|
||||
];
|
||||
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
/**
|
||||
* The original, un-scaled, viewport dimensions.
|
||||
* @type {Object}
|
||||
*/
|
||||
get rawDims() {
|
||||
const dims = this.viewBox;
|
||||
|
||||
return shadow(this, "rawDims", {
|
||||
pageWidth: dims[2] - dims[0],
|
||||
pageHeight: dims[3] - dims[1],
|
||||
pageX: dims[0],
|
||||
pageY: dims[1],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones viewport, with optional additional properties.
|
||||
* @param {PageViewportCloneParameters} [params]
|
||||
* @returns {PageViewport} Cloned viewport.
|
||||
*/
|
||||
clone({
|
||||
scale = this.scale,
|
||||
rotation = this.rotation,
|
||||
offsetX = this.offsetX,
|
||||
offsetY = this.offsetY,
|
||||
dontFlip = false,
|
||||
} = {}) {
|
||||
return new PageViewport({
|
||||
viewBox: this.viewBox.slice(),
|
||||
userUnit: this.userUnit,
|
||||
scale,
|
||||
rotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
dontFlip,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts PDF point to the viewport coordinates. For examples, useful for
|
||||
* converting PDF location into canvas pixel coordinates.
|
||||
* @param {number} x - The x-coordinate.
|
||||
* @param {number} y - The y-coordinate.
|
||||
* @returns {Array} Array containing `x`- and `y`-coordinates of the
|
||||
* point in the viewport coordinate space.
|
||||
* @see {@link convertToPdfPoint}
|
||||
* @see {@link convertToViewportRectangle}
|
||||
*/
|
||||
convertToViewportPoint(x, y) {
|
||||
const p = [x, y];
|
||||
Util.applyTransform(p, this.transform);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts PDF rectangle to the viewport coordinates.
|
||||
* @param {Array} rect - The xMin, yMin, xMax and yMax coordinates.
|
||||
* @returns {Array} Array containing corresponding coordinates of the
|
||||
* rectangle in the viewport coordinate space.
|
||||
* @see {@link convertToViewportPoint}
|
||||
*/
|
||||
convertToViewportRectangle(rect) {
|
||||
const topLeft = [rect[0], rect[1]];
|
||||
Util.applyTransform(topLeft, this.transform);
|
||||
const bottomRight = [rect[2], rect[3]];
|
||||
Util.applyTransform(bottomRight, this.transform);
|
||||
return [topLeft[0], topLeft[1], bottomRight[0], bottomRight[1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts viewport coordinates to the PDF location. For examples, useful
|
||||
* for converting canvas pixel location into PDF one.
|
||||
* @param {number} x - The x-coordinate.
|
||||
* @param {number} y - The y-coordinate.
|
||||
* @returns {Array} Array containing `x`- and `y`-coordinates of the
|
||||
* point in the PDF coordinate space.
|
||||
* @see {@link convertToViewportPoint}
|
||||
*/
|
||||
convertToPdfPoint(x, y) {
|
||||
const p = [x, y];
|
||||
Util.applyInverseTransform(p, this.transform);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
export { PageViewport };
|
||||
@ -13,8 +13,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { SVG_NS } from "./display_utils.js";
|
||||
import { unreachable } from "../shared/util.js";
|
||||
import { SVG_NS, unreachable } from "../shared/util.js";
|
||||
|
||||
class BaseSVGFactory {
|
||||
constructor() {
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @typedef {import("./display_utils").PageViewport} PageViewport */
|
||||
/** @typedef {import("./page_viewport").PageViewport} PageViewport */
|
||||
/** @typedef {import("./api").TextContent} TextContent */
|
||||
/** @typedef {import("./text_layer_images").TextLayerImages} TextLayerImages */
|
||||
|
||||
@ -472,7 +472,8 @@ class TextLayer {
|
||||
// their replacements when they aren't embedded) and then we can use an
|
||||
// OffscreenCanvas.
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "hiddenCanvasElement";
|
||||
canvas.style.cssText =
|
||||
"position:absolute;top:0;left:0;width:0;height:0;display:none";
|
||||
canvas.lang = lang;
|
||||
document.body.append(canvas);
|
||||
ctx = canvas.getContext("2d", {
|
||||
|
||||
@ -15,10 +15,10 @@
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("./annotation_storage").AnnotationStorage} AnnotationStorage */
|
||||
/** @typedef {import("./display_utils").PageViewport} PageViewport */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../../web/pdf_link_service.js").PDFLinkService} PDFLinkService */
|
||||
|
||||
import { PageViewport } from "./page_viewport.js";
|
||||
import { XfaText } from "./xfa_text.js";
|
||||
|
||||
/**
|
||||
@ -293,6 +293,20 @@ class XfaLayer {
|
||||
parameters.div.style.transform = transform;
|
||||
parameters.div.hidden = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: This is (mostly) intended to support printing of XFA forms.
|
||||
*/
|
||||
static getPageViewport(xfaPage, { scale = 1, rotation = 0 }) {
|
||||
const { width, height } = xfaPage.attributes.style;
|
||||
|
||||
return new PageViewport({
|
||||
viewBox: [0, 0, parseInt(width, 10), parseInt(height, 10)],
|
||||
userUnit: 1,
|
||||
scale,
|
||||
rotation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { XfaLayer };
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
/** @typedef {import("./display/api").PDFDocumentProxy} PDFDocumentProxy */
|
||||
/** @typedef {import("./display/api").PDFPageProxy} PDFPageProxy */
|
||||
/** @typedef {import("./display/api").RenderTask} RenderTask */
|
||||
/** @typedef {import("./display/display_utils").PageViewport} PageViewport */
|
||||
/** @typedef {import("./display/page_viewport").PageViewport} PageViewport */
|
||||
|
||||
import {
|
||||
AbortException,
|
||||
@ -55,7 +55,6 @@ import {
|
||||
getPdfFilenameFromUrl,
|
||||
getRGB,
|
||||
getRGBA,
|
||||
getXfaPageViewport,
|
||||
isDataScheme,
|
||||
isPdfFile,
|
||||
noContextMenu,
|
||||
@ -122,7 +121,6 @@ globalThis.pdfjsLib = {
|
||||
getRGB,
|
||||
getRGBA,
|
||||
getUuid,
|
||||
getXfaPageViewport,
|
||||
GlobalWorkerOptions,
|
||||
ImageKind,
|
||||
InvalidPDFException,
|
||||
@ -186,7 +184,6 @@ export {
|
||||
getRGB,
|
||||
getRGBA,
|
||||
getUuid,
|
||||
getXfaPageViewport,
|
||||
GlobalWorkerOptions,
|
||||
ImageKind,
|
||||
InvalidPDFException,
|
||||
|
||||
@ -36,6 +36,8 @@ const LINE_FACTOR = 1.35;
|
||||
const LINE_DESCENT_FACTOR = 0.35;
|
||||
const BASELINE_FACTOR = LINE_DESCENT_FACTOR / LINE_FACTOR;
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/**
|
||||
* Refer to the `WorkerTransport.getRenderingIntent`-method in the API, to see
|
||||
* how these flags are being used:
|
||||
@ -661,7 +663,10 @@ class FeatureTest {
|
||||
let ctx;
|
||||
if (this.isOffscreenCanvasSupported) {
|
||||
ctx = new OffscreenCanvas(1, 1).getContext("2d");
|
||||
} else if (typeof document !== "undefined") {
|
||||
} else if (
|
||||
(typeof PDFJSDev === "undefined" || !PDFJSDev.test("WORKER_THREAD")) &&
|
||||
typeof document !== "undefined"
|
||||
) {
|
||||
ctx = document.createElement("canvas").getContext("2d");
|
||||
}
|
||||
// Spec-compliant Canvas2D defaults `ctx.filter` to "none". On
|
||||
@ -673,21 +678,22 @@ class FeatureTest {
|
||||
}
|
||||
|
||||
static get isAlphaColorInputSupported() {
|
||||
if (
|
||||
(typeof PDFJSDev !== "undefined" && PDFJSDev.test("WORKER_THREAD")) ||
|
||||
typeof document === "undefined"
|
||||
) {
|
||||
return shadow(this, "isAlphaColorInputSupported", false);
|
||||
}
|
||||
const input = document.createElement("input");
|
||||
input.type = "color";
|
||||
input.setAttribute("alpha", "");
|
||||
input.value = "#ff000080";
|
||||
// If alpha is supported the color picker retains the alpha channel, so
|
||||
// the value won't be a plain opaque color (7-char #rrggbb).
|
||||
return shadow(
|
||||
this,
|
||||
"isAlphaColorInputSupported",
|
||||
(() => {
|
||||
if (typeof document === "undefined") {
|
||||
return false;
|
||||
}
|
||||
const input = document.createElement("input");
|
||||
input.type = "color";
|
||||
input.setAttribute("alpha", "");
|
||||
input.value = "#ff000080";
|
||||
// If alpha is supported the color picker retains the alpha channel, so
|
||||
// the value won't be a plain opaque color (7-char #rrggbb).
|
||||
return input.value !== "#ff0000";
|
||||
})()
|
||||
input.value !== "#ff0000"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -705,61 +711,6 @@ class Util {
|
||||
return `#${this.hexNums[r]}${this.hexNums[g]}${this.hexNums[b]}`;
|
||||
}
|
||||
|
||||
static domMatrixToTransform(dm) {
|
||||
return [dm.a, dm.b, dm.c, dm.d, dm.e, dm.f];
|
||||
}
|
||||
|
||||
// Apply a scaling matrix to some min/max values.
|
||||
// If a scaling factor is negative then min and max must be
|
||||
// swapped.
|
||||
static scaleMinMax(transform, minMax) {
|
||||
let temp;
|
||||
if (transform[0]) {
|
||||
if (transform[0] < 0) {
|
||||
temp = minMax[0];
|
||||
minMax[0] = minMax[2];
|
||||
minMax[2] = temp;
|
||||
}
|
||||
minMax[0] *= transform[0];
|
||||
minMax[2] *= transform[0];
|
||||
|
||||
if (transform[3] < 0) {
|
||||
temp = minMax[1];
|
||||
minMax[1] = minMax[3];
|
||||
minMax[3] = temp;
|
||||
}
|
||||
minMax[1] *= transform[3];
|
||||
minMax[3] *= transform[3];
|
||||
} else {
|
||||
temp = minMax[0];
|
||||
minMax[0] = minMax[1];
|
||||
minMax[1] = temp;
|
||||
temp = minMax[2];
|
||||
minMax[2] = minMax[3];
|
||||
minMax[3] = temp;
|
||||
|
||||
if (transform[1] < 0) {
|
||||
temp = minMax[1];
|
||||
minMax[1] = minMax[3];
|
||||
minMax[3] = temp;
|
||||
}
|
||||
minMax[1] *= transform[1];
|
||||
minMax[3] *= transform[1];
|
||||
|
||||
if (transform[2] < 0) {
|
||||
temp = minMax[0];
|
||||
minMax[0] = minMax[2];
|
||||
minMax[2] = temp;
|
||||
}
|
||||
minMax[0] *= transform[2];
|
||||
minMax[2] *= transform[2];
|
||||
}
|
||||
minMax[0] += transform[4];
|
||||
minMax[1] += transform[5];
|
||||
minMax[2] += transform[4];
|
||||
minMax[3] += transform[5];
|
||||
}
|
||||
|
||||
// Concatenates two transformation matrices together and returns the result.
|
||||
static transform(m1, m2) {
|
||||
return [
|
||||
@ -1057,67 +1008,6 @@ class Util {
|
||||
}
|
||||
}
|
||||
|
||||
const PDFStringTranslateTable = [
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x2d8,
|
||||
0x2c7, 0x2c6, 0x2d9, 0x2dd, 0x2db, 0x2da, 0x2dc, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0x2022, 0x2020, 0x2021, 0x2026, 0x2014, 0x2013, 0x192,
|
||||
0x2044, 0x2039, 0x203a, 0x2212, 0x2030, 0x201e, 0x201c, 0x201d, 0x2018,
|
||||
0x2019, 0x201a, 0x2122, 0xfb01, 0xfb02, 0x141, 0x152, 0x160, 0x178, 0x17d,
|
||||
0x131, 0x142, 0x153, 0x161, 0x17e, 0, 0x20ac,
|
||||
];
|
||||
|
||||
function stringToPDFString(str, keepEscapeSequence = false) {
|
||||
// See section 7.9.2.2 Text String Type.
|
||||
// The string can contain some language codes bracketed with 0x1b,
|
||||
// so we must remove them.
|
||||
if (str[0] >= "\xEF") {
|
||||
let encoding;
|
||||
if (str[0] === "\xFE" && str[1] === "\xFF") {
|
||||
encoding = "utf-16be";
|
||||
if (str.length % 2 === 1) {
|
||||
str = str.slice(0, -1);
|
||||
}
|
||||
} else if (str[0] === "\xFF" && str[1] === "\xFE") {
|
||||
encoding = "utf-16le";
|
||||
if (str.length % 2 === 1) {
|
||||
str = str.slice(0, -1);
|
||||
}
|
||||
} else if (str[0] === "\xEF" && str[1] === "\xBB" && str[2] === "\xBF") {
|
||||
encoding = "utf-8";
|
||||
}
|
||||
|
||||
if (encoding) {
|
||||
try {
|
||||
const decoder = new TextDecoder(encoding, { fatal: true });
|
||||
const buffer = stringToBytes(str);
|
||||
const decoded = decoder.decode(buffer);
|
||||
if (keepEscapeSequence || !decoded.includes("\x1b")) {
|
||||
return decoded;
|
||||
}
|
||||
return decoded.replaceAll(/\x1b[^\x1b]*(?:\x1b|$)/g, "");
|
||||
} catch (ex) {
|
||||
warn(`stringToPDFString: "${ex}".`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ISO Latin 1
|
||||
const strBuf = [];
|
||||
for (let i = 0, ii = str.length; i < ii; i++) {
|
||||
const charCode = str.charCodeAt(i);
|
||||
if (!keepEscapeSequence && charCode === 0x1b) {
|
||||
// eslint-disable-next-line no-empty
|
||||
while (++i < ii && str.charCodeAt(i) !== 0x1b) {}
|
||||
continue;
|
||||
}
|
||||
const code = PDFStringTranslateTable[charCode];
|
||||
strBuf.push(code ? String.fromCharCode(code) : str.charAt(i));
|
||||
}
|
||||
return strBuf.join("");
|
||||
}
|
||||
|
||||
function stringToUTF8String(str) {
|
||||
return decodeURIComponent(escape(str));
|
||||
}
|
||||
@ -1296,9 +1186,9 @@ export {
|
||||
setVerbosityLevel,
|
||||
shadow,
|
||||
stringToBytes,
|
||||
stringToPDFString,
|
||||
stringToUTF8String,
|
||||
stripPath,
|
||||
SVG_NS,
|
||||
TextRenderingMode,
|
||||
UnknownErrorException,
|
||||
unreachable,
|
||||
|
||||
@ -15,15 +15,15 @@
|
||||
|
||||
// Istanbul coverage objects use s (statements), b (branches), and f (functions)
|
||||
// as shorthand keys for the hit-count maps.
|
||||
function mergeWorkerCoverageIntoWindow(coverage) {
|
||||
function mergeCoverageIntoGlobal(coverage) {
|
||||
if (!coverage || Object.keys(coverage).length === 0) {
|
||||
return;
|
||||
}
|
||||
window.__coverage__ ??= {};
|
||||
globalThis.__coverage__ ??= {};
|
||||
for (const [key, fileCoverage] of Object.entries(coverage)) {
|
||||
const existing = window.__coverage__[key];
|
||||
const existing = globalThis.__coverage__[key];
|
||||
if (!existing) {
|
||||
window.__coverage__[key] = fileCoverage;
|
||||
globalThis.__coverage__[key] = fileCoverage;
|
||||
continue;
|
||||
}
|
||||
for (const id of Object.keys(fileCoverage.s)) {
|
||||
@ -49,10 +49,10 @@ async function fetchAndMergeWorkerCoverage(pdfWorker) {
|
||||
"GetWorkerCoverage",
|
||||
null
|
||||
);
|
||||
mergeWorkerCoverageIntoWindow(coverage);
|
||||
mergeCoverageIntoGlobal(coverage);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to collect worker coverage: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export { fetchAndMergeWorkerCoverage, mergeWorkerCoverageIntoWindow };
|
||||
export { fetchAndMergeWorkerCoverage, mergeCoverageIntoGlobal };
|
||||
|
||||
141
test/font/font_glyf_spec.js
Normal file
141
test/font/font_glyf_spec.js
Normal file
@ -0,0 +1,141 @@
|
||||
/* 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 { ttx, verifyTtxOutput } from "./fontutils.js";
|
||||
import { Font } from "../../src/core/fonts.js";
|
||||
import { Stream } from "../../src/core/stream.js";
|
||||
import { ToUnicodeMap } from "../../src/core/to_unicode_map.js";
|
||||
|
||||
// Minimal TrueType font: 4 glyphs (.notdef, space, A, B), OS/2 v1 / 86 bytes,
|
||||
// no hinting tables.
|
||||
const baseFont = Uint8Array.fromBase64(
|
||||
"AAEAAAAKAIAAAwAgT1MvMkTeRDYAAAEoAAAAVmNtYXAAdQBcAAABjAAAADxnbHlmmNLJuAAAAdQAAABKaGVhZC3Q8mwAAACsAAAANmhoZWEFFgH2AAAA5AAAACRobXR4AlgAAAAAAYAAAAAKbG9jYQAyACYAAAHIAAAACm1heHAABgAGAAABCAAAACBuYW1lAJlcyAAAAiAAAAA8cG9zdAAuACQAAAJcAAAAKgABAAAAAQAAfM/c718PPPUAAQPoAAAAAOYyVzYAAAAA5jJXNgAAAAACWAMgAAAAAwACAAAAAAAAAAEAAAMg/zgAAAJYAAAAZAH0AAEAAAAAAAAAAAAAAAAAAAABAAEAAAAEAAQAAQAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAQJYAZAABQAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAPz8/PwAAACAAQgMg/zgAAAMgAMgAAAAAAAAAAAAAAlgAAAAAAAAAAAAAAAAAAgAAAAMAAAAUAAMAAQAAABQABAAoAAAABgAEAAEAAgAgAEL//wAAACAAQf///+H/wQABAAAAAAAAAAAADQANABkAJQAAAAEAZAAAAlgDIAADAAAzIREhZAH0/gwDIAAAAQAAAAAB9AK8AAMAADEhESEB9P4MArwAAQAAAAAB9AK8AAMAADEhESEB9P4MArwAAAAAAAQANgABAAAAAAABAAEAAAABAAAAAAACAAEAAQADAAEECQABAAIAAgADAAEECQACAAIABFRSAFQAUgACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAADACQAJQAA"
|
||||
);
|
||||
|
||||
function clone(buf) {
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
function readUint16(buf, pos) {
|
||||
return (buf[pos] << 8) | buf[pos + 1];
|
||||
}
|
||||
|
||||
function readUint32(buf, pos) {
|
||||
return (
|
||||
buf[pos] * 0x1000000 +
|
||||
((buf[pos + 1] << 16) | (buf[pos + 2] << 8) | buf[pos + 3])
|
||||
);
|
||||
}
|
||||
|
||||
function getTables(buf) {
|
||||
const tables = Object.create(null);
|
||||
const numTables = readUint16(buf, 4);
|
||||
for (let i = 0; i < numTables; i++) {
|
||||
const off = 12 + i * 16;
|
||||
const tag = String.fromCharCode(
|
||||
buf[off],
|
||||
buf[off + 1],
|
||||
buf[off + 2],
|
||||
buf[off + 3]
|
||||
);
|
||||
tables[tag] = {
|
||||
offset: readUint32(buf, off + 8),
|
||||
length: readUint32(buf, off + 12),
|
||||
};
|
||||
}
|
||||
return tables;
|
||||
}
|
||||
|
||||
function makeProperties(toUnicode) {
|
||||
return {
|
||||
loadedName: "font",
|
||||
type: "TrueType",
|
||||
differences: [],
|
||||
defaultEncoding: [],
|
||||
toUnicode,
|
||||
xHeight: 0,
|
||||
capHeight: 0,
|
||||
italicAngle: 0,
|
||||
firstChar: 0,
|
||||
lastChar: 255,
|
||||
};
|
||||
}
|
||||
|
||||
describe("font_glyf", function () {
|
||||
describe("Cyclic composite glyph 0", function () {
|
||||
it("removes a self-referencing composite glyph 0 (issue 21298)", async function () {
|
||||
const buggy = clone(baseFont);
|
||||
const tables = getTables(buggy);
|
||||
const headOff = tables.head.offset;
|
||||
const indexToLocFormat = readUint16(buggy, headOff + 50);
|
||||
const locaOff = tables.loca.offset;
|
||||
const glyf0 =
|
||||
indexToLocFormat === 0
|
||||
? readUint16(buggy, locaOff) * 2
|
||||
: readUint32(buggy, locaOff);
|
||||
const glyf0End =
|
||||
indexToLocFormat === 0
|
||||
? readUint16(buggy, locaOff + 2) * 2
|
||||
: readUint32(buggy, locaOff + 4);
|
||||
const pos = tables.glyf.offset + glyf0;
|
||||
buggy.fill(0, pos, tables.glyf.offset + glyf0End);
|
||||
buggy[pos] = 0xff;
|
||||
buggy[pos + 1] = 0xff;
|
||||
buggy[pos + 11] = 0x02;
|
||||
|
||||
const font = new Font(
|
||||
"font",
|
||||
new Stream(buggy),
|
||||
makeProperties(new ToUnicodeMap([])),
|
||||
{}
|
||||
);
|
||||
const output = await ttx(font.data);
|
||||
verifyTtxOutput(output);
|
||||
const notdef =
|
||||
/<TTGlyph[^>]*name="\.notdef"[^>]*\/>|<TTGlyph[^>]*name="\.notdef"[^>]*>([\s\S]*?)<\/TTGlyph>/.exec(
|
||||
output
|
||||
);
|
||||
expect(notdef).not.toBeNull();
|
||||
expect(notdef[1] || "").not.toMatch(
|
||||
/<component\b[^>]*glyphName="\.notdef"/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OS/2 table length validation", function () {
|
||||
it("rewrites the OS/2 table when its length doesn't match the declared version", async function () {
|
||||
const buggy = clone(baseFont);
|
||||
const tables = getTables(buggy);
|
||||
const os2 = tables["OS/2"].offset;
|
||||
buggy[os2 + 62] = 0x00;
|
||||
buggy[os2 + 63] = 0x40;
|
||||
buggy[os2 + 1] = 0x03;
|
||||
|
||||
const font = new Font(
|
||||
"font",
|
||||
new Stream(buggy),
|
||||
makeProperties(new ToUnicodeMap([])),
|
||||
{}
|
||||
);
|
||||
const output = await ttx(font.data);
|
||||
verifyTtxOutput(output);
|
||||
expect(
|
||||
/<OS_2>\s*(<!--[\s\S]*?-->\s*)?<version value="3"\/>/.test(output)
|
||||
).toEqual(true);
|
||||
expect(/<sCapHeight\b/.test(output)).toEqual(true);
|
||||
expect(/<usMaxContext\b/.test(output)).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -46,6 +46,7 @@ async function initializePDFJS(callback) {
|
||||
await Promise.all(
|
||||
[
|
||||
"pdfjs-test/font/font_core_spec.js",
|
||||
"pdfjs-test/font/font_glyf_spec.js",
|
||||
"pdfjs-test/font/font_os2_spec.js",
|
||||
"pdfjs-test/font/font_post_spec.js",
|
||||
"pdfjs-test/font/font_fpgm_spec.js",
|
||||
|
||||
0
test/images/samplesignature.png
Executable file → Normal file
0
test/images/samplesignature.png
Executable file → Normal file
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import { closePages, FSI, loadAndWait, PDI } from "./test_utils.mjs";
|
||||
import fs from "fs/promises";
|
||||
|
||||
const FIELDS = [
|
||||
"fileName",
|
||||
@ -32,21 +33,50 @@ const FIELDS = [
|
||||
"linearized",
|
||||
];
|
||||
|
||||
describe("PDFDocumentProperties", () => {
|
||||
async function getFieldProperties(page) {
|
||||
const promises = [];
|
||||
async function openDocumentProperties(page) {
|
||||
await page.click("#secondaryToolbarToggleButton");
|
||||
await page.waitForSelector("#secondaryToolbar", { hidden: false });
|
||||
|
||||
for (const name of FIELDS) {
|
||||
promises.push(
|
||||
page.evaluate(
|
||||
n => [n, document.getElementById(`${n}Field`).textContent],
|
||||
name
|
||||
)
|
||||
);
|
||||
}
|
||||
return Object.fromEntries(await Promise.all(promises));
|
||||
await page.click("#documentProperties");
|
||||
await page.waitForSelector("#documentPropertiesDialog", {
|
||||
hidden: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function closeDocumentProperties(page) {
|
||||
await page.click("#documentPropertiesClose");
|
||||
await page.waitForSelector("#documentPropertiesDialog", {
|
||||
hidden: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function checkFieldProperties(page, expectedProps) {
|
||||
await page.waitForFunction(
|
||||
`document.getElementById("fileSizeField").textContent !== "-"`
|
||||
);
|
||||
const promises = [];
|
||||
|
||||
for (const name of FIELDS) {
|
||||
promises.push(
|
||||
page.evaluate(
|
||||
n => [n, document.getElementById(`${n}Field`).textContent],
|
||||
name
|
||||
)
|
||||
);
|
||||
}
|
||||
const props = Object.fromEntries(await Promise.all(promises));
|
||||
expect(props).toEqual(expectedProps);
|
||||
}
|
||||
|
||||
function getFieldDataLastUpdated(page) {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
document.getElementById("documentPropertiesDialog").dataset
|
||||
.fieldDataLastUpdated
|
||||
);
|
||||
}
|
||||
|
||||
describe("PDFDocumentProperties", () => {
|
||||
describe("Document with both /Info and /Metadata", () => {
|
||||
let pages;
|
||||
|
||||
@ -58,23 +88,12 @@ describe("PDFDocumentProperties", () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("must check that the document properties dialog has the correct information", async () => {
|
||||
it("check that the document properties dialog has the correct information", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
await page.click("#secondaryToolbarToggleButton");
|
||||
await page.waitForSelector("#secondaryToolbar", { hidden: false });
|
||||
await openDocumentProperties(page);
|
||||
|
||||
await page.click("#documentProperties");
|
||||
await page.waitForSelector("#documentPropertiesDialog", {
|
||||
hidden: false,
|
||||
});
|
||||
|
||||
await page.waitForFunction(
|
||||
`document.getElementById("fileSizeField").textContent !== "-"`
|
||||
);
|
||||
const props = await getFieldProperties(page);
|
||||
|
||||
expect(props).toEqual({
|
||||
await checkFieldProperties(page, {
|
||||
fileName: "basicapi.pdf",
|
||||
fileSize: `${FSI}103${PDI} KB (${FSI}105,779${PDI} bytes)`,
|
||||
title: "Basic API Test",
|
||||
@ -90,6 +109,319 @@ describe("PDFDocumentProperties", () => {
|
||||
pageSize: `${FSI}8.27${PDI} × ${FSI}11.69${PDI} ${FSI}in${PDI} (${FSI}A4${PDI}, ${FSI}portrait${PDI})`,
|
||||
linearized: "No",
|
||||
});
|
||||
|
||||
await closeDocumentProperties(page);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Document with approximately A4-sized page", () => {
|
||||
let pages;
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait(
|
||||
"arial_unicode_en_cidfont.pdf",
|
||||
".textLayer .endOfContent"
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("check that the document properties dialog has the correct information", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
await openDocumentProperties(page);
|
||||
|
||||
await checkFieldProperties(page, {
|
||||
fileName: "arial_unicode_en_cidfont.pdf",
|
||||
fileSize: `${FSI}15.4${PDI} KB (${FSI}15,779${PDI} bytes)`,
|
||||
title: "-",
|
||||
author: "Adil Allawi",
|
||||
subject: "-",
|
||||
keywords: "-",
|
||||
creationDate: "7/10/11, 7:17:28 PM",
|
||||
modificationDate: "-",
|
||||
creator: "Writer",
|
||||
producer: "NeoOffice 3.2 Beta",
|
||||
version: "1.4",
|
||||
pageCount: "1",
|
||||
pageSize: `${FSI}8.27${PDI} × ${FSI}11.69${PDI} ${FSI}in${PDI} (${FSI}A4${PDI}, ${FSI}portrait${PDI})`,
|
||||
linearized: "No",
|
||||
});
|
||||
|
||||
await closeDocumentProperties(page);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Document without contentLength", () => {
|
||||
let pages;
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait("empty.pdf", ".textLayer .endOfContent");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("check that the document properties dialog has the correct information", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
// Open a binary PDF document, such that `contentLength` is undefined.
|
||||
const base64 = await fs.readFile("./pdfs/clippath.pdf", {
|
||||
encoding: "base64",
|
||||
});
|
||||
|
||||
await page.evaluate(async b64 => {
|
||||
await window.PDFViewerApplication.open({
|
||||
data: Uint8Array.fromBase64(b64),
|
||||
});
|
||||
}, base64);
|
||||
|
||||
await openDocumentProperties(page);
|
||||
|
||||
await checkFieldProperties(page, {
|
||||
fileName: "document.pdf",
|
||||
fileSize: `${FSI}0.448${PDI} KB (${FSI}459${PDI} bytes)`,
|
||||
title: "-",
|
||||
author: "-",
|
||||
subject: "-",
|
||||
keywords: "-",
|
||||
creationDate: "-",
|
||||
modificationDate: "-",
|
||||
creator: "-",
|
||||
producer: "-",
|
||||
version: "1.1",
|
||||
pageCount: "1",
|
||||
pageSize: `${FSI}2.78${PDI} × ${FSI}1.39${PDI} ${FSI}in${PDI} (${FSI}landscape${PDI})`,
|
||||
linearized: "No",
|
||||
});
|
||||
|
||||
await closeDocumentProperties(page);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Document with multiple pages, and changed viewer page/rotation", () => {
|
||||
let pages;
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait("basicapi.pdf", ".textLayer .endOfContent");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("check that the document properties dialog has the correct information", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
await openDocumentProperties(page);
|
||||
|
||||
await checkFieldProperties(page, {
|
||||
fileName: "basicapi.pdf",
|
||||
fileSize: `${FSI}103${PDI} KB (${FSI}105,779${PDI} bytes)`,
|
||||
title: "Basic API Test",
|
||||
author: "Brendan Dahl",
|
||||
subject: "-",
|
||||
keywords: "TCPDF",
|
||||
creationDate: "4/10/12, 7:30:26 AM",
|
||||
modificationDate: "4/10/12, 7:30:26 AM",
|
||||
creator: "TCPDF",
|
||||
producer: "TCPDF 5.9.133 (http://www.tcpdf.org)",
|
||||
version: "1.7",
|
||||
pageCount: "3",
|
||||
pageSize: `${FSI}8.27${PDI} × ${FSI}11.69${PDI} ${FSI}in${PDI} (${FSI}A4${PDI}, ${FSI}portrait${PDI})`,
|
||||
linearized: "No",
|
||||
});
|
||||
const fieldDataLastUpdated = await getFieldDataLastUpdated(page);
|
||||
|
||||
await closeDocumentProperties(page);
|
||||
|
||||
// Ensure that immediately re-opening the dialog doesn't cause
|
||||
// the field-data to be fetched and parsed again.
|
||||
await openDocumentProperties(page);
|
||||
|
||||
expect(await getFieldDataLastUpdated(page)).toEqual(
|
||||
fieldDataLastUpdated
|
||||
);
|
||||
|
||||
await closeDocumentProperties(page);
|
||||
|
||||
// Goto the second page, and rotate the document.
|
||||
await page.click("#next");
|
||||
await page.waitForFunction(
|
||||
() => window.PDFViewerApplication.page === 2
|
||||
);
|
||||
await page.keyboard.press("r");
|
||||
await page.waitForFunction(
|
||||
() => window.PDFViewerApplication.pdfViewer.pagesRotation === 90
|
||||
);
|
||||
|
||||
await openDocumentProperties(page);
|
||||
|
||||
await checkFieldProperties(page, {
|
||||
fileName: "basicapi.pdf",
|
||||
fileSize: `${FSI}103${PDI} KB (${FSI}105,779${PDI} bytes)`,
|
||||
title: "Basic API Test",
|
||||
author: "Brendan Dahl",
|
||||
subject: "-",
|
||||
keywords: "TCPDF",
|
||||
creationDate: "4/10/12, 7:30:26 AM",
|
||||
modificationDate: "4/10/12, 7:30:26 AM",
|
||||
creator: "TCPDF",
|
||||
producer: "TCPDF 5.9.133 (http://www.tcpdf.org)",
|
||||
version: "1.7",
|
||||
pageCount: "3",
|
||||
pageSize: `${FSI}11.69${PDI} × ${FSI}8.27${PDI} ${FSI}in${PDI} (${FSI}A4${PDI}, ${FSI}landscape${PDI})`,
|
||||
linearized: "No",
|
||||
});
|
||||
|
||||
await closeDocumentProperties(page);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Document with different page sizes", () => {
|
||||
let pages;
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait("sizes.pdf", ".textLayer .endOfContent");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("check that the document properties dialog has the correct information", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
await openDocumentProperties(page);
|
||||
|
||||
await checkFieldProperties(page, {
|
||||
fileName: "sizes.pdf",
|
||||
fileSize: `${FSI}13.4${PDI} KB (${FSI}13,739${PDI} bytes)`,
|
||||
title: "-",
|
||||
author: "Yury ",
|
||||
subject: "-",
|
||||
keywords: "-",
|
||||
creationDate: "6/26/11, 1:26:03 PM",
|
||||
modificationDate: "-",
|
||||
creator: "Writer",
|
||||
producer: "OpenOffice.org 3.3",
|
||||
version: "1.4",
|
||||
pageCount: "3",
|
||||
pageSize: `${FSI}8.5${PDI} × ${FSI}11${PDI} ${FSI}in${PDI} (${FSI}Letter${PDI}, ${FSI}portrait${PDI})`,
|
||||
linearized: "No",
|
||||
});
|
||||
|
||||
await closeDocumentProperties(page);
|
||||
|
||||
// Goto the second page.
|
||||
await page.click("#next");
|
||||
await page.waitForFunction(
|
||||
() => window.PDFViewerApplication.page === 2
|
||||
);
|
||||
|
||||
await openDocumentProperties(page);
|
||||
|
||||
await checkFieldProperties(page, {
|
||||
fileName: "sizes.pdf",
|
||||
fileSize: `${FSI}13.4${PDI} KB (${FSI}13,739${PDI} bytes)`,
|
||||
title: "-",
|
||||
author: "Yury ",
|
||||
subject: "-",
|
||||
keywords: "-",
|
||||
creationDate: "6/26/11, 1:26:03 PM",
|
||||
modificationDate: "-",
|
||||
creator: "Writer",
|
||||
producer: "OpenOffice.org 3.3",
|
||||
version: "1.4",
|
||||
pageCount: "3",
|
||||
pageSize: `${FSI}9.01${PDI} × ${FSI}4.49${PDI} ${FSI}in${PDI} (${FSI}landscape${PDI})`,
|
||||
linearized: "No",
|
||||
});
|
||||
|
||||
await closeDocumentProperties(page);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Document with corrupt page", () => {
|
||||
let pages;
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait(
|
||||
"Pages-tree-refs.pdf",
|
||||
".textLayer .endOfContent",
|
||||
null,
|
||||
null,
|
||||
{ page: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("check that the document properties dialog has the correct information", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
await openDocumentProperties(page);
|
||||
|
||||
await checkFieldProperties(page, {
|
||||
fileName: "Pages-tree-refs.pdf",
|
||||
fileSize: `${FSI}1.07${PDI} KB (${FSI}1,098${PDI} bytes)`,
|
||||
title: "-",
|
||||
author: "-",
|
||||
subject: "-",
|
||||
keywords: "-",
|
||||
creationDate: "-",
|
||||
modificationDate: "-",
|
||||
creator: "-",
|
||||
producer: "-",
|
||||
version: "1.7",
|
||||
pageCount: "2",
|
||||
pageSize: "-",
|
||||
linearized: "No",
|
||||
});
|
||||
|
||||
await closeDocumentProperties(page);
|
||||
|
||||
// Goto the first page (which is *not* corrupt).
|
||||
await page.click("#previous");
|
||||
await page.waitForFunction(
|
||||
() => window.PDFViewerApplication.page === 1
|
||||
);
|
||||
|
||||
await openDocumentProperties(page);
|
||||
|
||||
await checkFieldProperties(page, {
|
||||
fileName: "Pages-tree-refs.pdf",
|
||||
fileSize: `${FSI}1.07${PDI} KB (${FSI}1,098${PDI} bytes)`,
|
||||
title: "-",
|
||||
author: "-",
|
||||
subject: "-",
|
||||
keywords: "-",
|
||||
creationDate: "-",
|
||||
modificationDate: "-",
|
||||
creator: "-",
|
||||
producer: "-",
|
||||
version: "1.7",
|
||||
pageCount: "2",
|
||||
pageSize: `${FSI}8.27${PDI} × ${FSI}11.69${PDI} ${FSI}in${PDI} (${FSI}A4${PDI}, ${FSI}portrait${PDI})`,
|
||||
linearized: "No",
|
||||
});
|
||||
|
||||
await closeDocumentProperties(page);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@ -1726,10 +1726,6 @@ describe("Highlight Editor", () => {
|
||||
it("must check that an existing highlight is ignored on hovering", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
if (navigator.platform.includes("Win")) {
|
||||
pending("Fails consistently on Windows (issue #20136).");
|
||||
}
|
||||
|
||||
await switchToHighlight(page);
|
||||
|
||||
const rect = await getSpanRectFromText(
|
||||
|
||||
@ -38,11 +38,13 @@ async function runTests(results) {
|
||||
"freetext_editor_spec.mjs",
|
||||
"highlight_editor_spec.mjs",
|
||||
"ink_editor_spec.mjs",
|
||||
"presentation_mode_spec.mjs",
|
||||
"reorganize_pages_spec.mjs",
|
||||
"scripting_spec.mjs",
|
||||
"signature_editor_spec.mjs",
|
||||
"simple_viewer_spec.mjs",
|
||||
"stamp_editor_spec.mjs",
|
||||
"text_extractor_spec.mjs",
|
||||
"text_field_spec.mjs",
|
||||
"text_layer_spec.mjs",
|
||||
"text_layer_images_spec.mjs",
|
||||
|
||||
174
test/integration/presentation_mode_spec.mjs
Normal file
174
test/integration/presentation_mode_spec.mjs
Normal file
@ -0,0 +1,174 @@
|
||||
/* 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 {
|
||||
awaitPromise,
|
||||
closePages,
|
||||
createPromise,
|
||||
loadAndWait,
|
||||
waitForTimeout,
|
||||
} from "./test_utils.mjs";
|
||||
|
||||
async function enterPresentationMode(page) {
|
||||
await page.click("#secondaryToolbarToggleButton");
|
||||
await page.waitForSelector("#secondaryToolbar", { hidden: false });
|
||||
|
||||
const handlePresentationModeChanged = await createPromise(page, resolve => {
|
||||
window.PDFViewerApplication.eventBus.on(
|
||||
"presentationmodechanged",
|
||||
resolve,
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
await page.click("#presentationMode");
|
||||
await awaitPromise(handlePresentationModeChanged);
|
||||
|
||||
// Check that presentation mode is active and that the toolbar is
|
||||
// invisible; the latter differentiates between proper presentation
|
||||
// mode and pressing F11 to only hide the browser's UI elements.
|
||||
await page.waitForFunction(`document.fullscreenElement !== null`);
|
||||
await page.waitForSelector("#viewerContainer.pdfPresentationMode", {
|
||||
visible: true,
|
||||
});
|
||||
await page.waitForSelector("#toolbarContainer", { visible: false });
|
||||
await page.waitForFunction(
|
||||
`window.PDFViewerApplication.pdfViewer.currentPageNumber === 1`
|
||||
);
|
||||
await page.waitForFunction(
|
||||
`window.PDFViewerApplication.pdfViewer.currentScaleValue === "page-fit"`
|
||||
);
|
||||
}
|
||||
|
||||
async function exitPresentationMode(page, browserName) {
|
||||
// Note that in Chrome pressing Escape does not work to exit full screen mode
|
||||
// in the Puppeteer scope, so there we exit full screen mode programmatically
|
||||
// instead, which is equivalent to what happens if Escape is pressed.
|
||||
const handlePresentationModeChanged = await createPromise(page, resolve => {
|
||||
window.PDFViewerApplication.eventBus.on(
|
||||
"presentationmodechanged",
|
||||
resolve,
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
await (browserName === "chrome"
|
||||
? page.evaluate(() => document.exitFullscreen())
|
||||
: page.keyboard.press("Escape"));
|
||||
await awaitPromise(handlePresentationModeChanged);
|
||||
|
||||
// Check that presentation mode is not active anymore and the toolbar
|
||||
// is visible again.
|
||||
await page.waitForFunction(`document.fullscreenElement === null`);
|
||||
await page.waitForSelector("#viewerContainer:not(.pdfPresentationMode)", {
|
||||
visible: true,
|
||||
});
|
||||
await page.waitForSelector("#toolbarContainer", { visible: true });
|
||||
await page.waitForFunction(
|
||||
`window.PDFViewerApplication.pdfViewer.currentPageNumber === 1`
|
||||
);
|
||||
await page.waitForFunction(
|
||||
`window.PDFViewerApplication.pdfViewer.currentScaleValue !== "page-fit"`
|
||||
);
|
||||
}
|
||||
|
||||
describe("PDFPresentationMode", () => {
|
||||
describe("Changing pages", () => {
|
||||
let pages;
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait(
|
||||
"basicapi.pdf",
|
||||
".textLayer .endOfContent",
|
||||
100
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("must check that changing pages using arrow keys works in presentation mode", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
await enterPresentationMode(page);
|
||||
|
||||
// Go to the next page.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.waitForFunction(
|
||||
`window.PDFViewerApplication.pdfViewer.currentPageNumber === 2`
|
||||
);
|
||||
|
||||
// Go to the previous page.
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await page.waitForFunction(
|
||||
`window.PDFViewerApplication.pdfViewer.currentPageNumber === 1`
|
||||
);
|
||||
|
||||
await exitPresentationMode(page, browserName);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("must check that changing pages using mouse wheel works in presentation mode", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
await enterPresentationMode(page);
|
||||
|
||||
// Go to the next page.
|
||||
await page.mouse.wheel({ deltaY: 100 });
|
||||
await page.waitForFunction(
|
||||
`window.PDFViewerApplication.pdfViewer.currentPageNumber === 2`
|
||||
);
|
||||
|
||||
// Wait until the viewer accepts a new mouse scroll event; see
|
||||
// `MOUSE_SCROLL_COOLDOWN_TIME` in `web/pdf_presentation_mode.js`.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
await waitForTimeout(50);
|
||||
|
||||
// Go to the previous page.
|
||||
await page.mouse.wheel({ deltaY: -100 });
|
||||
await page.waitForFunction(
|
||||
`window.PDFViewerApplication.pdfViewer.currentPageNumber === 1`
|
||||
);
|
||||
|
||||
await exitPresentationMode(page, browserName);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("must check that changing pages using mouse click works in presentation mode", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
await enterPresentationMode(page);
|
||||
|
||||
// Go to the next page.
|
||||
await page.click(".page[data-page-number='1']");
|
||||
await page.waitForFunction(
|
||||
`window.PDFViewerApplication.pdfViewer.currentPageNumber === 2`
|
||||
);
|
||||
|
||||
// Go to the previous page.
|
||||
await page.keyboard.down("Shift");
|
||||
await page.click(".page[data-page-number='2']");
|
||||
await page.keyboard.up("Shift");
|
||||
await page.waitForFunction(
|
||||
`window.PDFViewerApplication.pdfViewer.currentPageNumber === 1`
|
||||
);
|
||||
|
||||
await exitPresentationMode(page, browserName);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -40,10 +40,27 @@ import {
|
||||
waitForTextToBe,
|
||||
waitForTooltipToBe,
|
||||
} from "./test_utils.mjs";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const __dirname = import.meta.dirname;
|
||||
|
||||
async function createPDFDataTransfer(page, filename) {
|
||||
const pdfPath = path.join(__dirname, "../pdfs", filename);
|
||||
const pdfData = fs.readFileSync(pdfPath).toString("base64");
|
||||
return page.evaluateHandle(
|
||||
(data, name) => {
|
||||
const transfer = new DataTransfer();
|
||||
const view = Uint8Array.fromBase64(data);
|
||||
const file = new File([view], name, { type: "application/pdf" });
|
||||
transfer.items.add(file);
|
||||
return transfer;
|
||||
},
|
||||
pdfData,
|
||||
filename
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForThumbnailVisible(page, pageNums) {
|
||||
await showViewsManager(page);
|
||||
|
||||
@ -3066,7 +3083,7 @@ describe("Reorganize Pages View", () => {
|
||||
await waitAndClick(page, getThumbnailSelector(2));
|
||||
|
||||
const handleMerged = await createPromise(page, resolve => {
|
||||
window.PDFViewerApplication.eventBus._on(
|
||||
window.PDFViewerApplication.eventBus.on(
|
||||
"thumbnailsloaded",
|
||||
resolve,
|
||||
{ once: true }
|
||||
@ -3125,7 +3142,7 @@ describe("Reorganize Pages View", () => {
|
||||
await waitAndClick(page, getThumbnailSelector(1));
|
||||
|
||||
const handleMerged = await createPromise(page, resolve => {
|
||||
window.PDFViewerApplication.eventBus._on(
|
||||
window.PDFViewerApplication.eventBus.on(
|
||||
"thumbnailsloaded",
|
||||
resolve,
|
||||
{ once: true }
|
||||
@ -3161,7 +3178,7 @@ describe("Reorganize Pages View", () => {
|
||||
await waitForThumbnailVisible(page, 1);
|
||||
|
||||
const handleMerged = await createPromise(page, resolve => {
|
||||
window.PDFViewerApplication.eventBus._on(
|
||||
window.PDFViewerApplication.eventBus.on(
|
||||
"thumbnailsloaded",
|
||||
resolve,
|
||||
{ once: true }
|
||||
@ -3197,7 +3214,7 @@ describe("Reorganize Pages View", () => {
|
||||
await waitForTextToBe(page, labelSelector, `${FSI}1${PDI} selected`);
|
||||
|
||||
const handleMerged = await createPromise(page, resolve => {
|
||||
window.PDFViewerApplication.eventBus._on(
|
||||
window.PDFViewerApplication.eventBus.on(
|
||||
"thumbnailsloaded",
|
||||
resolve,
|
||||
{ once: true }
|
||||
@ -3227,4 +3244,136 @@ describe("Reorganize Pages View", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Drag-and-drop PDF merge", () => {
|
||||
let pages;
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait(
|
||||
"three_pages_with_number.pdf",
|
||||
'.page[data-page-number = "1"] .endOfContent',
|
||||
"1",
|
||||
null,
|
||||
{ enableSplitMerge: true, enableMerge: true }
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("should show the marker and merge before the first thumbnail", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
await waitForThumbnailVisible(page, [1, 2, 3]);
|
||||
|
||||
const dataTransfer = await createPDFDataTransfer(
|
||||
page,
|
||||
"three_pages_with_number.pdf"
|
||||
);
|
||||
const markerInfo = await page.evaluate(
|
||||
(transfer, selector) => {
|
||||
const container = document.getElementById("thumbnailsView");
|
||||
const target = document.querySelector(selector);
|
||||
const { left, top, width, height } =
|
||||
target.getBoundingClientRect();
|
||||
const clientX = left + width / 4;
|
||||
const clientY = top + height / 4;
|
||||
const dispatchDragEvent = type => {
|
||||
target.dispatchEvent(
|
||||
new DragEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX,
|
||||
clientY,
|
||||
dataTransfer: transfer,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
dispatchDragEvent("dragenter");
|
||||
dispatchDragEvent("dragover");
|
||||
|
||||
const marker = container.querySelector(":scope > .dragMarker");
|
||||
const { width: markerWidth = 0, height: markerHeight = 0 } =
|
||||
marker?.getBoundingClientRect() ?? {};
|
||||
const translate = marker?.style.translate ?? "";
|
||||
const filesLength = transfer.files.length;
|
||||
|
||||
dispatchDragEvent("dragleave");
|
||||
const survivedDragLeave = !!container.querySelector(
|
||||
":scope > .dragMarker"
|
||||
);
|
||||
|
||||
return {
|
||||
markerHeight,
|
||||
markerWidth,
|
||||
filesLength,
|
||||
survivedDragLeave,
|
||||
translate,
|
||||
};
|
||||
},
|
||||
dataTransfer,
|
||||
getThumbnailSelector(1)
|
||||
);
|
||||
|
||||
expect(markerInfo.markerWidth + markerInfo.markerHeight)
|
||||
.withContext(`In ${browserName}, marker dimensions`)
|
||||
.toBeGreaterThan(0);
|
||||
expect(markerInfo.filesLength)
|
||||
.withContext(`In ${browserName}, dropped files`)
|
||||
.toBe(1);
|
||||
expect(markerInfo.translate.includes("NaN"))
|
||||
.withContext(`In ${browserName}, marker position`)
|
||||
.toBeFalse();
|
||||
expect(markerInfo.survivedDragLeave)
|
||||
.withContext(`In ${browserName}, marker after child dragleave`)
|
||||
.toBeTrue();
|
||||
|
||||
const handleMerged = await createPromise(page, resolve => {
|
||||
const listener = ({ pagesCount }) => {
|
||||
if (pagesCount !== 6) {
|
||||
return;
|
||||
}
|
||||
window.PDFViewerApplication.eventBus.off("pagesloaded", listener);
|
||||
resolve();
|
||||
};
|
||||
window.PDFViewerApplication.eventBus.on("pagesloaded", listener);
|
||||
});
|
||||
await page.evaluate(
|
||||
(transfer, selector) => {
|
||||
const target = document.querySelector(selector);
|
||||
const { left, top, width, height } =
|
||||
target.getBoundingClientRect();
|
||||
target.dispatchEvent(
|
||||
new DragEvent("drop", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: left + width / 4,
|
||||
clientY: top + height / 4,
|
||||
dataTransfer: transfer,
|
||||
})
|
||||
);
|
||||
},
|
||||
dataTransfer,
|
||||
getThumbnailSelector(1)
|
||||
);
|
||||
await awaitPromise(handleMerged);
|
||||
|
||||
await page.waitForFunction(
|
||||
() => parseInt(document.getElementById("pageNumber").max, 10) === 6
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.PDFViewerApplication.page === 1
|
||||
);
|
||||
await waitForHavingContents(page, [1, 2, 3, 1, 2, 3]);
|
||||
await waitForTextToBe(
|
||||
page,
|
||||
"#viewsManagerStatusActionLabel",
|
||||
`${FSI}3${PDI} selected`
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { mergeCoverageIntoGlobal } from "../coverage_utils.js";
|
||||
import os from "os";
|
||||
|
||||
const isMac = os.platform() === "darwin";
|
||||
@ -149,11 +150,42 @@ function closePages(pages) {
|
||||
}
|
||||
|
||||
async function closeSinglePage(page) {
|
||||
// Avoid to keep something from a previous test.
|
||||
await page.evaluate(async () => {
|
||||
const coverage = await page.evaluate(async () => {
|
||||
// Collect coverage data from the worker before the document is closed.
|
||||
let workerCoverage = null;
|
||||
const handler =
|
||||
window.PDFViewerApplication.pdfDocument?._transport?.messageHandler;
|
||||
if (handler) {
|
||||
try {
|
||||
workerCoverage = await handler.sendWithPromise(
|
||||
"GetWorkerCoverage",
|
||||
null
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Close the viewer gracefully, and clear local storage to avoid state
|
||||
// leaking from one test to another.
|
||||
await window.PDFViewerApplication.testingClose();
|
||||
window.localStorage.clear();
|
||||
|
||||
// Serialize the coverage data to a JSON string because that is a lot
|
||||
// faster/cheaper to transfer from the browser to Node.js over the WebDriver
|
||||
// BiDi protocol, otherwise Puppeteer's (significantly slower) serialization
|
||||
// logic kicks in (see https://github.com/puppeteer/puppeteer/issues/2427).
|
||||
return {
|
||||
page: window.__coverage__ ? JSON.stringify(window.__coverage__) : null,
|
||||
worker: workerCoverage ? JSON.stringify(workerCoverage) : null,
|
||||
};
|
||||
});
|
||||
|
||||
if (coverage.page) {
|
||||
mergeCoverageIntoGlobal(JSON.parse(coverage.page));
|
||||
}
|
||||
if (coverage.worker) {
|
||||
mergeCoverageIntoGlobal(JSON.parse(coverage.worker));
|
||||
}
|
||||
|
||||
await page.close({ runBeforeUnload: false });
|
||||
}
|
||||
|
||||
@ -236,13 +268,8 @@ function getSelector(id) {
|
||||
}
|
||||
|
||||
async function getRect(page, selector) {
|
||||
// In Chrome something is wrong when serializing a `DomRect`,
|
||||
// so we extract the values and return them ourselves.
|
||||
await page.waitForSelector(selector, { visible: true });
|
||||
return page.$eval(selector, el => {
|
||||
const { x, y, width, height } = el.getBoundingClientRect();
|
||||
return { x, y, width, height };
|
||||
});
|
||||
return (await page.$(selector)).boundingBox();
|
||||
}
|
||||
|
||||
function getQuerySelector(id) {
|
||||
|
||||
119
test/integration/text_extractor_spec.mjs
Normal file
119
test/integration/text_extractor_spec.mjs
Normal file
@ -0,0 +1,119 @@
|
||||
/* 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 { closePages, loadAndWait } from "./test_utils.mjs";
|
||||
|
||||
async function dispatchRequestTextContent(page, id) {
|
||||
return page.evaluate(requestId => {
|
||||
const event = new CustomEvent("requestTextContent", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
detail: { requestId },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}, id);
|
||||
}
|
||||
|
||||
async function getReportTextData(page) {
|
||||
await page.waitForFunction(() => window._reportTextData !== undefined);
|
||||
return page.evaluate(() => {
|
||||
const data = window._reportTextData;
|
||||
delete window._reportTextData;
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
describe("PdfTextExtractor", () => {
|
||||
describe("Simple multi-page document", () => {
|
||||
let pages;
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait("basicapi.pdf", ".textLayer .endOfContent");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("check that all text is extracted", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
await dispatchRequestTextContent(page, 1);
|
||||
|
||||
const { text, requestId } = await getReportTextData(page);
|
||||
|
||||
expect(text).toEqual(
|
||||
[
|
||||
"Table Of Content",
|
||||
"Chapter 1 .......................................................... 2",
|
||||
"Paragraph 1.1 ...................................................... 3",
|
||||
"page 1 / 3",
|
||||
"Chapter 1",
|
||||
"page 2 / 3",
|
||||
"Paragraph 1.1",
|
||||
"Powered by TCPDF (www.tcpdf.org)",
|
||||
"page 3 / 3",
|
||||
].join("\n")
|
||||
);
|
||||
expect(requestId).toEqual(1);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multi-page document, with disableAutoFetch=true set", () => {
|
||||
let pages;
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait(
|
||||
"tracemonkey.pdf",
|
||||
".textLayer .endOfContent",
|
||||
null,
|
||||
null,
|
||||
{
|
||||
disableAutoFetch: true,
|
||||
disableStream: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("check that all text is extracted", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
await dispatchRequestTextContent(page, 2);
|
||||
|
||||
const { text, requestId } = await getReportTextData(page);
|
||||
|
||||
expect(
|
||||
text.startsWith(
|
||||
"Trace-based Just-in-Time Type Specialization for Dynamic\nLanguages"
|
||||
)
|
||||
).toBeTrue();
|
||||
expect(
|
||||
text.endsWith(
|
||||
"Conference on Virtual Execution Environments, pages 83–93. ACM\nPress, 2007."
|
||||
)
|
||||
).toBeTrue();
|
||||
expect(text.length).toEqual(82804);
|
||||
expect(requestId).toEqual(2);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -13,15 +13,41 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @import { Page } from "puppeteer"
|
||||
*/
|
||||
|
||||
import {
|
||||
closePages,
|
||||
closeSinglePage,
|
||||
getSpanRectFromText,
|
||||
kbSelectAll,
|
||||
loadAndWait,
|
||||
waitForEvent,
|
||||
} from "./test_utils.mjs";
|
||||
import { MathClamp } from "../../src/shared/math_clamp.js";
|
||||
import { startBrowser } from "../test.mjs";
|
||||
|
||||
/**
|
||||
* @typedef Point
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef Rect
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
* @property {number} width
|
||||
* @property {number} height
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef SpanInfo
|
||||
* @property {Rect} rect
|
||||
* @property {string} text
|
||||
*/
|
||||
|
||||
describe("Text layer", () => {
|
||||
describe("Text selection", () => {
|
||||
// page.mouse.move(x, y, { steps: ... }) doesn't work in Firefox, because
|
||||
@ -59,6 +85,144 @@ describe("Text layer", () => {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a point outside the page while remaining inside the viewer.
|
||||
*
|
||||
* @param {Rect} page
|
||||
* Page rectangle.
|
||||
* @param {Rect} viewer
|
||||
* Viewer rectangle.
|
||||
* @param {number} preferredY
|
||||
* Preferred Y coordinate for the pointer target, to avoid unnecessarily
|
||||
* moving the pointer too far.
|
||||
* @returns {Point}
|
||||
* Point outside the page bounds but inside the viewer.
|
||||
*/
|
||||
function getOutsidePagePosition(page, viewer, preferredY) {
|
||||
// The pointer target must remain inside the visible viewer area;
|
||||
// otherwise Firefox can fail with an out-of-bounds move.
|
||||
const minX = Math.ceil(viewer.x) + 5;
|
||||
const maxX = Math.floor(viewer.x + viewer.width) - 5;
|
||||
const minY = Math.ceil(viewer.y) + 5;
|
||||
const maxY = Math.floor(viewer.y + viewer.height) - 5;
|
||||
const y = MathClamp(minY, Math.round(preferredY), maxY);
|
||||
|
||||
const candidates = [
|
||||
{ x: Math.round(page.x + page.width + 20), y },
|
||||
// Prefer below over left: going left retraces through existing text
|
||||
// and shrinks the selection before exiting the page boundary.
|
||||
{
|
||||
x: Math.round(page.x + page.width / 2),
|
||||
y: Math.round(page.y + page.height + 20),
|
||||
},
|
||||
{ x: Math.round(page.x - 20), y },
|
||||
{
|
||||
x: Math.round(page.x + page.width / 2),
|
||||
y: Math.round(page.y - 20),
|
||||
},
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
candidate.x >= minX &&
|
||||
candidate.x <= maxX &&
|
||||
candidate.y >= minY &&
|
||||
candidate.y <= maxY
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: still return a safe in-view point if preferred directions
|
||||
// are clipped by the viewport at this scroll position.
|
||||
return { x: maxX, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current selection.
|
||||
*
|
||||
* @param {Page} page
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function getSelectionText(page) {
|
||||
return page.evaluate(
|
||||
() => window.getSelection()?.toString().replaceAll("\r\n", "\n") || ""
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the draw layer contains a non-empty selection.
|
||||
*
|
||||
* @param {Page} page
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function hasDrawnSelection(page) {
|
||||
return page.evaluate(() => {
|
||||
// If there is no selection, the `div.selection` is removed.
|
||||
for (const path of document.querySelectorAll(
|
||||
".canvasWrapper .selection svg path"
|
||||
)) {
|
||||
if (path.getAttribute("d")?.trim()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first non-empty text span on a page.
|
||||
*
|
||||
* @param {Page} page
|
||||
* @param {number} pageNumber
|
||||
* @returns {Promise<SpanInfo | null>}
|
||||
*/
|
||||
async function getFirstSpanInfo(page, pageNumber) {
|
||||
await page.waitForSelector(
|
||||
`.page[data-page-number="${pageNumber}"] > .textLayer .endOfContent`
|
||||
);
|
||||
return page.evaluate(number => {
|
||||
for (const el of document.querySelectorAll(
|
||||
`.page[data-page-number="${number}"] > .textLayer span:not(:has(> span))`
|
||||
)) {
|
||||
const text = el.textContent?.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const { x, y, width, height } = el.getBoundingClientRect();
|
||||
return { rect: { x, y, width, height }, text };
|
||||
}
|
||||
return null;
|
||||
}, pageNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last non-empty text span on a page.
|
||||
*
|
||||
* @param {Page} page
|
||||
* @param {number} pageNumber
|
||||
* @returns {Promise<SpanInfo | null>}
|
||||
*/
|
||||
async function getLastSpanInfo(page, pageNumber) {
|
||||
await page.waitForSelector(
|
||||
`.page[data-page-number="${pageNumber}"] > .textLayer .endOfContent`
|
||||
);
|
||||
return page.evaluate(number => {
|
||||
let last = null;
|
||||
for (const el of document.querySelectorAll(
|
||||
`.page[data-page-number="${number}"] > .textLayer span:not(:has(> span))`
|
||||
)) {
|
||||
const text = el.textContent?.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const { x, y, width, height } = el.getBoundingClientRect();
|
||||
last = { rect: { x, y, width, height }, text };
|
||||
}
|
||||
return last;
|
||||
}, pageNumber);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jasmine.addAsyncMatchers({
|
||||
// Check that a page has a selection containing the given text, with
|
||||
@ -67,11 +231,7 @@ describe("Text layer", () => {
|
||||
return {
|
||||
async compare(page, expected) {
|
||||
const TOLERANCE = 10;
|
||||
|
||||
const actual = await page.evaluate(() =>
|
||||
// We need to normalize EOL for Windows
|
||||
window.getSelection().toString().replaceAll("\r\n", "\n")
|
||||
);
|
||||
const actual = await getSelectionText(page);
|
||||
|
||||
let start, end;
|
||||
if (expected instanceof RegExp) {
|
||||
@ -108,6 +268,275 @@ describe("Text layer", () => {
|
||||
});
|
||||
|
||||
describe("using mouse", () => {
|
||||
describe("selection is preserved when dragging outside page bounds", () => {
|
||||
/** @type {Array<[string, Page]>} */
|
||||
let pages;
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait(
|
||||
"tracemonkey.pdf",
|
||||
`.page[data-page-number = "1"] .endOfContent`,
|
||||
undefined,
|
||||
undefined,
|
||||
(_page, browserName) => ({
|
||||
imagesRightClickMinSize: browserName === "firefox" ? 16 : -1,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("keeps selection when dragging to another page and then outside", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
const scrollTarget = await getSpanRectFromText(
|
||||
page,
|
||||
1,
|
||||
"Unlike method-based dynamic compilers, our dynamic com-"
|
||||
);
|
||||
await page.evaluate(top => {
|
||||
document.getElementById("viewerContainer").scrollTop = top;
|
||||
}, scrollTarget.y - 50);
|
||||
|
||||
const [
|
||||
positionStartPage1,
|
||||
positionStartPage2,
|
||||
positionEndPage2,
|
||||
page2Rect,
|
||||
viewerRect,
|
||||
] = await Promise.all([
|
||||
getSpanRectFromText(
|
||||
page,
|
||||
1,
|
||||
"Each compiled trace covers one path through the program with"
|
||||
).then(middlePosition),
|
||||
getSpanRectFromText(
|
||||
page,
|
||||
2,
|
||||
"Hence, recording and compiling a trace"
|
||||
).then(middlePosition),
|
||||
getSpanRectFromText(
|
||||
page,
|
||||
2,
|
||||
"cache. Alternatively, the VM could simply stop tracing, and give up"
|
||||
).then(belowEndPosition),
|
||||
page.$eval('.page[data-page-number="2"]', div => {
|
||||
const { x, y, width, height } = div.getBoundingClientRect();
|
||||
return { x, y, width, height };
|
||||
}),
|
||||
page.$eval("#viewerContainer", div => {
|
||||
const { x, y, width, height } = div.getBoundingClientRect();
|
||||
return { x, y, width, height };
|
||||
}),
|
||||
]);
|
||||
|
||||
const outsidePage2 = getOutsidePagePosition(
|
||||
page2Rect,
|
||||
viewerRect,
|
||||
positionEndPage2.y
|
||||
);
|
||||
|
||||
await page.mouse.move(positionStartPage1.x, positionStartPage1.y);
|
||||
await page.mouse.down();
|
||||
// First cross into page 2 while still in-bounds so we can verify
|
||||
// the multi-page selection is established before exiting page 2.
|
||||
await moveInSteps(
|
||||
page,
|
||||
positionStartPage1,
|
||||
positionStartPage2,
|
||||
20
|
||||
);
|
||||
|
||||
const selectionBeforeOutside = await getSelectionText(page);
|
||||
expect(selectionBeforeOutside)
|
||||
.withContext(`In ${browserName}, before leaving page 2`)
|
||||
.toMatch(/path through.*Hence, recording/s);
|
||||
|
||||
await moveInSteps(page, positionStartPage2, positionEndPage2, 20);
|
||||
const selectionInsidePage2 = await getSelectionText(page);
|
||||
expect(selectionInsidePage2)
|
||||
.withContext(`In ${browserName}, while still on page 2`)
|
||||
.toMatch(/path through.*Hence, recording and .* tracing/s);
|
||||
|
||||
await moveInSteps(page, positionEndPage2, outsidePage2, 20);
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
expect(await hasDrawnSelection(page))
|
||||
.withContext(
|
||||
`In ${browserName}, selection drawn while outside page`
|
||||
)
|
||||
.toBeTrue();
|
||||
|
||||
const selectedText = await getSelectionText(page);
|
||||
expect(selectedText.length)
|
||||
.withContext(
|
||||
`In ${browserName}, selection not lost after mouseup`
|
||||
)
|
||||
.toBeGreaterThan(10);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps selection when dragging outside the current page", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
const [positionStart, page1Rect, viewerRect] = await Promise.all([
|
||||
getSpanRectFromText(
|
||||
page,
|
||||
1,
|
||||
"(frequently executed) bytecode sequences, records"
|
||||
).then(middlePosition),
|
||||
page.$eval('.page[data-page-number="1"]', div => {
|
||||
const { x, y, width, height } = div.getBoundingClientRect();
|
||||
return { x, y, width, height };
|
||||
}),
|
||||
page.$eval("#viewerContainer", div => {
|
||||
const { x, y, width, height } = div.getBoundingClientRect();
|
||||
return { x, y, width, height };
|
||||
}),
|
||||
]);
|
||||
|
||||
const outsidePage1 = getOutsidePagePosition(
|
||||
page1Rect,
|
||||
viewerRect,
|
||||
positionStart.y
|
||||
);
|
||||
|
||||
await page.mouse.move(positionStart.x, positionStart.y);
|
||||
await page.mouse.down();
|
||||
await moveInSteps(page, positionStart, outsidePage1, 20);
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
expect(await hasDrawnSelection(page))
|
||||
.withContext(
|
||||
`In ${browserName}, selection drawn while outside page`
|
||||
)
|
||||
.toBeTrue();
|
||||
|
||||
const selectedText = await getSelectionText(page);
|
||||
expect(selectedText.length)
|
||||
.withContext(`In ${browserName}`)
|
||||
.toBeGreaterThan(5);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection with tagged PDFs", () => {
|
||||
/** @type {Array<[string, Page]>} */
|
||||
let pages;
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait(
|
||||
"structure_simple.pdf",
|
||||
`.page[data-page-number = "1"] .endOfContent`,
|
||||
undefined,
|
||||
undefined,
|
||||
(_page, browserName) => ({
|
||||
imagesRightClickMinSize: browserName === "firefox" ? 16 : -1,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("keeps selection when dragging outside the page", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
const [firstSpanInfo, pageRect, viewerRect] = await Promise.all([
|
||||
getFirstSpanInfo(page, 1),
|
||||
page.$eval('.page[data-page-number="1"]', div => {
|
||||
const { x, y, width, height } = div.getBoundingClientRect();
|
||||
return { x, y, width, height };
|
||||
}),
|
||||
page.$eval("#viewerContainer", div => {
|
||||
const { x, y, width, height } = div.getBoundingClientRect();
|
||||
return { x, y, width, height };
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(firstSpanInfo)
|
||||
.withContext(`In ${browserName}`)
|
||||
.not.toBeNull();
|
||||
|
||||
const positionStart = middlePosition(firstSpanInfo.rect);
|
||||
const outsidePage = getOutsidePagePosition(
|
||||
pageRect,
|
||||
viewerRect,
|
||||
positionStart.y
|
||||
);
|
||||
|
||||
await page.mouse.move(positionStart.x, positionStart.y);
|
||||
await page.mouse.down();
|
||||
await moveInSteps(page, positionStart, outsidePage, 20);
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
expect(await hasDrawnSelection(page))
|
||||
.withContext(
|
||||
`In ${browserName}, selection drawn while outside page`
|
||||
)
|
||||
.toBeTrue();
|
||||
|
||||
const selectedText = await getSelectionText(page);
|
||||
expect(selectedText.length)
|
||||
.withContext(`In ${browserName}`)
|
||||
.toBeGreaterThan(0);
|
||||
expect(selectedText)
|
||||
.withContext(`In ${browserName}`)
|
||||
.toContain(firstSpanInfo.text.slice(0, 1));
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("doesn't jump when hovering on an empty area", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
const [firstSpanInfo, lastSpanInfo] = await Promise.all([
|
||||
getFirstSpanInfo(page, 1),
|
||||
getLastSpanInfo(page, 1),
|
||||
]);
|
||||
|
||||
expect(firstSpanInfo)
|
||||
.withContext(`In ${browserName}, first span`)
|
||||
.not.toBeNull();
|
||||
expect(lastSpanInfo)
|
||||
.withContext(`In ${browserName}, last span`)
|
||||
.not.toBeNull();
|
||||
|
||||
const positionStart = middlePosition(firstSpanInfo.rect);
|
||||
const positionEnd = belowEndPosition(lastSpanInfo.rect);
|
||||
|
||||
await page.mouse.move(positionStart.x, positionStart.y);
|
||||
await page.mouse.down();
|
||||
// Drag from the first to the last text run to pass through the
|
||||
// tagged content and end in the empty area below the text.
|
||||
await moveInSteps(page, positionStart, positionEnd, 20);
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
expect(await hasDrawnSelection(page))
|
||||
.withContext(`In ${browserName}, selection drawn in tagged PDF`)
|
||||
.toBeTrue();
|
||||
|
||||
await expectAsync(page)
|
||||
.withContext(`In ${browserName}`)
|
||||
// Selection starts mid-word in Heading 1, so assert the stable
|
||||
// trailing content rather than exact full-line boundaries.
|
||||
.toHaveRoughlySelected(
|
||||
/ing 1\s+This paragraph 1\.\s+Heading 2\s+This paragraph 2/s
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("doesn't jump when hovering on an empty area", () => {
|
||||
let pages;
|
||||
|
||||
@ -273,7 +702,7 @@ describe("Text layer", () => {
|
||||
.withContext(`In ${browserName}`)
|
||||
.toHaveRoughlySelected(
|
||||
"rs as the railway projects under\n" +
|
||||
"development enter the construction phase (estimated at "
|
||||
"development enter the construction phase (estimated at"
|
||||
);
|
||||
})
|
||||
);
|
||||
@ -519,9 +948,7 @@ describe("Text layer", () => {
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
const selection = await page.evaluate(() =>
|
||||
window.getSelection().toString()
|
||||
);
|
||||
const selection = await getSelectionText(page);
|
||||
expect(selection).withContext(`In ${browserName}`).toEqual("AB");
|
||||
|
||||
// The selectionchange handler in TextLayerBuilder walks up
|
||||
@ -541,6 +968,65 @@ describe("Text layer", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with `enableSelectionRendering` disabled", () => {
|
||||
/** @type {Array<[string, Page]>} */
|
||||
let pages;
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait(
|
||||
"tracemonkey.pdf",
|
||||
`.page[data-page-number = "1"] .endOfContent`,
|
||||
undefined,
|
||||
undefined,
|
||||
(_page, browserName) => ({
|
||||
enableSelectionRendering: false,
|
||||
imagesRightClickMinSize: browserName === "firefox" ? 16 : -1,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("does not render a selection overlay in the draw layer", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
const [positionStart, positionEnd] = await Promise.all([
|
||||
getSpanRectFromText(
|
||||
page,
|
||||
1,
|
||||
"(frequently executed) bytecode sequences, records"
|
||||
).then(middlePosition),
|
||||
getSpanRectFromText(
|
||||
page,
|
||||
1,
|
||||
"them, and compiles them to fast native code. We call such a se-"
|
||||
).then(belowEndPosition),
|
||||
]);
|
||||
|
||||
await page.mouse.move(positionStart.x, positionStart.y);
|
||||
await page.mouse.down();
|
||||
await moveInSteps(page, positionStart, positionEnd, 20);
|
||||
await page.mouse.up();
|
||||
|
||||
// Text should still be selectable.
|
||||
const selectedText = await getSelectionText(page);
|
||||
expect(selectedText.length)
|
||||
.withContext(`In ${browserName}, text is still selectable`)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// But no selection overlay should appear in the draw layer.
|
||||
expect(await hasDrawnSelection(page))
|
||||
.withContext(
|
||||
`In ${browserName}, no selection drawn when disabled`
|
||||
)
|
||||
.toBeFalse();
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("using selection carets", () => {
|
||||
@ -633,6 +1119,142 @@ describe("Text layer", () => {
|
||||
.toHaveRoughlySelected(/frequently .* We call such a s/s);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with select-all (Ctrl+A)", () => {
|
||||
/** @type {Array<[string, Page]>} */
|
||||
let pages;
|
||||
|
||||
/**
|
||||
* Return the set of page numbers that have a non-empty selection
|
||||
* overlay path in their draw layer.
|
||||
*
|
||||
* @param {Page} page
|
||||
* @returns {Promise<Array<number>>}
|
||||
*/
|
||||
async function pagesWithDrawnSelection(page) {
|
||||
return page.evaluate(() => {
|
||||
const numbers = new Set();
|
||||
for (const path of document.querySelectorAll(
|
||||
".page .canvasWrapper .selection svg path"
|
||||
)) {
|
||||
if (path.getAttribute("d")?.trim()) {
|
||||
const n = path.closest(".page")?.dataset.pageNumber;
|
||||
if (n) {
|
||||
numbers.add(Number(n));
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...numbers].sort((a, b) => a - b);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait(
|
||||
"tracemonkey.pdf",
|
||||
`.page[data-page-number = "1"] .endOfContent`
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("draws a selection overlay on currently-rendered pages", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
// Wait for at least two text layers to be rendered so the
|
||||
// overlay can be expected on multiple pages. The number of
|
||||
// pages rendered up-front at the default zoom can vary
|
||||
// depending on the viewport size on CI.
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelectorAll(".textLayer .endOfContent").length >=
|
||||
2
|
||||
);
|
||||
|
||||
await waitForEvent({
|
||||
page,
|
||||
eventName: "selectionchange",
|
||||
action: () => kbSelectAll(page),
|
||||
});
|
||||
|
||||
expect(await hasDrawnSelection(page))
|
||||
.withContext(`In ${browserName}`)
|
||||
.toBeTrue();
|
||||
|
||||
// Several text layers are rendered at the default zoom and
|
||||
// each one should now carry a selection overlay.
|
||||
const drawn = await pagesWithDrawnSelection(page);
|
||||
expect(drawn.length)
|
||||
.withContext(
|
||||
`In ${browserName}, pages with selection overlay: ` +
|
||||
`${drawn.join(",")}`
|
||||
)
|
||||
.toBeGreaterThan(1);
|
||||
expect(drawn[0])
|
||||
.withContext(`In ${browserName}, first selected page`)
|
||||
.toBe(1);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("extends the overlay onto pages rendered after scroll", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
await waitForEvent({
|
||||
page,
|
||||
eventName: "selectionchange",
|
||||
action: () => kbSelectAll(page),
|
||||
});
|
||||
|
||||
const initial = await pagesWithDrawnSelection(page);
|
||||
expect(initial.length)
|
||||
.withContext(`In ${browserName}, initial pages with overlay`)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// Pick the first page that hasn't been rendered with a
|
||||
// selection overlay yet, scroll to it, and verify the overlay
|
||||
// gets drawn on it once its draw layer is parented.
|
||||
const lastInitial = initial.at(-1);
|
||||
const targetPage = lastInitial + 1;
|
||||
|
||||
await page.evaluate(n => {
|
||||
const pageDiv = document.querySelector(
|
||||
`.page[data-page-number="${n}"]`
|
||||
);
|
||||
pageDiv.scrollIntoView({ block: "center" });
|
||||
}, targetPage);
|
||||
|
||||
await page.waitForSelector(
|
||||
`.page[data-page-number="${targetPage}"] .textLayer .endOfContent`,
|
||||
{ timeout: 0 }
|
||||
);
|
||||
|
||||
// After the new page is rendered, its draw layer becomes
|
||||
// "live" (`setParent` is called) and the selection overlay
|
||||
// must be extended onto it without requiring a new
|
||||
// `selectionchange` event.
|
||||
await page.waitForFunction(
|
||||
n => {
|
||||
const path = document.querySelector(
|
||||
`.page[data-page-number="${n}"] .canvasWrapper .selection svg path`
|
||||
);
|
||||
return !!path?.getAttribute("d")?.trim();
|
||||
},
|
||||
{ timeout: 0 },
|
||||
targetPage
|
||||
);
|
||||
|
||||
const afterScroll = await pagesWithDrawnSelection(page);
|
||||
expect(afterScroll)
|
||||
.withContext(
|
||||
`In ${browserName}, target page ${targetPage} has overlay`
|
||||
)
|
||||
.toContain(targetPage);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the browser enforces a minimum font size", () => {
|
||||
|
||||
@ -26,8 +26,11 @@ import {
|
||||
waitForPageChanging,
|
||||
waitForPageRendered,
|
||||
} from "./test_utils.mjs";
|
||||
import path from "path";
|
||||
import { PNG } from "pngjs";
|
||||
|
||||
const __dirname = import.meta.dirname;
|
||||
|
||||
describe("PDF viewer", () => {
|
||||
describe("Zoom origin", () => {
|
||||
let pages;
|
||||
@ -1403,6 +1406,10 @@ describe("PDF viewer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("keeps the content under the pinch centre fixed on the screen", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
@ -1610,6 +1617,10 @@ describe("PDF viewer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
it("Check that the top right corner of the annotation is centered vertically", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
@ -1892,5 +1903,108 @@ describe("PDF viewer", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("@page size stylesheet under CSP", () => {
|
||||
let pages;
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait(
|
||||
"basicapi.pdf",
|
||||
".textLayer .endOfContent",
|
||||
null,
|
||||
{
|
||||
earlySetup: () => {
|
||||
// Capture state while window.print() runs — the print service's
|
||||
// destroy() removes the @page stylesheet right after, on the
|
||||
// afterprint event.
|
||||
window._pageRuleApplied = null;
|
||||
window.print = () => {
|
||||
window._pageRuleApplied = [
|
||||
...document.querySelectorAll("style"),
|
||||
].some(
|
||||
s =>
|
||||
s.sheet?.cssRules.length > 0 &&
|
||||
[...s.sheet.cssRules].some(r => r.cssText.includes("@page"))
|
||||
);
|
||||
};
|
||||
},
|
||||
appSetup: app => {
|
||||
app._testPrintResolver = Promise.withResolvers();
|
||||
},
|
||||
eventBusSetup: eventBus => {
|
||||
eventBus.on(
|
||||
"afterprint",
|
||||
() => {
|
||||
window.PDFViewerApplication._testPrintResolver.resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
// The print service injects an inline
|
||||
// <style>@page { size: WxH pt }</style> to match the PDF's page
|
||||
// dimensions. If the CSP `style-src-elem` directive blocks inline
|
||||
// <style> elements, the element is created but its content is never
|
||||
// parsed — `sheet.cssRules` stays empty and the @page rule has no
|
||||
// effect. See web/viewer.html.
|
||||
it("must apply the injected @page rule (no CSP block)", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
await waitAndClick(page, "#printButton");
|
||||
await awaitPromise(
|
||||
await page.evaluateHandle(() => [
|
||||
window.PDFViewerApplication._testPrintResolver.promise,
|
||||
])
|
||||
);
|
||||
|
||||
const hasPageRule = await page.evaluate(
|
||||
() => window._pageRuleApplied
|
||||
);
|
||||
expect(hasPageRule)
|
||||
.withContext(
|
||||
`In ${browserName}: injected @page stylesheet was parsed`
|
||||
)
|
||||
.toBeTrue();
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Open a new PDF via the file input", () => {
|
||||
let pages;
|
||||
|
||||
beforeEach(async () => {
|
||||
pages = await loadAndWait("tracemonkey.pdf", ".textLayer .endOfContent");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
// The "Open" input wraps the chosen file in a blob URL,
|
||||
// which the worker then fetches. `connect-src` in the production CSP must
|
||||
// therefore allow `blob:` — see web/viewer.html.
|
||||
it("must load a PDF picked through the file input (blob URL)", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
const fileInput = await page.$("#fileInput");
|
||||
await fileInput.uploadFile(
|
||||
path.join(__dirname, "../pdfs/basicapi.pdf")
|
||||
);
|
||||
|
||||
await page.waitForFunction(
|
||||
() => window.PDFViewerApplication.pdfDocument?.numPages === 3
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -890,6 +890,7 @@ async function startIntegrationTest() {
|
||||
sessions[0].numRuns = results.runs;
|
||||
sessions[0].numErrors = results.failures;
|
||||
sessions[0].failures = results.failureList;
|
||||
sessions[0].coverage = globalThis.__coverage__;
|
||||
await Promise.all(sessions.map(session => closeSession(session.name)));
|
||||
}
|
||||
|
||||
@ -991,12 +992,20 @@ async function startBrowser({
|
||||
// calls can run before events triggered by the previous protocol calls had
|
||||
// a chance to be processed (essentially causing events to get lost). This
|
||||
// value gives Chrome a more similar execution speed as Firefox.
|
||||
options.slowMo = 5;
|
||||
options.slowMo = 3;
|
||||
|
||||
// avoid crash
|
||||
options.args = ["--no-sandbox", "--disable-setuid-sandbox"];
|
||||
// silent printing in a pdf
|
||||
options.args.push("--kiosk-printing");
|
||||
options.args = [
|
||||
// Avoid crashing because no sandbox is shipped by default and we only run
|
||||
// our own trusted content in the scope of the tests (for more information
|
||||
// see https://pptr.dev/troubleshooting#setting-up-chrome-linux-sandbox).
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
// Print PDFs silently (without print preview or user interaction).
|
||||
"--kiosk-printing",
|
||||
// Disable hardware acceleration (fixes rendering issues, see #15168 and
|
||||
// #21272, and environments like GitHub Actions don't expose GPUs anyway).
|
||||
"--disable-gpu",
|
||||
];
|
||||
}
|
||||
|
||||
if (browserName === "firefox") {
|
||||
@ -1015,12 +1024,13 @@ async function startBrowser({
|
||||
// Save file in output
|
||||
"browser.download.folderList": 2,
|
||||
"browser.download.dir": tempDir,
|
||||
// Print silently in a pdf
|
||||
// Print PDFs silently (without print preview or user interaction).
|
||||
"print.always_print_silent": true,
|
||||
print_printer: "PDF",
|
||||
"print.printer_PDF.print_to_file": true,
|
||||
"print.printer_PDF.print_to_filename": printFile,
|
||||
// Disable gpu acceleration
|
||||
// Disable hardware acceleration (fixes rendering issues, see #15168 and
|
||||
// #21272, and environments like GitHub Actions don't expose GPUs anyway).
|
||||
"gfx.canvas.accelerated": false,
|
||||
// It's helpful to see where the caret is.
|
||||
"accessibility.browsewithcaret": true,
|
||||
|
||||
@ -39,7 +39,6 @@ import {
|
||||
} from "./test_utils.js";
|
||||
import {
|
||||
fetchData as fetchDataDOM,
|
||||
PageViewport,
|
||||
RenderingCancelledException,
|
||||
StatTimer,
|
||||
} from "../../src/display/display_utils.js";
|
||||
@ -56,6 +55,7 @@ import { AutoPrintRegExp } from "../../web/ui_utils.js";
|
||||
import { GlobalImageCache } from "../../src/core/image_utils.js";
|
||||
import { GlobalWorkerOptions } from "../../src/display/worker_options.js";
|
||||
import { Metadata } from "../../src/display/metadata.js";
|
||||
import { PageViewport } from "../../src/display/page_viewport.js";
|
||||
|
||||
const WORKER_SRC = "../../build/generic/build/pdf.worker.mjs";
|
||||
|
||||
@ -4590,6 +4590,27 @@ have written that much by now. So, here’s to squashing bugs.`);
|
||||
await loadingTask.destroy();
|
||||
});
|
||||
|
||||
it("gets operatorList, from PDF with /BrotliDecode", async function () {
|
||||
const loadingTask = getDocument(
|
||||
buildGetDocumentParams("Brotli-Prototype-FileA.pdf")
|
||||
);
|
||||
expect(loadingTask).toBeInstanceOf(PDFDocumentLoadingTask);
|
||||
|
||||
const pdfDoc = await loadingTask.promise;
|
||||
expect(pdfDoc.numPages).toEqual(25);
|
||||
|
||||
const pdfPage = await pdfDoc.getPage(1);
|
||||
expect(pdfPage).toBeInstanceOf(PDFPageProxy);
|
||||
|
||||
const opList = await pdfPage.getOperatorList();
|
||||
expect(opList.fnArray.length).toBeGreaterThan(9800);
|
||||
expect(opList.argsArray.length).toBeGreaterThan(9800);
|
||||
expect(opList.lastChunk).toBeTrue();
|
||||
expect(opList.separateAnnots).toBeNull();
|
||||
|
||||
await loadingTask.destroy();
|
||||
});
|
||||
|
||||
it("gets page stats after parsing page, without `pdfBug` set", async function () {
|
||||
await page.getOperatorList();
|
||||
expect(page.stats).toEqual(null);
|
||||
|
||||
@ -18,7 +18,9 @@ import {
|
||||
CFFCompiler,
|
||||
CFFFDSelect,
|
||||
CFFParser,
|
||||
CFFPrivateDict,
|
||||
CFFStrings,
|
||||
CFFTopDict,
|
||||
} from "../../src/core/cff_parser.js";
|
||||
import { SEAC_ANALYSIS_ENABLED } from "../../src/core/fonts_utils.js";
|
||||
import { Stream } from "../../src/core/stream.js";
|
||||
@ -112,6 +114,77 @@ describe("CFFParser", function () {
|
||||
expect(topDict.getByName("Private")).toEqual([45, 102]);
|
||||
});
|
||||
|
||||
it("ignores an empty FontBBox when adjusting ascent/descent", function () {
|
||||
cff.topDict.setByName("FontBBox", [0, 0, 0, 0]);
|
||||
const fontDataWithEmptyBBox = new CFFCompiler(cff).compile();
|
||||
|
||||
const properties = {
|
||||
ascent: 800,
|
||||
descent: -200,
|
||||
};
|
||||
new CFFParser(
|
||||
new Stream(fontDataWithEmptyBBox),
|
||||
properties,
|
||||
SEAC_ANALYSIS_ENABLED
|
||||
).parse();
|
||||
|
||||
expect(properties.ascent).toEqual(800);
|
||||
expect(properties.descent).toEqual(-200);
|
||||
expect(properties.ascentScaled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("repairs an empty FontBBox from font descriptor data", function () {
|
||||
cff.topDict.setByName("FontBBox", [0, 0, 0, 0]);
|
||||
const fontDataWithEmptyBBox = new CFFCompiler(cff).compile();
|
||||
|
||||
const properties = {
|
||||
bbox: [2974, -300, 64236, 900],
|
||||
};
|
||||
const reparsedCff = new CFFParser(
|
||||
new Stream(fontDataWithEmptyBBox),
|
||||
properties,
|
||||
SEAC_ANALYSIS_ENABLED
|
||||
).parse();
|
||||
|
||||
expect(reparsedCff.topDict.getByName("FontBBox")).toEqual([
|
||||
-1300, -300, 2974, 900,
|
||||
]);
|
||||
expect(properties.ascent).toEqual(900);
|
||||
expect(properties.descent).toEqual(-300);
|
||||
expect(properties.ascentScaled).toEqual(true);
|
||||
});
|
||||
|
||||
it("repairs likely Ghostscript-zeroed FDArray private defaults", function () {
|
||||
cff.isCIDFont = true;
|
||||
cff.topDict.setByName("ROS", [0, 0, 0]);
|
||||
cff.topDict.setByName("FDSelect", 0);
|
||||
cff.topDict.setByName("FDArray", 0);
|
||||
|
||||
const fdDict = new CFFTopDict(cff.strings);
|
||||
fdDict.setByName("Private", [0, 0]);
|
||||
fdDict.privateDict = new CFFPrivateDict(cff.strings);
|
||||
fdDict.privateDict.setByName("BlueScale", 0);
|
||||
fdDict.privateDict.setByName("BlueShift", 0);
|
||||
fdDict.privateDict.setByName("BlueFuzz", 0);
|
||||
fdDict.privateDict.setByName("ExpansionFactor", 0);
|
||||
|
||||
cff.fdArray = [fdDict];
|
||||
cff.fdSelect = new CFFFDSelect(0, Array(cff.charStrings.count).fill(0));
|
||||
const fontDataWithBrokenFDPrivate = new CFFCompiler(cff).compile();
|
||||
|
||||
const reparsedCff = new CFFParser(
|
||||
new Stream(fontDataWithBrokenFDPrivate),
|
||||
{},
|
||||
SEAC_ANALYSIS_ENABLED
|
||||
).parse();
|
||||
const privateDict = reparsedCff.fdArray[0].privateDict;
|
||||
|
||||
expect(privateDict.getByName("BlueScale")).toEqual(0.039625);
|
||||
expect(privateDict.getByName("BlueShift")).toEqual(7);
|
||||
expect(privateDict.getByName("BlueFuzz")).toEqual(1);
|
||||
expect(privateDict.getByName("ExpansionFactor")).toEqual(0.06);
|
||||
});
|
||||
|
||||
it("refuses to add topDict key with invalid value (bug 1068432)", function () {
|
||||
const topDict = cff.topDict;
|
||||
const defaultValue = topDict.getByName("UnderlinePosition");
|
||||
|
||||
@ -48,7 +48,9 @@
|
||||
"pdf_viewer_spec.js",
|
||||
"postscript_spec.js",
|
||||
"primitives_spec.js",
|
||||
"scripting_utils_spec.js",
|
||||
"stream_spec.js",
|
||||
"string_utils_spec.js",
|
||||
"struct_tree_spec.js",
|
||||
"svg_factory_spec.js",
|
||||
"text_layer_spec.js",
|
||||
|
||||
@ -22,13 +22,10 @@ import {
|
||||
getInheritableProperty,
|
||||
getModificationDate,
|
||||
getSizeInBytes,
|
||||
isAscii,
|
||||
isWhiteSpace,
|
||||
numberToString,
|
||||
parseXFAPath,
|
||||
recoverJsURL,
|
||||
stringToUTF16HexString,
|
||||
stringToUTF16String,
|
||||
toRomanNumerals,
|
||||
validateCSSFont,
|
||||
} from "../../src/core/core_utils.js";
|
||||
@ -416,56 +413,6 @@ describe("core_utils", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAscii", function () {
|
||||
it("handles ascii/non-ascii strings", function () {
|
||||
expect(isAscii("hello world")).toEqual(true);
|
||||
expect(isAscii("こんにちは世界の")).toEqual(false);
|
||||
expect(isAscii("hello world in Japanese is こんにちは世界の")).toEqual(
|
||||
false
|
||||
);
|
||||
expect(isAscii("")).toEqual(true);
|
||||
expect(isAscii(123)).toEqual(false);
|
||||
expect(isAscii(null)).toEqual(false);
|
||||
expect(isAscii(undefined)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stringToUTF16HexString", function () {
|
||||
it("should encode a string in UTF16 hexadecimal format", function () {
|
||||
expect(stringToUTF16HexString("hello world")).toEqual(
|
||||
"00680065006c006c006f00200077006f0072006c0064"
|
||||
);
|
||||
|
||||
expect(stringToUTF16HexString("こんにちは世界の")).toEqual(
|
||||
"30533093306b3061306f4e16754c306e"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stringToUTF16String", function () {
|
||||
it("should encode a string in UTF16", function () {
|
||||
expect(stringToUTF16String("hello world")).toEqual(
|
||||
"\0h\0e\0l\0l\0o\0 \0w\0o\0r\0l\0d"
|
||||
);
|
||||
|
||||
expect(stringToUTF16String("こんにちは世界の")).toEqual(
|
||||
"\x30\x53\x30\x93\x30\x6b\x30\x61\x30\x6f\x4e\x16\x75\x4c\x30\x6e"
|
||||
);
|
||||
});
|
||||
|
||||
it("should encode a string in UTF16BE with a BOM", function () {
|
||||
expect(
|
||||
stringToUTF16String("hello world", /* bigEndian = */ true)
|
||||
).toEqual("\xfe\xff\0h\0e\0l\0l\0o\0 \0w\0o\0r\0l\0d");
|
||||
|
||||
expect(
|
||||
stringToUTF16String("こんにちは世界の", /* bigEndian = */ true)
|
||||
).toEqual(
|
||||
"\xfe\xff\x30\x53\x30\x93\x30\x6b\x30\x61\x30\x6f\x4e\x16\x75\x4c\x30\x6e"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deepCompare", function () {
|
||||
it("should return true for the same reference", function () {
|
||||
const dict = new Dict();
|
||||
|
||||
@ -151,6 +151,9 @@ describe("display_utils", function () {
|
||||
expect(getPdfFilenameFromUrl("/pdfs/%AA.pdf")).toEqual("%AA.pdf");
|
||||
|
||||
expect(getPdfFilenameFromUrl("/pdfs/%2F.pdf")).toEqual("%2F.pdf");
|
||||
|
||||
// A corrupt relative URL.
|
||||
expect(getPdfFilenameFromUrl("//%%file.pdf")).toEqual("document.pdf");
|
||||
});
|
||||
|
||||
it("gets PDF filename from (some) standard protocols", function () {
|
||||
|
||||
@ -42,7 +42,7 @@
|
||||
|
||||
import { GlobalWorkerOptions } from "pdfjs/display/worker_options.js";
|
||||
import { isNodeJS } from "../../src/shared/util.js";
|
||||
import { mergeWorkerCoverageIntoWindow } from "../coverage_utils.js";
|
||||
import { mergeCoverageIntoGlobal } from "../coverage_utils.js";
|
||||
import { MessageHandler } from "pdfjs/shared/message_handler.js";
|
||||
import { PDFWorker } from "pdfjs/display/api.js";
|
||||
import { TestReporter } from "../reporter.js";
|
||||
@ -95,7 +95,9 @@ async function initializePDFJS(callback) {
|
||||
"pdfjs-test/unit/postscript_spec.js",
|
||||
"pdfjs-test/unit/primitives_spec.js",
|
||||
"pdfjs-test/unit/scripting_spec.js",
|
||||
"pdfjs-test/unit/scripting_utils_spec.js",
|
||||
"pdfjs-test/unit/stream_spec.js",
|
||||
"pdfjs-test/unit/string_utils_spec.js",
|
||||
"pdfjs-test/unit/struct_tree_spec.js",
|
||||
"pdfjs-test/unit/svg_factory_spec.js",
|
||||
"pdfjs-test/unit/text_layer_spec.js",
|
||||
@ -156,7 +158,7 @@ function installWorkerCoverageHook() {
|
||||
const handler = new MessageHandler("main", "worker", webWorker);
|
||||
const promise = handler
|
||||
.sendWithPromise("GetWorkerCoverage", null)
|
||||
.then(mergeWorkerCoverageIntoWindow)
|
||||
.then(mergeCoverageIntoGlobal)
|
||||
.catch(e => {
|
||||
console.warn(`Failed to collect worker coverage: ${e}`);
|
||||
})
|
||||
|
||||
@ -46,7 +46,6 @@ import {
|
||||
getPdfFilenameFromUrl,
|
||||
getRGB,
|
||||
getRGBA,
|
||||
getXfaPageViewport,
|
||||
isDataScheme,
|
||||
isPdfFile,
|
||||
noContextMenu,
|
||||
@ -106,7 +105,6 @@ const expectedAPI = Object.freeze({
|
||||
getRGB,
|
||||
getRGBA,
|
||||
getUuid,
|
||||
getXfaPageViewport,
|
||||
GlobalWorkerOptions,
|
||||
ImageKind,
|
||||
InvalidPDFException,
|
||||
|
||||
70
test/unit/scripting_utils_spec.js
Normal file
70
test/unit/scripting_utils_spec.js
Normal file
@ -0,0 +1,70 @@
|
||||
/* 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 { ColorConverters } from "../../src/shared/scripting_utils.js";
|
||||
|
||||
describe("scripting_utils", function () {
|
||||
describe("ColorConverters", function () {
|
||||
it("should check G conversion", function () {
|
||||
const color = [0.5];
|
||||
|
||||
expect(ColorConverters.G_CMYK(color)).toEqual(["CMYK", 0, 0, 0, 0.5]);
|
||||
|
||||
expect(ColorConverters.G_RGB(color)).toEqual(["RGB", 0.5, 0.5, 0.5]);
|
||||
|
||||
expect(ColorConverters.G_rgb(color)).toEqual([127.5, 127.5, 127.5]);
|
||||
|
||||
expect(ColorConverters.G_HTML(color)).toEqual("#7f7f7f");
|
||||
});
|
||||
|
||||
it("should check RGB conversion", function () {
|
||||
const color = [0.4, 0.5, 0.6];
|
||||
|
||||
expect(ColorConverters.RGB_CMYK(color)).toEqual([
|
||||
"CMYK",
|
||||
0.6,
|
||||
0.5,
|
||||
0.4,
|
||||
0.4,
|
||||
]);
|
||||
|
||||
expect(ColorConverters.RGB_G(color)).toEqual(["G", 0.481]);
|
||||
|
||||
expect(ColorConverters.RGB_rgb(color)).toEqual([102, 127.5, 153]);
|
||||
|
||||
expect(ColorConverters.RGB_HTML(color)).toEqual("#667f99");
|
||||
});
|
||||
|
||||
it("should check CMYK conversion", function () {
|
||||
const color = [0.4, 0.5, 0.6, 0];
|
||||
|
||||
expect(ColorConverters.CMYK_RGB(color)).toEqual(["RGB", 0.6, 0.4, 0.5]);
|
||||
|
||||
expect(ColorConverters.CMYK_G(color)).toEqual(["G", 0.471]);
|
||||
|
||||
expect(ColorConverters.CMYK_rgb(color)).toEqual([153, 102, 127.5]);
|
||||
|
||||
expect(ColorConverters.CMYK_HTML(color)).toEqual("#99667f");
|
||||
});
|
||||
|
||||
it("should check T conversion", function () {
|
||||
const color = [0.4, 0.5, 0.6];
|
||||
|
||||
expect(ColorConverters.T_rgb(color)).toEqual([null]);
|
||||
|
||||
expect(ColorConverters.T_HTML(color)).toEqual("#00000000");
|
||||
});
|
||||
});
|
||||
});
|
||||
147
test/unit/string_utils_spec.js
Normal file
147
test/unit/string_utils_spec.js
Normal file
@ -0,0 +1,147 @@
|
||||
/* Copyright 2019 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 {
|
||||
isAscii,
|
||||
stringToPDFString,
|
||||
stringToUTF16HexString,
|
||||
stringToUTF16String,
|
||||
} from "../../src/core/string_utils.js";
|
||||
|
||||
describe("string_utils", function () {
|
||||
describe("isAscii", function () {
|
||||
it("handles ascii/non-ascii strings", function () {
|
||||
expect(isAscii("hello world")).toEqual(true);
|
||||
expect(isAscii("こんにちは世界の")).toEqual(false);
|
||||
expect(isAscii("hello world in Japanese is こんにちは世界の")).toEqual(
|
||||
false
|
||||
);
|
||||
expect(isAscii("")).toEqual(true);
|
||||
expect(isAscii(123)).toEqual(false);
|
||||
expect(isAscii(null)).toEqual(false);
|
||||
expect(isAscii(undefined)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stringToUTF16HexString", function () {
|
||||
it("should encode a string in UTF16 hexadecimal format", function () {
|
||||
expect(stringToUTF16HexString("hello world")).toEqual(
|
||||
"00680065006c006c006f00200077006f0072006c0064"
|
||||
);
|
||||
|
||||
expect(stringToUTF16HexString("こんにちは世界の")).toEqual(
|
||||
"30533093306b3061306f4e16754c306e"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stringToUTF16String", function () {
|
||||
it("should encode a string in UTF16", function () {
|
||||
expect(stringToUTF16String("hello world")).toEqual(
|
||||
"\0h\0e\0l\0l\0o\0 \0w\0o\0r\0l\0d"
|
||||
);
|
||||
|
||||
expect(stringToUTF16String("こんにちは世界の")).toEqual(
|
||||
"\x30\x53\x30\x93\x30\x6b\x30\x61\x30\x6f\x4e\x16\x75\x4c\x30\x6e"
|
||||
);
|
||||
});
|
||||
|
||||
it("should encode a string in UTF16BE with a BOM", function () {
|
||||
expect(
|
||||
stringToUTF16String("hello world", /* bigEndian = */ true)
|
||||
).toEqual("\xfe\xff\0h\0e\0l\0l\0o\0 \0w\0o\0r\0l\0d");
|
||||
|
||||
expect(
|
||||
stringToUTF16String("こんにちは世界の", /* bigEndian = */ true)
|
||||
).toEqual(
|
||||
"\xfe\xff\x30\x53\x30\x93\x30\x6b\x30\x61\x30\x6f\x4e\x16\x75\x4c\x30\x6e"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stringToPDFString", function () {
|
||||
it("handles ISO Latin 1 strings", function () {
|
||||
const str = "\x8Dstring\x8E";
|
||||
expect(stringToPDFString(str)).toEqual("\u201Cstring\u201D");
|
||||
});
|
||||
|
||||
it("handles UTF-16 big-endian strings", function () {
|
||||
const str = "\xFE\xFF\x00\x73\x00\x74\x00\x72\x00\x69\x00\x6E\x00\x67";
|
||||
expect(stringToPDFString(str)).toEqual("string");
|
||||
});
|
||||
|
||||
it("handles incomplete UTF-16 big-endian strings", function () {
|
||||
const str = "\xFE\xFF\x00\x73\x00\x74\x00\x72\x00\x69\x00\x6E\x00";
|
||||
expect(stringToPDFString(str)).toEqual("strin");
|
||||
});
|
||||
|
||||
it("handles UTF-16 little-endian strings", function () {
|
||||
const str = "\xFF\xFE\x73\x00\x74\x00\x72\x00\x69\x00\x6E\x00\x67\x00";
|
||||
expect(stringToPDFString(str)).toEqual("string");
|
||||
});
|
||||
|
||||
it("handles incomplete UTF-16 little-endian strings", function () {
|
||||
const str = "\xFF\xFE\x73\x00\x74\x00\x72\x00\x69\x00\x6E\x00\x67";
|
||||
expect(stringToPDFString(str)).toEqual("strin");
|
||||
});
|
||||
|
||||
it("handles UTF-8 strings", function () {
|
||||
const simpleStr = "\xEF\xBB\xBF\x73\x74\x72\x69\x6E\x67";
|
||||
expect(stringToPDFString(simpleStr)).toEqual("string");
|
||||
|
||||
const complexStr =
|
||||
"\xEF\xBB\xBF\xE8\xA1\xA8\xE3\x83\x9D\xE3\x81\x82\x41\xE9\xB7\x97" +
|
||||
"\xC5\x92\xC3\xA9\xEF\xBC\xA2\xE9\x80\x8D\xC3\x9C\xC3\x9F\xC2\xAA" +
|
||||
"\xC4\x85\xC3\xB1\xE4\xB8\x82\xE3\x90\x80\xF0\xA0\x80\x80";
|
||||
expect(stringToPDFString(complexStr)).toEqual(
|
||||
"表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles empty strings", function () {
|
||||
// ISO Latin 1
|
||||
const str1 = "";
|
||||
expect(stringToPDFString(str1)).toEqual("");
|
||||
|
||||
// UTF-16BE
|
||||
const str2 = "\xFE\xFF";
|
||||
expect(stringToPDFString(str2)).toEqual("");
|
||||
|
||||
// UTF-16LE
|
||||
const str3 = "\xFF\xFE";
|
||||
expect(stringToPDFString(str3)).toEqual("");
|
||||
|
||||
// UTF-8
|
||||
const str4 = "\xEF\xBB\xBF";
|
||||
expect(stringToPDFString(str4)).toEqual("");
|
||||
});
|
||||
|
||||
it("handles strings with language code", function () {
|
||||
// ISO Latin 1
|
||||
const str1 = "hello \x1benUS\x1bworld";
|
||||
expect(stringToPDFString(str1)).toEqual("hello world");
|
||||
|
||||
// UTF-16BE
|
||||
const str2 =
|
||||
"\xFE\xFF\x00h\x00e\x00l\x00l\x00o\x00 \x00\x1b\x00e\x00n\x00U\x00S\x00\x1b\x00w\x00o\x00r\x00l\x00d";
|
||||
expect(stringToPDFString(str2)).toEqual("hello world");
|
||||
|
||||
// UTF-16LE
|
||||
const str3 =
|
||||
"\xFF\xFEh\x00e\x00l\x00l\x00o\x00 \x00\x1b\x00e\x00n\x00U\x00S\x00\x1b\x00w\x00o\x00r\x00l\x00d\x00";
|
||||
expect(stringToPDFString(str3)).toEqual("hello world");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -19,7 +19,6 @@ import {
|
||||
createValidAbsoluteUrl,
|
||||
getUuid,
|
||||
stringToBytes,
|
||||
stringToPDFString,
|
||||
} from "../../src/shared/util.js";
|
||||
|
||||
describe("util", function () {
|
||||
@ -83,80 +82,6 @@ describe("util", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("stringToPDFString", function () {
|
||||
it("handles ISO Latin 1 strings", function () {
|
||||
const str = "\x8Dstring\x8E";
|
||||
expect(stringToPDFString(str)).toEqual("\u201Cstring\u201D");
|
||||
});
|
||||
|
||||
it("handles UTF-16 big-endian strings", function () {
|
||||
const str = "\xFE\xFF\x00\x73\x00\x74\x00\x72\x00\x69\x00\x6E\x00\x67";
|
||||
expect(stringToPDFString(str)).toEqual("string");
|
||||
});
|
||||
|
||||
it("handles incomplete UTF-16 big-endian strings", function () {
|
||||
const str = "\xFE\xFF\x00\x73\x00\x74\x00\x72\x00\x69\x00\x6E\x00";
|
||||
expect(stringToPDFString(str)).toEqual("strin");
|
||||
});
|
||||
|
||||
it("handles UTF-16 little-endian strings", function () {
|
||||
const str = "\xFF\xFE\x73\x00\x74\x00\x72\x00\x69\x00\x6E\x00\x67\x00";
|
||||
expect(stringToPDFString(str)).toEqual("string");
|
||||
});
|
||||
|
||||
it("handles incomplete UTF-16 little-endian strings", function () {
|
||||
const str = "\xFF\xFE\x73\x00\x74\x00\x72\x00\x69\x00\x6E\x00\x67";
|
||||
expect(stringToPDFString(str)).toEqual("strin");
|
||||
});
|
||||
|
||||
it("handles UTF-8 strings", function () {
|
||||
const simpleStr = "\xEF\xBB\xBF\x73\x74\x72\x69\x6E\x67";
|
||||
expect(stringToPDFString(simpleStr)).toEqual("string");
|
||||
|
||||
const complexStr =
|
||||
"\xEF\xBB\xBF\xE8\xA1\xA8\xE3\x83\x9D\xE3\x81\x82\x41\xE9\xB7\x97" +
|
||||
"\xC5\x92\xC3\xA9\xEF\xBC\xA2\xE9\x80\x8D\xC3\x9C\xC3\x9F\xC2\xAA" +
|
||||
"\xC4\x85\xC3\xB1\xE4\xB8\x82\xE3\x90\x80\xF0\xA0\x80\x80";
|
||||
expect(stringToPDFString(complexStr)).toEqual(
|
||||
"表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles empty strings", function () {
|
||||
// ISO Latin 1
|
||||
const str1 = "";
|
||||
expect(stringToPDFString(str1)).toEqual("");
|
||||
|
||||
// UTF-16BE
|
||||
const str2 = "\xFE\xFF";
|
||||
expect(stringToPDFString(str2)).toEqual("");
|
||||
|
||||
// UTF-16LE
|
||||
const str3 = "\xFF\xFE";
|
||||
expect(stringToPDFString(str3)).toEqual("");
|
||||
|
||||
// UTF-8
|
||||
const str4 = "\xEF\xBB\xBF";
|
||||
expect(stringToPDFString(str4)).toEqual("");
|
||||
});
|
||||
|
||||
it("handles strings with language code", function () {
|
||||
// ISO Latin 1
|
||||
const str1 = "hello \x1benUS\x1bworld";
|
||||
expect(stringToPDFString(str1)).toEqual("hello world");
|
||||
|
||||
// UTF-16BE
|
||||
const str2 =
|
||||
"\xFE\xFF\x00h\x00e\x00l\x00l\x00o\x00 \x00\x1b\x00e\x00n\x00U\x00S\x00\x1b\x00w\x00o\x00r\x00l\x00d";
|
||||
expect(stringToPDFString(str2)).toEqual("hello world");
|
||||
|
||||
// UTF-16LE
|
||||
const str3 =
|
||||
"\xFF\xFEh\x00e\x00l\x00l\x00o\x00 \x00\x1b\x00e\x00n\x00U\x00S\x00\x1b\x00w\x00o\x00r\x00l\x00d\x00";
|
||||
expect(stringToPDFString(str3)).toEqual("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createValidAbsoluteUrl", function () {
|
||||
it("handles invalid URLs", function () {
|
||||
expect(createValidAbsoluteUrl(undefined, undefined)).toEqual(null);
|
||||
|
||||
@ -285,6 +285,7 @@
|
||||
pointer-events: auto;
|
||||
box-sizing: content-box;
|
||||
padding: var(--editor-toolbar-padding);
|
||||
user-select: none;
|
||||
|
||||
position: absolute;
|
||||
inset-inline-end: 0;
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
|
||||
/** @typedef {import("../src/display/api").PDFPageProxy} PDFPageProxy */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
|
||||
/** @typedef {import("../src/display/page_viewport").PageViewport} PageViewport */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/editor/tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */
|
||||
// eslint-disable-next-line max-len
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
|
||||
/** @typedef {import("../src/display/api").PDFPageProxy} PDFPageProxy */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
|
||||
/** @typedef {import("../src/display/page_viewport").PageViewport} PageViewport */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/annotation_storage").AnnotationStorage} AnnotationStorage */
|
||||
// eslint-disable-next-line max-len
|
||||
|
||||
@ -376,6 +376,7 @@ const PDFViewerApplication = {
|
||||
enableNewBadge: x => x === "true",
|
||||
enablePermissions: x => x === "true",
|
||||
enableMerge: x => x === "true",
|
||||
enableSelectionRendering: x => x === "true",
|
||||
enableSplitMerge: x => x === "true",
|
||||
enableUpdatedAddImage: x => x === "true",
|
||||
highlightEditorColors: x => x,
|
||||
@ -578,6 +579,7 @@ const PDFViewerApplication = {
|
||||
enableOptimizedPartialRendering: AppOptions.get(
|
||||
"enableOptimizedPartialRendering"
|
||||
),
|
||||
enableSelectionRendering: AppOptions.get("enableSelectionRendering"),
|
||||
imagesRightClickMinSize: AppOptions.get("imagesRightClickMinSize"),
|
||||
pageColors,
|
||||
mlManager,
|
||||
|
||||
@ -320,6 +320,11 @@ const defaultOptions = {
|
||||
: "./images/",
|
||||
kind: OptionKind.VIEWER,
|
||||
},
|
||||
enableSelectionRendering: {
|
||||
/** @type {boolean} */
|
||||
value: true,
|
||||
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
|
||||
},
|
||||
imagesRightClickMinSize: {
|
||||
/** @type {number} */
|
||||
value:
|
||||
|
||||
@ -36,6 +36,8 @@ class BasePDFPageView extends RenderableView {
|
||||
|
||||
enableOptimizedPartialRendering = false;
|
||||
|
||||
enableSelectionRendering = true;
|
||||
|
||||
imagesRightClickMinSize = -1;
|
||||
|
||||
eventBus = null;
|
||||
@ -58,6 +60,7 @@ class BasePDFPageView extends RenderableView {
|
||||
this.renderingQueue = options.renderingQueue;
|
||||
this.enableOptimizedPartialRendering =
|
||||
options.enableOptimizedPartialRendering ?? false;
|
||||
this.enableSelectionRendering = options.enableSelectionRendering !== false;
|
||||
this.imagesRightClickMinSize = options.imagesRightClickMinSize ?? -1;
|
||||
this.minDurationToUpdateCanvas = options.minDurationToUpdateCanvas ?? 500;
|
||||
}
|
||||
|
||||
@ -14,6 +14,16 @@
|
||||
*/
|
||||
|
||||
.canvasWrapper {
|
||||
.selection {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
background: rgb(0 90 255 / 0.22);
|
||||
}
|
||||
|
||||
svg {
|
||||
transform: none;
|
||||
|
||||
|
||||
@ -15,6 +15,19 @@
|
||||
|
||||
import { DrawLayer } from "pdfjs-lib";
|
||||
|
||||
/**
|
||||
* @typedef DrawLayerBuilderOptions
|
||||
* Configuration for {@linkcode DrawLayerBuilder}.
|
||||
* @property {number} pageIndex
|
||||
* Zero-based page index.
|
||||
* @property {Element | null} [textLayer]
|
||||
* Text layer element (optional).
|
||||
* @property {Object | null} [filterFactory]
|
||||
* Filter factory used to style selections (optional).
|
||||
* @property {Object | null} [pageColors]
|
||||
* Page foreground/background colors for HCM (optional).
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DrawLayerBuilderRenderOptions
|
||||
* @property {string} [intent] - The default value is "display".
|
||||
@ -23,6 +36,19 @@ import { DrawLayer } from "pdfjs-lib";
|
||||
class DrawLayerBuilder {
|
||||
#drawLayer = null;
|
||||
|
||||
/**
|
||||
* @param {DrawLayerBuilderOptions} options
|
||||
* Configuration.
|
||||
* @returns
|
||||
* Instance.
|
||||
*/
|
||||
constructor(options) {
|
||||
this.pageIndex = options.pageIndex;
|
||||
this.textLayer = options.textLayer || null;
|
||||
this.filterFactory = options.filterFactory || null;
|
||||
this.pageColors = options.pageColors || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DrawLayerBuilderRenderOptions} options
|
||||
* @returns {Promise<void>}
|
||||
@ -31,7 +57,12 @@ class DrawLayerBuilder {
|
||||
if (intent !== "display" || this.#drawLayer || this._cancelled) {
|
||||
return;
|
||||
}
|
||||
this.#drawLayer = new DrawLayer();
|
||||
this.#drawLayer = new DrawLayer({
|
||||
pageIndex: this.pageIndex,
|
||||
textLayer: this.textLayer,
|
||||
filterFactory: this.filterFactory,
|
||||
pageColors: this.pageColors,
|
||||
});
|
||||
}
|
||||
|
||||
cancel() {
|
||||
|
||||
@ -39,6 +39,19 @@ class Preferences extends BasePreferences {
|
||||
}
|
||||
|
||||
class ExternalServices extends BaseExternalServices {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
|
||||
// For testing purposes.
|
||||
Object.defineProperty(this, "reportText", {
|
||||
value: data => {
|
||||
window._reportTextData = data;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async createL10n() {
|
||||
return new GenericL10n(AppOptions.get("localeProperties")?.lang);
|
||||
}
|
||||
|
||||
@ -111,6 +111,9 @@ class PDFDocumentProperties {
|
||||
this.#updateUI();
|
||||
return;
|
||||
}
|
||||
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
|
||||
this._fieldDataLastUpdated = Date.now();
|
||||
}
|
||||
|
||||
// Get the document properties.
|
||||
const [
|
||||
@ -118,7 +121,13 @@ class PDFDocumentProperties {
|
||||
pdfPage,
|
||||
] = await Promise.all([
|
||||
this.pdfDocument.getMetadata(),
|
||||
this.pdfDocument.getPage(currentPageNumber),
|
||||
this.pdfDocument.getPage(currentPageNumber).catch(reason => {
|
||||
console.error(
|
||||
`PDFDocumentProperties - unable to get page ${currentPageNumber}.`,
|
||||
reason
|
||||
);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
|
||||
const [
|
||||
@ -135,7 +144,7 @@ class PDFDocumentProperties {
|
||||
this._titleLookup(),
|
||||
this.#parseDate(metadata?.get("xmp:createdate"), info.CreationDate),
|
||||
this.#parseDate(metadata?.get("xmp:modifydate"), info.ModDate),
|
||||
this.#parsePageSize(getPageSizeInches(pdfPage), pagesRotation),
|
||||
this.#parsePageSize(pdfPage, pagesRotation),
|
||||
this.#parseLinearization(info.IsLinearized),
|
||||
]);
|
||||
|
||||
@ -220,6 +229,9 @@ class PDFDocumentProperties {
|
||||
// since it will be updated the next time `this.open` is called.
|
||||
return;
|
||||
}
|
||||
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
|
||||
this.dialog.dataset.fieldDataLastUpdated = this._fieldDataLastUpdated;
|
||||
}
|
||||
for (const id in this.fields) {
|
||||
const content = this.#fieldData?.[id];
|
||||
this.fields[id].textContent = content || content === 0 ? content : "-";
|
||||
@ -239,10 +251,11 @@ class PDFDocumentProperties {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async #parsePageSize(pageSizeInches, pagesRotation) {
|
||||
if (!pageSizeInches) {
|
||||
async #parsePageSize(pdfPage, pagesRotation) {
|
||||
if (!pdfPage) {
|
||||
return undefined;
|
||||
}
|
||||
let pageSizeInches = getPageSizeInches(pdfPage);
|
||||
// Take the viewer rotation into account as well; compare with Adobe Reader.
|
||||
if (pagesRotation % 180 !== 0) {
|
||||
pageSizeInches = {
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
|
||||
/** @typedef {import("../src/display/page_viewport").PageViewport} PageViewport */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/optional_content_config").OptionalContentConfig} OptionalContentConfig */
|
||||
/** @typedef {import("./event_utils").EventBus} EventBus */
|
||||
@ -93,6 +93,9 @@ import { XfaLayerBuilder } from "./xfa_layer_builder.js";
|
||||
* @property {number} [imagesRightClickMinSize] - All images whose width and
|
||||
* height are at least this value (in pixels) will be lazily inserted in the
|
||||
* dom to allow right-clicking and saving them. Use `-1` to disable this.
|
||||
* @property {boolean} [enableSelectionRendering] - When enabled, renders text
|
||||
* selections in the draw layer.
|
||||
* The default value is `true`.
|
||||
* @property {boolean} [enableOptimizedPartialRendering] - When enabled, PDF
|
||||
* rendering will keep track of which areas of the page each PDF operation
|
||||
* affects. Then, when rendering a partial page (if `enableDetailCanvas` is
|
||||
@ -1191,14 +1194,20 @@ class PDFPageView extends BasePDFPageView {
|
||||
}
|
||||
}
|
||||
|
||||
this.drawLayer ||= new DrawLayerBuilder({
|
||||
pageIndex: this.id,
|
||||
textLayer: this.enableSelectionRendering ? this.textLayer?.div : null,
|
||||
filterFactory: this.pdfPage?.filterFactory,
|
||||
pageColors: this.pageColors,
|
||||
});
|
||||
await this.#renderDrawLayer();
|
||||
this.drawLayer.setParent(canvasWrapper);
|
||||
|
||||
const { annotationEditorUIManager } = this.#layerProperties;
|
||||
|
||||
if (!annotationEditorUIManager) {
|
||||
return;
|
||||
}
|
||||
this.drawLayer ||= new DrawLayerBuilder();
|
||||
await this.#renderDrawLayer();
|
||||
this.drawLayer.setParent(canvasWrapper);
|
||||
|
||||
if (
|
||||
this.annotationLayer ||
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/optional_content_config").OptionalContentConfig} OptionalContentConfig */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
|
||||
/** @typedef {import("../src/display/page_viewport").PageViewport} PageViewport */
|
||||
/** @typedef {import("./event_utils").EventBus} EventBus */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("./pdf_rendering_queue").PDFRenderingQueue} PDFRenderingQueue */
|
||||
|
||||
@ -94,6 +94,10 @@ class PDFThumbnailViewer {
|
||||
|
||||
#dragAC = null;
|
||||
|
||||
#abortSignal = undefined;
|
||||
|
||||
#externalDragActive = false;
|
||||
|
||||
#draggedContainer = null;
|
||||
|
||||
#thumbnailsPositions = null;
|
||||
@ -201,6 +205,7 @@ class PDFThumbnailViewer {
|
||||
this.maxCanvasPixels = maxCanvasPixels;
|
||||
this.maxCanvasDim = maxCanvasDim;
|
||||
this.pageColors = pageColors || null;
|
||||
this.#abortSignal = abortSignal;
|
||||
this.#enableMerge = enableMerge || false;
|
||||
this.#enableSplitMerge = enableSplitMerge || false;
|
||||
this.#statusLabel = statusBar?.viewsManagerStatusActionLabel || null;
|
||||
@ -315,62 +320,11 @@ class PDFThumbnailViewer {
|
||||
|
||||
if (this.#enableMerge && addFileComponent) {
|
||||
const { picker, button } = addFileComponent;
|
||||
picker.addEventListener("change", async () => {
|
||||
picker.addEventListener("change", () => {
|
||||
const file = picker.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
if (file) {
|
||||
this.#mergeFile(file, this._currentPageNumber - 1);
|
||||
}
|
||||
if (file.type !== "application/pdf") {
|
||||
const magic = await file.slice(0, 5).text();
|
||||
if (magic !== "%PDF-") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.#toggleBar("waiting", "pdfjs-views-manager-waiting-for-file");
|
||||
const currentPageIndex = this._currentPageNumber - 1;
|
||||
const buffer = await file.bytes();
|
||||
const pagesCount = this.#pagesMapper.pagesNumber;
|
||||
const data = this.hasStructuralChanges()
|
||||
? this.getStructuralChanges()
|
||||
: [{ document: null }];
|
||||
data.push({
|
||||
document: buffer,
|
||||
insertAfter: currentPageIndex ?? -1,
|
||||
});
|
||||
this.eventBus._on(
|
||||
"pagesloaded",
|
||||
() => {
|
||||
// Clear any pre-merge selection: thumbnails are rebuilt fresh
|
||||
// (all unchecked), so the old set would cause a label/visual
|
||||
// mismatch.
|
||||
this.#selectedPages = null;
|
||||
this.#updateMenuEntries();
|
||||
this.#toggleBar("status");
|
||||
const newPagesCount = this.#pagesMapper.pagesNumber;
|
||||
const insertedPagesCount = newPagesCount - pagesCount;
|
||||
for (
|
||||
let i = currentPageIndex + 1,
|
||||
ii = currentPageIndex + 1 + insertedPagesCount;
|
||||
i < ii;
|
||||
i++
|
||||
) {
|
||||
this._thumbnails[i].checkbox.checked = true;
|
||||
this.#selectPage(i + 1, true);
|
||||
}
|
||||
if (insertedPagesCount) {
|
||||
this.#updateCurrentPage(
|
||||
currentPageIndex + 2,
|
||||
/* force = */ true
|
||||
);
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
this.#reportTelemetry({ action: "merge" });
|
||||
this.eventBus.dispatch("saveandload", {
|
||||
source: this,
|
||||
data,
|
||||
});
|
||||
});
|
||||
button.addEventListener("click", () => {
|
||||
picker.click();
|
||||
@ -397,6 +351,55 @@ class PDFThumbnailViewer {
|
||||
this.renderingQueue.renderHighestPriority();
|
||||
}
|
||||
|
||||
async #mergeFile(file, insertAfter) {
|
||||
if (file.type !== "application/pdf") {
|
||||
const magic = await file.slice(0, 5).text();
|
||||
if (magic !== "%PDF-") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.#toggleBar("waiting", "pdfjs-views-manager-waiting-for-file");
|
||||
const buffer = await file.bytes();
|
||||
const pagesCount = this.#pagesMapper.pagesNumber;
|
||||
const data = this.hasStructuralChanges()
|
||||
? this.getStructuralChanges()
|
||||
: [{ document: null }];
|
||||
data.push({
|
||||
document: buffer,
|
||||
insertAfter,
|
||||
});
|
||||
this.eventBus._on(
|
||||
"pagesloaded",
|
||||
() => {
|
||||
// Clear any pre-merge selection: thumbnails are rebuilt fresh
|
||||
// (all unchecked), so the old set would cause a label/visual
|
||||
// mismatch.
|
||||
this.#selectedPages = null;
|
||||
this.#updateMenuEntries();
|
||||
this.#toggleBar("status");
|
||||
const newPagesCount = this.#pagesMapper.pagesNumber;
|
||||
const insertedPagesCount = newPagesCount - pagesCount;
|
||||
for (
|
||||
let i = insertAfter + 1, ii = insertAfter + 1 + insertedPagesCount;
|
||||
i < ii;
|
||||
i++
|
||||
) {
|
||||
this._thumbnails[i].checkbox.checked = true;
|
||||
this.#selectPage(i + 1, true);
|
||||
}
|
||||
if (insertedPagesCount) {
|
||||
this.#updateCurrentPage(insertAfter + 2, /* force = */ true);
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
this.#reportTelemetry({ action: "merge" });
|
||||
this.eventBus.dispatch("saveandload", {
|
||||
source: this,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
getThumbnail(index) {
|
||||
return this._thumbnails[index];
|
||||
}
|
||||
@ -1185,6 +1188,10 @@ class PDFThumbnailViewer {
|
||||
this.#draggedImageX + this.#draggedImageWidth / 2,
|
||||
this.#draggedImageY + this.#draggedImageHeight / 2
|
||||
);
|
||||
this.#positionDragMarker(positionData);
|
||||
}
|
||||
|
||||
#positionDragMarker(positionData) {
|
||||
if (!positionData) {
|
||||
return;
|
||||
}
|
||||
@ -1202,7 +1209,7 @@ class PDFThumbnailViewer {
|
||||
if (index < 0) {
|
||||
if (xPos.length === 1) {
|
||||
y = bbox[1] - SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT;
|
||||
x = bbox[4];
|
||||
x = bbox[0];
|
||||
width = bbox[2];
|
||||
} else {
|
||||
y = bbox[1];
|
||||
@ -1272,16 +1279,22 @@ class PDFThumbnailViewer {
|
||||
lastRightX ??= cx + w;
|
||||
}
|
||||
}
|
||||
const space =
|
||||
positionsX.length > 1
|
||||
? (positionsX[1] - firstRightX) / 2
|
||||
: (positionsY[1] - firstBottomY) / 2;
|
||||
let space;
|
||||
if (positionsX.length > 1) {
|
||||
space = (positionsX[1] - firstRightX) / 2;
|
||||
} else if (positionsY.length > 1) {
|
||||
space = (positionsY[1] - firstBottomY) / 2;
|
||||
} else {
|
||||
space = SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT;
|
||||
}
|
||||
this.#thumbnailsPositions = {
|
||||
x: positionsX,
|
||||
y: positionsY,
|
||||
lastX: positionsLastX,
|
||||
space,
|
||||
lastSpace: (positionsLastX.at(-1) - lastRightX) / 2,
|
||||
lastSpace: positionsLastX.length
|
||||
? (positionsLastX.at(-1) - lastRightX) / 2
|
||||
: space,
|
||||
bbox,
|
||||
};
|
||||
this.#isOneColumnView = positionsX.length === 1;
|
||||
@ -1380,6 +1393,7 @@ class PDFThumbnailViewer {
|
||||
this.#goToPage(e);
|
||||
});
|
||||
this.#addDragListeners();
|
||||
this.#addExternalFileDropListeners();
|
||||
}
|
||||
|
||||
#selectPage(pageNumber, checked) {
|
||||
@ -1550,6 +1564,140 @@ class PDFThumbnailViewer {
|
||||
});
|
||||
}
|
||||
|
||||
#addExternalFileDropListeners() {
|
||||
if (!this.#enableMerge) {
|
||||
return;
|
||||
}
|
||||
const container = this.container;
|
||||
const signal = this.#abortSignal;
|
||||
|
||||
const hasPdfItem = dataTransfer => {
|
||||
if (!dataTransfer) {
|
||||
return false;
|
||||
}
|
||||
// The file's bytes aren't readable during dragover, so the MIME type is
|
||||
// the only available signal. Matches the existing global drop handler
|
||||
// in app.js. Files with no MIME (e.g. some macOS sources) are rejected
|
||||
// here to keep the "copy" cursor honest; if needed, drop-time magic-byte
|
||||
// validation in #mergeFile would still catch a permissive variant.
|
||||
for (const item of dataTransfer.items) {
|
||||
if (item.kind === "file" && item.type === "application/pdf") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const pointerInContainer = ({ clientX, clientY }) => {
|
||||
const { left, right, top, bottom } = container.getBoundingClientRect();
|
||||
return (
|
||||
clientX >= left && clientX < right && clientY >= top && clientY < bottom
|
||||
);
|
||||
};
|
||||
|
||||
container.addEventListener(
|
||||
"dragenter",
|
||||
e => {
|
||||
if (
|
||||
this.#externalDragActive ||
|
||||
// A page-move drag is already in progress.
|
||||
!isNaN(this.#lastDraggedOverIndex) ||
|
||||
!this._thumbnails.length ||
|
||||
!hasPdfItem(e.dataTransfer)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
this.#externalDragActive = true;
|
||||
this.container.classList.add("isDraggingFile");
|
||||
// Recompute positions in case the layout changed since last time.
|
||||
this.#thumbnailsPositions = null;
|
||||
this.#computeThumbnailsPosition();
|
||||
// Marker hasn't been positioned yet — first dragover will do it.
|
||||
this.#lastDraggedOverIndex = NaN;
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
|
||||
container.addEventListener(
|
||||
"dragover",
|
||||
e => {
|
||||
if (!this.#externalDragActive) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
if (!this.#thumbnailsPositions) {
|
||||
return;
|
||||
}
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const positionData = this.#findClosestThumbnail(x, y);
|
||||
this.#positionDragMarker(positionData);
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
|
||||
container.addEventListener(
|
||||
"dragleave",
|
||||
e => {
|
||||
if (!this.#externalDragActive) {
|
||||
return;
|
||||
}
|
||||
// dragleave fires when crossing into a child element too; only treat
|
||||
// it as a true leave when the cursor has actually left the container.
|
||||
if (
|
||||
(e.relatedTarget && container.contains(e.relatedTarget)) ||
|
||||
pointerInContainer(e)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.#endExternalFileDrag();
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
|
||||
container.addEventListener(
|
||||
"drop",
|
||||
e => {
|
||||
if (!this.#externalDragActive) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
// If no dragover ever ran (e.g. instant drop), compute the index from
|
||||
// the drop event itself so we don't fall through to a stale fallback.
|
||||
if (isNaN(this.#lastDraggedOverIndex) && this.#thumbnailsPositions) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
this.#findClosestThumbnail(
|
||||
e.clientX - rect.left,
|
||||
e.clientY - rect.top
|
||||
);
|
||||
}
|
||||
const insertAfter = isNaN(this.#lastDraggedOverIndex)
|
||||
? -1
|
||||
: this.#lastDraggedOverIndex;
|
||||
this.#endExternalFileDrag();
|
||||
if (file) {
|
||||
this.#mergeFile(file, insertAfter);
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
#endExternalFileDrag() {
|
||||
this.#externalDragActive = false;
|
||||
this.container.classList.remove("isDraggingFile");
|
||||
this.#dragMarker?.remove();
|
||||
this.#dragMarker = null;
|
||||
this.#lastDraggedOverIndex = NaN;
|
||||
}
|
||||
|
||||
#goToPage(e) {
|
||||
const container = e.target.closest(".thumbnailImageContainer");
|
||||
if (container) {
|
||||
|
||||
@ -78,16 +78,6 @@
|
||||
transform: rotate(270deg) translateX(-100%);
|
||||
}
|
||||
|
||||
#hiddenCopyElement,
|
||||
.hiddenCanvasElement {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pdfViewer {
|
||||
/* Define this variable here and not in :root to avoid to reflow all the UI
|
||||
when scaling (see #15929). */
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
/** @typedef {import("../src/display/api").PDFDocumentProxy} PDFDocumentProxy */
|
||||
/** @typedef {import("../src/display/api").PDFPageProxy} PDFPageProxy */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
|
||||
/** @typedef {import("../src/display/page_viewport").PageViewport} PageViewport */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/optional_content_config").OptionalContentConfig} OptionalContentConfig */
|
||||
/** @typedef {import("./event_utils").EventBus} EventBus */
|
||||
@ -132,6 +132,9 @@ function isValidAnnotationEditorMode(mode) {
|
||||
* `maxCanvasDim`, it will draw a second canvas on top of the CSS-zoomed one,
|
||||
* that only renders the part of the page that is close to the viewport.
|
||||
* The default value is `true`.
|
||||
* @property {boolean} [enableSelectionRendering] - Enables rendering of text
|
||||
* selections in the draw layer.
|
||||
* The default value is `true`.
|
||||
* @property {number} [imagesRightClickMinSize] - All images whose width and
|
||||
* height are at least this value (in pixels) will be lazily inserted in the
|
||||
* dom to allow right-clicking and saving them. Use `-1` to disable this.
|
||||
@ -362,6 +365,7 @@ class PDFViewer {
|
||||
this.enableDetailCanvas = options.enableDetailCanvas ?? true;
|
||||
this.enableOptimizedPartialRendering =
|
||||
options.enableOptimizedPartialRendering ?? false;
|
||||
this.enableSelectionRendering = options.enableSelectionRendering !== false;
|
||||
this.imagesRightClickMinSize = options.imagesRightClickMinSize ?? -1;
|
||||
this.l10n = options.l10n;
|
||||
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
|
||||
@ -457,6 +461,25 @@ class PDFViewer {
|
||||
return this._pages.every(pageView => pageView?.pdfPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear text selections within the viewer.
|
||||
*/
|
||||
clearSelection() {
|
||||
const selection = document.getSelection();
|
||||
if (!selection || selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0, ii = selection.rangeCount; i < ii; i++) {
|
||||
if (selection.getRangeAt(i).intersectsNode(this.viewer)) {
|
||||
// `empty()` is non-standard; `removeAllRanges()` is the standard API.
|
||||
selection.removeAllRanges?.();
|
||||
selection.empty?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
@ -488,6 +511,9 @@ class PDFViewer {
|
||||
if (!this.pdfDocument) {
|
||||
return;
|
||||
}
|
||||
if (this._currentPageNumber !== val) {
|
||||
this.clearSelection();
|
||||
}
|
||||
// The intent can be to just reset a scroll position and/or scale.
|
||||
if (!this._setCurrentPageNumber(val, /* resetCurrentPageView = */ true)) {
|
||||
console.error(`currentPageNumber: "${val}" is not a valid page.`);
|
||||
@ -547,6 +573,9 @@ class PDFViewer {
|
||||
page = i + 1;
|
||||
}
|
||||
}
|
||||
if (this._currentPageNumber !== page) {
|
||||
this.clearSelection();
|
||||
}
|
||||
// The intent can be to just reset a scroll position and/or scale.
|
||||
if (!this._setCurrentPageNumber(page, /* resetCurrentPageView = */ true)) {
|
||||
console.error(`currentPageLabel: "${val}" is not a valid page.`);
|
||||
@ -617,6 +646,7 @@ class PDFViewer {
|
||||
if (this._pagesRotation === rotation) {
|
||||
return; // The rotation didn't change.
|
||||
}
|
||||
this.clearSelection();
|
||||
this._pagesRotation = rotation;
|
||||
|
||||
const pageNumber = this._currentPageNumber;
|
||||
@ -967,6 +997,8 @@ class PDFViewer {
|
||||
const element = (this.#hiddenCopyElement =
|
||||
document.createElement("div"));
|
||||
element.id = "hiddenCopyElement";
|
||||
element.style.cssText =
|
||||
"position:absolute;top:0;left:0;width:0;height:0;display:none";
|
||||
viewer.before(element);
|
||||
}
|
||||
|
||||
@ -1064,6 +1096,7 @@ class PDFViewer {
|
||||
enableDetailCanvas: this.enableDetailCanvas,
|
||||
enableOptimizedPartialRendering:
|
||||
this.enableOptimizedPartialRendering,
|
||||
enableSelectionRendering: this.enableSelectionRendering,
|
||||
imagesRightClickMinSize: this.imagesRightClickMinSize,
|
||||
pageColors,
|
||||
l10n: this.l10n,
|
||||
@ -1486,6 +1519,7 @@ class PDFViewer {
|
||||
newValue,
|
||||
{ noScroll = false, preset = false, drawingDelay = -1, origin = null }
|
||||
) {
|
||||
this.clearSelection();
|
||||
this._currentScaleValue = newValue.toString();
|
||||
|
||||
if (this.#isSameScale(newScale)) {
|
||||
@ -2208,6 +2242,7 @@ class PDFViewer {
|
||||
}
|
||||
this._previousScrollMode = this._scrollMode;
|
||||
|
||||
this.clearSelection();
|
||||
this._scrollMode = mode;
|
||||
this.eventBus.dispatch("scrollmodechanged", { source: this, mode });
|
||||
|
||||
@ -2273,6 +2308,7 @@ class PDFViewer {
|
||||
if (!isValidSpreadMode(mode)) {
|
||||
throw new Error(`Invalid spread mode: ${mode}`);
|
||||
}
|
||||
this.clearSelection();
|
||||
this._spreadMode = mode;
|
||||
this.eventBus.dispatch("spreadmodechanged", { source: this, mode });
|
||||
|
||||
|
||||
@ -38,7 +38,6 @@ const {
|
||||
getRGB,
|
||||
getRGBA,
|
||||
getUuid,
|
||||
getXfaPageViewport,
|
||||
GlobalWorkerOptions,
|
||||
ImageKind,
|
||||
InvalidPDFException,
|
||||
@ -102,7 +101,6 @@ export {
|
||||
getRGB,
|
||||
getRGBA,
|
||||
getUuid,
|
||||
getXfaPageViewport,
|
||||
GlobalWorkerOptions,
|
||||
ImageKind,
|
||||
InvalidPDFException,
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getXfaPageViewport, PixelsPerInch } from "pdfjs-lib";
|
||||
import { PixelsPerInch, XfaLayer } from "pdfjs-lib";
|
||||
import { SimpleLinkService } from "./pdf_link_service.js";
|
||||
import { XfaLayerBuilder } from "./xfa_layer_builder.js";
|
||||
|
||||
@ -51,7 +51,7 @@ function getXfaHtmlForPrinting(printContainer, pdfDocument) {
|
||||
linkService,
|
||||
xfaHtml: xfaPage,
|
||||
});
|
||||
const viewport = getXfaPageViewport(xfaPage, { scale });
|
||||
const viewport = XfaLayer.getPageViewport(xfaPage, { scale });
|
||||
|
||||
builder.render({ viewport, intent: "print" });
|
||||
page.append(builder.div);
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
white-space: pre;
|
||||
cursor: text;
|
||||
transform-origin: 0% 0%;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* We multiply the font size by --min-font-size, and then scale the text
|
||||
@ -115,12 +116,7 @@
|
||||
}
|
||||
|
||||
::selection {
|
||||
/* stylelint-disable declaration-block-no-duplicate-properties */
|
||||
/*#if !MOZCENTRAL*/
|
||||
background: rgba(0 0 255 / 0.25);
|
||||
/*#endif*/
|
||||
/* stylelint-enable declaration-block-no-duplicate-properties */
|
||||
background: color-mix(in srgb, AccentColor, transparent 75%);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Avoids https://github.com/mozilla/pdf.js/issues/13840 in Chrome */
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
|
||||
/** @typedef {import("../src/display/api").PDFPageProxy} PDFPageProxy */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
|
||||
/** @typedef {import("../src/display/page_viewport").PageViewport} PageViewport */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/text_layer_images.js").TextLayerImages} TextLayerImages */
|
||||
/** @typedef {import("./text_highlighter").TextHighlighter} TextHighlighter */
|
||||
|
||||
@ -26,6 +26,20 @@ See https://github.com/adobe-type-tools/cmap-resources
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
<title>PDF.js viewer</title>
|
||||
|
||||
<!--
|
||||
The print service injects an inline <style>@page { size: … }</style>
|
||||
at print time (web/pdf_print_service.js, web/firefox_print_service.js)
|
||||
to match the PDF's page dimensions. Since the size varies per PDF the
|
||||
content can't be pre-hashed, so style-src-elem allows 'unsafe-inline'.
|
||||
Inline style="…" attributes stay blocked via style-src (no fallback).
|
||||
-->
|
||||
<!--#if MOZCENTRAL-->
|
||||
<!--<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src resource: 'wasm-unsafe-eval'; worker-src resource:; style-src resource:; style-src-elem resource: 'unsafe-inline'; img-src resource: blob: data:; font-src resource:; connect-src resource:; base-uri 'none'; form-action 'none';"
|
||||
/>-->
|
||||
<!--#endif-->
|
||||
|
||||
<!--#if MOZCENTRAL-->
|
||||
<!--#include viewer-snippet-firefox-extension.html-->
|
||||
<!--#endif-->
|
||||
|
||||
@ -4,19 +4,7 @@
|
||||
users with recognizing which checkbox they have to click when they
|
||||
visit chrome://extensions.
|
||||
-->
|
||||
<p
|
||||
id="chrome-pdfjs-logo-bg"
|
||||
style="
|
||||
display: block;
|
||||
padding-left: 60px;
|
||||
min-height: 48px;
|
||||
background-size: 48px;
|
||||
background-repeat: no-repeat;
|
||||
font-size: 14px;
|
||||
line-height: 1.8em;
|
||||
word-break: break-all;
|
||||
"
|
||||
>
|
||||
<p id="chrome-pdfjs-logo-bg">
|
||||
Click on "<span id="chrome-file-access-label">Allow access to file URLs</span>" at
|
||||
<a id="chrome-link-to-extensions-page">chrome://extensions</a>
|
||||
<br />
|
||||
|
||||
@ -713,6 +713,25 @@ dialog :link {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/*#if !MOZCENTRAL*/
|
||||
#printServiceDialog {
|
||||
min-width: 200px;
|
||||
}
|
||||
/*#endif*/
|
||||
|
||||
/*#if CHROME*/
|
||||
#chrome-pdfjs-logo-bg {
|
||||
display: block;
|
||||
padding-left: 60px;
|
||||
min-height: 48px;
|
||||
background-size: 48px;
|
||||
background-repeat: no-repeat;
|
||||
font-size: 14px;
|
||||
line-height: 1.8em;
|
||||
word-break: break-all;
|
||||
}
|
||||
/*#endif*/
|
||||
|
||||
.grab-to-pan-grab {
|
||||
cursor: grab !important;
|
||||
}
|
||||
|
||||
@ -29,6 +29,35 @@ See https://github.com/adobe-type-tools/cmap-resources
|
||||
<!--#endif-->
|
||||
<title>PDF.js viewer</title>
|
||||
|
||||
<!--
|
||||
The print service injects an inline <style>@page { size: … }</style>
|
||||
at print time (web/pdf_print_service.js, web/firefox_print_service.js)
|
||||
to match the PDF's page dimensions. Since the size varies per PDF the
|
||||
content can't be pre-hashed, so style-src-elem allows 'unsafe-inline'.
|
||||
Inline style="…" attributes stay blocked via style-src (no fallback).
|
||||
-->
|
||||
<!--#if MOZCENTRAL-->
|
||||
<!--<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src resource: 'wasm-unsafe-eval'; worker-src resource:; style-src resource:; style-src-elem resource: 'unsafe-inline'; img-src resource: blob: data:; font-src resource:; connect-src resource:; base-uri 'none'; form-action 'none';"
|
||||
/>-->
|
||||
<!--#elif TESTING-->
|
||||
<!--<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'; worker-src 'self' blob:; style-src 'self'; style-src-elem 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self' data:; connect-src * blob: data:; base-uri 'self'; form-action 'none';"
|
||||
/>-->
|
||||
<!--#elif CHROME-->
|
||||
<!--<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self'; style-src-elem 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self' data:; connect-src * blob: data:; base-uri 'self'; form-action 'none';"
|
||||
/>-->
|
||||
<!--#else-->
|
||||
<!--<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self'; style-src-elem 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self' data:; connect-src * blob: data:; base-uri 'none'; form-action 'none';"
|
||||
/>-->
|
||||
<!--#endif-->
|
||||
|
||||
<!--#if MOZCENTRAL-->
|
||||
<!--#include viewer-snippet-firefox-extension.html-->
|
||||
<!--#elif CHROME-->
|
||||
@ -1237,7 +1266,7 @@ See https://github.com/adobe-type-tools/cmap-resources
|
||||
</dialog>
|
||||
|
||||
<!--#if !MOZCENTRAL-->
|
||||
<dialog id="printServiceDialog" style="min-width: 200px">
|
||||
<dialog id="printServiceDialog">
|
||||
<div class="row">
|
||||
<span data-l10n-id="pdfjs-print-progress-message"></span>
|
||||
</div>
|
||||
|
||||
@ -632,14 +632,15 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .dragMarker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border: 2px solid var(--indicator-color);
|
||||
contain: strict;
|
||||
}
|
||||
&.isDragging > .dragMarker,
|
||||
&.isDraggingFile > .dragMarker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border: 2px solid var(--indicator-color);
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
&.pasteMode {
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/annotation_storage").AnnotationStorage} AnnotationStorage */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
|
||||
/** @typedef {import("../src/display/page_viewport").PageViewport} PageViewport */
|
||||
/** @typedef {import("./pdf_link_service.js").PDFLinkService} PDFLinkService */
|
||||
|
||||
import { XfaLayer } from "pdfjs-lib";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user