Compare commits

...

75 Commits

Author SHA1 Message Date
calixteman
25c7d9eaac
Merge pull request #21316 from mozilla/update-locales
l10n: Update locale files
2026-05-22 08:11:19 +02:00
github-actions[bot]
d8c08b980a l10n: Update locale files 2026-05-22 00:51:32 +00:00
calixteman
98e3a85a44
Merge pull request #21300 from calixteman/issue21298
Sanitize glyf composite cycles, OS/2 length and maxp version mismatches
2026-05-21 21:45:33 +02:00
Calixte Denizet
d6a2b91243 Sanitize glyf composite cycles, OS/2 length and maxp version mismatches
Prune the back-edge components from cyclic composite glyphs in
sanitizeGlyphLocations (leaving non-cyclic siblings intact), reject OS/2
tables whose length is too short for the declared version so a clean
table gets regenerated, and upgrade a version 0.5 maxp table to 1.0 for
TrueType fonts to silence OTS' "wrong maxp version for glyph data".

It fixes #21298.
2026-05-21 21:24:00 +02:00
Tim van der Meij
52d574c539
Merge pull request #21306 from calixteman/dnd_pdf_merging
Allow merging a PDF by dropping it onto the thumbnail viewer
2026-05-21 21:01:42 +02:00
Tim van der Meij
9b5cd3db64
Merge pull request #21304 from Snuffleupagus/PdfTextExtractor-tests
Add basic integration-tests for the `PdfTextExtractor` class
2026-05-21 20:27:53 +02:00
Tim van der Meij
93f01aa412
Merge pull request #21311 from Snuffleupagus/getPdfFilenameFromUrl-test-corrupt-relative
Extend unit-test coverage for the `getPdfFilenameFromUrl` helper function
2026-05-21 20:19:48 +02:00
Tim van der Meij
83c37357dc
Merge pull request #21302 from mozilla/dependabot/github_actions/github/codeql-action-4.35.4
Bump github/codeql-action from 4.35.3 to 4.35.4
2026-05-21 20:18:52 +02:00
Tim van der Meij
223170694c
Merge pull request #21301 from mozilla/dependabot/github_actions/actions/create-github-app-token-3.2.0
Bump actions/create-github-app-token from 3.1.1 to 3.2.0
2026-05-21 20:18:09 +02:00
Tim van der Meij
42db304268
Merge pull request #21305 from Snuffleupagus/integration-test-EventBus-on
Don't use "internal" `EventBus` methods in the integration-tests
2026-05-21 20:17:28 +02:00
calixteman
78cc2e3d38
Merge pull request #21309 from calixteman/issue21307
Fix 'Select all' after #20981
2026-05-21 18:11:56 +02:00
Calixte Denizet
0f90987927 Fix 'Select all' after #20981 2026-05-21 16:39:34 +02:00
Jonas Jenwald
74471651c7 Extend unit-test coverage for the getPdfFilenameFromUrl helper function
Currently there's a couple of branches, specifically for dealing with corrupt URLs, that are not covered by tests.
2026-05-21 14:03:32 +02:00
Calixte Denizet
d79043b3af Allow merging a PDF by dropping it onto the thumbnail viewer
Drop an external PDF anywhere in the views-manager thumbnail
sidebar to merge it at the cursor, rather than always inserting
after the current page via the "Add file" button.

The drop reuses the blue separator from page-move drag so the
user can see exactly where the inserted pages will land, and the
merge path is shared with the existing picker so post-merge
selection/current-page behavior stays consistent.
2026-05-20 18:06:55 +02:00
Jonas Jenwald
429b469ecb Add basic integration-tests for the PdfTextExtractor class 2026-05-20 17:26:50 +02:00
Jonas Jenwald
abe8b564a3 Don't use "internal" EventBus methods in the integration-tests
This way *guarantees* that any and all internal viewer state has been updated first, before any test-specific code runs.
2026-05-20 16:39:21 +02:00
calixteman
5a4d93a238
Merge pull request #20981 from wooorm/wooorm/hcm
Make text selection more visible (bug 1879559)
2026-05-20 15:49:01 +02:00
dependabot[bot]
16a9f1cafc
Bump github/codeql-action from 4.35.3 to 4.35.4
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.3 to 4.35.4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](e46ed2cbd0...68bde559de)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 23:20:52 +00:00
dependabot[bot]
5b99173043
Bump actions/create-github-app-token from 3.1.1 to 3.2.0
Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 3.1.1 to 3.2.0.
- [Release notes](https://github.com/actions/create-github-app-token/releases)
- [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md)
- [Commits](1b10c78c78...bcd2ba4921)

---
updated-dependencies:
- dependency-name: actions/create-github-app-token
  dependency-version: 3.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 23:19:08 +00:00
Tim van der Meij
b13ec1fc3c
Merge pull request #21299 from timvandermeij/disable-gpu
Disable hardware acceleration for Chrome tests
2026-05-19 22:19:32 +02:00
Titus Wormer
957e004e38
Make text selection more visible (bug 1879559)
References <https://bugzilla.mozilla.org/show_bug.cgi?id=1879559>
(“In HCM, the text selection is barely visible”).

Continues work from @calixteman who had a partial patch.

This PR improves viewer text-selection highlighting by rendering
selection shapes in the draw layer.

* add selection overlay rendering in the draw layer
  * significant code relates to selections spanning multiple text
    layers/pages, and edges/end-of-content boundaries
* clear selection on rotate/scale/scroll/spread changes

My main question is: how should it appear?
I don’t have access to the Figma file linked on bugzilla.

In the CSS (`draw_layer-builder.css`) there are 3 blocks:

* default
* `@supports` for browsers supporting `backdrop-filter`
* `forced-colors` mode

So it’s possible to design for those (or more).
Personally, the `backdrop-filter: invert(1)` is the most contrast,
so perhaps it’s better to use something else as the default,
and to use `invert(1)` if high contrast mode is used (maybe with a
`prefers-contrast` media query instead)?
2026-05-19 21:10:12 +02:00
Tim van der Meij
60591388a8
Disable hardware acceleration for Chrome tests
This commit fixes the rendering issue that makes the "must update an
existing annotation" ink editor integration test permafail locally in
Chrome. Note that we already do this for Firefox tests, so this also
improves consistency between the two browsers.

Moreover, improve how we define Chrome options to (similar to their
Firefox counterparts) provide them in a single array, and document the
reasoning for why these options are being set more explicitly.

Fixes #21272.
2026-05-19 21:07:26 +02:00
Jonas Jenwald
f500cffd2e
Merge pull request #21292 from timvandermeij/presentation-mode
Introduce integration tests for the presentation mode functionality
2026-05-19 13:29:45 +02:00
Tim van der Meij
0d69cc4dcf
Introduce integration tests for the presentation mode functionality
This commit provides coverage for the happy flows of basic operations
like entering/exiting and changing pages.
2026-05-18 20:52:00 +02:00
Tim van der Meij
00af75905f
Merge pull request #21295 from Snuffleupagus/PDFDocumentProperties-more-tests
Add more integration-tests, with multi-page documents, for the `PDFDocumentProperties` dialog
2026-05-18 20:24:41 +02:00
Tim van der Meij
9ebee868c9
Merge pull request #21294 from Snuffleupagus/mv-scaleMinMax
Move the `Util.scaleMinMax` helper into `src/display/canvas_dependency_tracker.js`
2026-05-18 20:18:31 +02:00
calixteman
abb8e31408
Merge pull request #21261 from calixteman/bug1960363
Add a Content-Security-Policy to pdf.js' viewer.html (bug 1960363)
2026-05-18 18:24:38 +02:00
Jonas Jenwald
1e62f01773 Improve handling of corrupt pages in the PDFDocumentProperties dialog
If the active page is corrupt that currently results in the entire dialog being "blank", thus providing no information, which seems unfortunate and it's easy enough to only skip `pageSizeField` in that rare case.
2026-05-18 17:38:43 +02:00
Jonas Jenwald
e05b6d6f59 Add more integration-tests, with multi-page documents, for the PDFDocumentProperties dialog
Improve test coverage for multi-page documents, to ensure that:
 - Unnecessary re-parsing is avoided where possible.
 - Rotation, in the viewer, is handled correctly.
 - Different page sizes are handled correctly.
2026-05-18 14:24:05 +02:00
Jonas Jenwald
1e9e8fad7e Add helper functions for opening/closing the document properties dialog during testing
This reduces duplication in the various integration-tests.
2026-05-18 13:31:26 +02:00
Jonas Jenwald
d1da73931a Move the Util.scaleMinMax helper into src/display/canvas_dependency_tracker.js
This method is completely unused in the worker-thread, and it only has a single call-site in the main-thread.
By moving this helper into the `src/display/canvas_dependency_tracker.js` file, the size of the `gulp mozcentral` bundle is reduced by `1220` bytes.
2026-05-18 11:41:37 +02:00
calixteman
deb532334f
Merge pull request #21290 from calixteman/issue20633
Recover CFF private dict defaults zeroed by Ghostscript
2026-05-17 21:57:16 +02:00
calixteman
cd8a78c4e2
Recover CFF private dict defaults zeroed by Ghostscript
It fixes the issue #20633.
2026-05-17 20:51:35 +02:00
Tim van der Meij
2fb2bc13e0
Merge pull request #21275 from timvandermeij/integration-tests-optimize
Optimize runtime and memory usage of the integration tests
2026-05-17 20:41:43 +02:00
Tim van der Meij
f2bf57f444
Merge pull request #21288 from timvandermeij/unskip-test
Enable the `must check that an existing highlight is ignored on hovering` integration test on Windows
2026-05-17 20:19:42 +02:00
Tim van der Meij
b942714f45
Merge pull request #21291 from Snuffleupagus/PDFDocumentProperties-undef-contentLength-test
Add an integration-test for documents without `contentLength` available in the `PDFDocumentProperties` dialog
2026-05-17 18:31:19 +02:00
Jonas Jenwald
2cef900834 Add an integration-test for documents without contentLength available in the PDFDocumentProperties dialog 2026-05-17 16:55:48 +02:00
Tim van der Meij
e98b43879e
Merge pull request #21289 from Snuffleupagus/PDFDocumentProperties-approx-size-test
Add an integration-test for the page-name fuzzy matching in the `PDFDocumentProperties` dialog
2026-05-17 15:23:49 +02:00
Tim van der Meij
af65d7f930
Enable the must check that an existing highlight is ignored on hovering integration test on Windows
It looks like this test passes consistently again, most likely after a
combination of browser/Puppeteer/configuration updates and the completed
switch to the WebDriver BiDi protocol.

Fixes #20136.
2026-05-17 14:47:14 +02:00
Jonas Jenwald
afa9b6ef9b Add an integration-test for the page-name fuzzy matching in the PDFDocumentProperties dialog
Also, test that closing the dialog works correctly.
2026-05-17 14:40:09 +02:00
Tim van der Meij
f290da0e4b
Merge pull request #21287 from Snuffleupagus/rm-_normalizePagePoint
Remove the unused `Outline._normalizePagePoint` method
2026-05-17 12:47:57 +02:00
Jonas Jenwald
036436a0be Remove the unused Outline._normalizePagePoint method
This method was added in PR 19093, back in 2024, however it never actually appears to have been used.
2026-05-16 23:17:10 +02:00
Tim van der Meij
600a4bb1ee
Fix missing page closing for two viewer integration tests
This caused the tab to remain open after the tests ran, which meant
that a total of ~120 MB of memory was not being freed.
2026-05-16 19:30:42 +02:00
Tim van der Meij
c66f9f2497
Reduce the protocol delay for Chrome in the integration tests
Originally we introduced a small delay for Puppeteer operations in
Chrome to avoid intermittent failures where protocol calls were
happening too quickly in succession. However, since then a number of
improvements were made, both locally and upstream, that reduce the need
for this delay:

- the integration tests have been hardened to remove (potential) sources
  of intermittent failures in many places;
- the browsers and Puppeteer have been updated to improve performance
  and support for testing infrastructure;
- the conversion to WebDriver BiDi has been completed, which replaced
  the Chrome-specific CDP protocol with a formalized protocol that could
  provide more safety guarantees.

This commit therefore reduces the Chrome-specific delay from 5 to 3
milliseconds, which should nowadays be a better value to speed up the
Chrome tests and bring them closer to Firefox in terms of runtime.
2026-05-16 19:30:42 +02:00
Tim van der Meij
62b88aa56e
Use Puppeteer's ElementHandle.boundingBox() API in the integration tests
The custom solution for obtaining the bounding box of a given element
that we have now was necessary during the original introduction of the
integration tests because at the time the `ElementHandle.boundingBox()`
API in Puppeteer didn't work correctly in Chrome.

However, `getRect`, where this is used, is a hot utility function
because most tests call it multiple times, either directly or indirectly
via other utility functions, and it turns out that the approach we use
is slower than the native `ElementHandle.boundingBox()` API.

Fortunately, most likely after a combination of Chrome/Puppeteer updates
and the conversion to the formalized WebDriver BiDi protocol the custom
solution is no longer necessary because all tests pass without it too,
so this commit converts `getRect` to use `ElementHandle.boundingBox()`
instead to speed up the tests.
2026-05-16 19:30:34 +02:00
Tim van der Meij
65b8aec420
Merge pull request #21286 from Snuffleupagus/ColorConverters-tests
Add a couple of very basic `ColorConverters` unit-tests
2026-05-16 18:11:16 +02:00
Jonas Jenwald
7f2bb0e991 Add a couple of very basic ColorConverters unit-tests
These tests could obviously be improved/extended, but it's at least a start to ensure that `ColorConverters` is tested since it's used in both the annotation-layer and the scripting-implementation.
2026-05-16 17:17:41 +02:00
Tim van der Meij
3450e95179
Merge pull request #21284 from Snuffleupagus/mv-SVG_NS
Move the `SVG_NS` definition into `src/shared/util.js`
2026-05-16 16:03:10 +02:00
Jonas Jenwald
7c5087cc16 Move the SVG_NS definition into src/shared/util.js
This constant is already defined in both the `src/core/` and `src/display/` folders, and in a few spots the same string was also inlined.
2026-05-16 15:17:04 +02:00
Jonas Jenwald
d27b9ab5fa
Merge pull request #21283 from Snuffleupagus/mv-getXfaPageViewport
[api-minor] Move the `getXfaPageViewport` helper into the `XfaLayer` class
2026-05-16 14:18:22 +02:00
Jonas Jenwald
e8f07d7ca3 [api-minor] Move the getXfaPageViewport helper into the XfaLayer class
This small helper function only exists to support printing of XFA documents, in the viewer, hence it seems like a good idea to (ever so slightly) reduce the official API surface a little bit.
2026-05-16 12:31:53 +02:00
Jonas Jenwald
eda97fe8fc Move the PageViewport class into its own file
This is necessary to prevent import cycles with the next patch.

It also shouldn't hurt to reduce the size of `src/display/display_utils.js` a little bit, since utility-files have a tendency to increase in size over time.
2026-05-16 12:31:40 +02:00
Tim van der Meij
26474b09cb
Merge pull request #21277 from calixteman/issue21276
Clear the full SMask scratch canvas in compose()
2026-05-15 20:04:11 +02:00
Tim van der Meij
77a2dc8532
Merge pull request #21281 from Snuffleupagus/rm-domMatrixToTransform
Remove the `Util.domMatrixToTransform` method
2026-05-15 20:03:19 +02:00
Jonas Jenwald
367f994d94 Remove the Util.domMatrixToTransform method
This method is unused in the worker-thread and has only *a single* call-site in the main-thread, which can be trivially replaced with the `getCurrentTransform` helper function.
2026-05-15 15:07:44 +02:00
Jonas Jenwald
69efba1ca2
Merge pull request #21279 from Snuffleupagus/mv-stringToPDFString
Move the `stringToPDFString` helper function to the worker-thread
2026-05-15 15:01:15 +02:00
Jonas Jenwald
e5330f06fa Move the stringToPDFString helper function into the src/core/string_utils.js file
Given that this function is only ever used during *parsing* of the PDF document, which happens in the worker-thread, this has always added (a little bit of) dead code in the built `pdf.mjs` file.
2026-05-15 12:10:30 +02:00
Jonas Jenwald
7a7e7049c1 Shorten the isAscii helper function a tiny bit 2026-05-15 11:56:33 +02:00
Jonas Jenwald
153cef615e Move a couple of src/core/ string helper functions into their own file
Given that the various utility-files naturally increase in size over time, it shouldn't hurt to shorten `src/core/core_utils.js` a little bit by moving a few of its string helper functions to their own file.
2026-05-15 11:49:54 +02:00
calixteman
d9491ffce3
Merge pull request #21278 from mozilla/update-locales
l10n: Update locale files
2026-05-15 09:34:50 +02:00
github-actions[bot]
0fab33c2e6 l10n: Update locale files 2026-05-15 00:47:51 +00:00
calixteman
5e18cfd8f0
Clear the full SMask scratch canvas in compose()
PR #21101 narrowed `compose()`'s `clearRect` from full canvas to the
caller-supplied dirty box. That leaves pixels outside the current
dirty box on the SMask scratch canvas between `compose()` calls;
subsequent draws into scratch are then source-over-blended on top of
those leftovers, so the output depends on the cumulative draw history
rather than just the current draw.
2026-05-14 23:02:14 +02:00
calixteman
cd4fd7563c
Merge pull request #21218 from RolandWArnold/fix/text-layer-hidden-canvas-layout-neutral
Ensure TextLayer hiddenCanvasElement is layout-neutral by default
2026-05-14 22:11:06 +02:00
Tim van der Meij
d9665c4e0f
Merge pull request #21274 from calixteman/issue21273
Enable Codecov flag carryforward for accurate coverage badge
2026-05-14 18:26:11 +02:00
calixteman
195bfdcfbd
Enable Codecov flag carryforward for accurate coverage badge
It fixes #21273.
2026-05-14 18:13:11 +02:00
Tim van der Meij
b708f59d04
Merge pull request #21271 from Snuffleupagus/unittest-BrotliDecode
Add a simple API unit-test for /BrotliDecode
2026-05-14 16:05:34 +02:00
Jonas Jenwald
949497a3c4 Add a simple API unit-test for /BrotliDecode
This is a new feature in PDF documents, hence it shouldn't hurt to complement the existing ref-test with a simple unit-test as well.
This should also improve test coverage for the `external/` folder, which can't hurt since the other external decoders are already fairly well covered.
2026-05-14 15:44:42 +02:00
Roland Arnold
16d82e094f fix: inline styles for hidden helper elements
Set layout-neutral styles at the creation sites for the hidden TextLayer
canvas and PDFViewer copy element rather than relying on the shared
web/pdf_viewer.css rule.

This keeps the helper elements invisible and out of layout when viewer CSS
selectors are scoped or omitted, and removes the obsolete hiddenCanvasElement
class and shared CSS rule.
2026-05-14 14:28:41 +01:00
Tim van der Meij
7ade637449
Merge pull request #21268 from calixteman/lint_chmod
Add a lint-chmod task to catch stray executable bits
2026-05-14 15:16:08 +02:00
Calixte Denizet
f8f497a03a
Add a lint-chmod task to catch stray executable bits 2026-05-14 13:55:14 +02:00
Tim van der Meij
056837dace
Merge pull request #21173 from timvandermeij/github-actions-integration-tests-coverage
Collect coverage information for the integration tests
2026-05-14 13:40:05 +02:00
Tim van der Meij
71e9eb25f6
Merge pull request #21269 from Snuffleupagus/WORKER_THREAD-define
Avoid bundling DOM-related code in the built `pdf.worker.mjs` file
2026-05-14 13:02:08 +02:00
Calixte Denizet
94de952b65
Add a Content-Security-Policy to pdf.js' viewer.html (bug 1960363) 2026-05-14 12:50:07 +02:00
Tim van der Meij
26dc195a65
Collect coverage information for the integration tests
Note that for the integration tests the coverage information ends up
being processed in the Node.js context where `window` is not available,
so we use `globalThis` instead for the function that merges individual
test's coverage information into the global object because that is
available in all contexts we support. For clarity we also rename said
function since we're not exclusively dealing with `window` nor worker
data anymore.
2026-05-14 12:34:12 +02:00
Jonas Jenwald
204f9203bd Avoid bundling DOM-related code in the built pdf.worker.mjs file
Currently the `isCanvasFilterSupported`/`isAlphaColorInputSupported` getters, on the `FeatureTest` class, contains code that cannot run in the worker-thread since it relies on the DOM being available.
To avoid that a new DEFINE is added, in `gulpfile.mjs`, to allow skipping this sort of dead code in the built `pdf.worker.mjs` file.
2026-05-14 12:21:36 +02:00
93 changed files with 4116 additions and 815 deletions

View File

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

View File

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

View File

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

View File

@ -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
View File

@ -0,0 +1,3 @@
flag_management:
default_rules:
carryforward: true

0
external/iccs/CGATS001Compat-v2-micro.icc vendored Executable file → Normal file
View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = ਸਫ਼ਿਆਂ ਦਾ ਇੰਤਜ਼ਾਮ

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

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

View File

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

View File

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

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

View File

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

View File

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

View 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 8393. ACM\nPress, 2007."
)
).toBeTrue();
expect(text.length).toEqual(82804);
expect(requestId).toEqual(2);
})
);
});
});
});

View File

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

View File

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

View File

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

View File

@ -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, heres 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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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");
});
});
});

View 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鷗Œé逍Üߪąñ丂㐀𠀀"
);
});
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");
});
});
});

View File

@ -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鷗Œé逍Üߪąñ丂㐀𠀀"
);
});
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);

View File

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

View File

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

View File

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

View File

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

View File

@ -320,6 +320,11 @@ const defaultOptions = {
: "./images/",
kind: OptionKind.VIEWER,
},
enableSelectionRendering: {
/** @type {boolean} */
value: true,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
imagesRightClickMinSize: {
/** @type {number} */
value:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,7 +38,6 @@ const {
getRGB,
getRGBA,
getUuid,
getXfaPageViewport,
GlobalWorkerOptions,
ImageKind,
InvalidPDFException,
@ -102,7 +101,6 @@ export {
getRGB,
getRGBA,
getUuid,
getXfaPageViewport,
GlobalWorkerOptions,
ImageKind,
InvalidPDFException,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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