├── .formatter.exs ├── .github ├── docker │ ├── Dockerfile_alpine-3-17-3 │ ├── Dockerfile_alpine-3-18-4 │ ├── Dockerfile_debian-buster │ ├── Dockerfile_elixir-1-11 │ ├── README.md │ └── auto-install.xml └── workflows │ ├── lint.yaml │ ├── publish-image.yml │ └── test.yaml ├── .gitignore ├── .plts └── .keep ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── COPYRIGHT ├── icon.png ├── icon.svg ├── logo.svg └── social.png ├── config └── config.exs ├── lib ├── chromic_pdf.ex ├── chromic_pdf │ ├── api.ex │ ├── api │ │ ├── chrome_error.ex │ │ ├── export_options.ex │ │ ├── protocol_options.ex │ │ └── telemetry.ex │ ├── pdf │ │ ├── browser.ex │ │ ├── browser │ │ │ ├── channel.ex │ │ │ ├── execution_error.ex │ │ │ ├── session_pool.ex │ │ │ └── session_pool_config.ex │ │ ├── chrome_runner.ex │ │ ├── connection.ex │ │ ├── connection │ │ │ ├── connection_lost_error.ex │ │ │ ├── inet.ex │ │ │ ├── local.ex │ │ │ └── tokenizer.ex │ │ ├── json_rpc.ex │ │ ├── protocol.ex │ │ ├── protocol_macros.ex │ │ └── protocols │ │ │ ├── capture_screenshot.ex │ │ │ ├── close_target.ex │ │ │ ├── navigate.ex │ │ │ ├── print_to_pdf.ex │ │ │ ├── reset_target.ex │ │ │ └── spawn_session.ex │ ├── pdfa │ │ ├── PDFA_def.ps.eex │ │ ├── ghostscript_pool.ex │ │ ├── ghostscript_runner.ex │ │ └── ghostscript_worker.ex │ ├── plug.ex │ ├── supervisor.ex │ ├── template.ex │ └── utils.ex └── mix │ └── tasks │ └── chromic_pdf.warm_up.ex ├── mix.exs ├── mix.lock ├── priv ├── blank.html ├── eciRGB_v2.icc └── pdfinfo.ps └── test ├── integration ├── connection_lost_test.exs ├── custom_protocol_test.exs ├── dynamic_name_test.exs ├── fixtures │ ├── CREDITS │ ├── cert.pem │ ├── cert_key.pem │ ├── chrome-seccomp.json │ ├── embed_xml.ps.eex │ ├── image_with_text.svg │ ├── large.html │ ├── test.html │ ├── test_dynamic.html │ ├── test_exception.html │ └── zugferd-invoice.xml ├── inet_connection_test.exs ├── on_demand_test.exs ├── pdf_generation_test.exs ├── pdfa_generation_test.exs ├── plug_test.exs ├── screenshot_test.exs ├── session_pool_test.exs ├── support │ ├── assertions.ex │ ├── case.ex │ ├── get_targets.ex │ ├── test_api.ex │ ├── test_docker_chrome.ex │ ├── test_inet_chrome.ex │ └── test_server.ex ├── telemetry_test.exs ├── template_test.exs ├── warm_up_test.exs └── zugferd_test.exs ├── test_helper.exs └── unit └── chromic_pdf ├── pdf ├── chrome_runner_test.exs ├── connection │ └── tokenizer_test.exs └── protocol_test.exs └── utils_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/docker/Dockerfile_alpine-3-17-3: -------------------------------------------------------------------------------- 1 | FROM hexpm/elixir:1.14.5-erlang-25.3.1-alpine-3.17.3 2 | 3 | USER root 4 | 5 | RUN apk update \ 6 | && apk add --no-cache \ 7 | chromium \ 8 | # Will install ghostscript 10.0 9 | ghostscript \ 10 | # for verapdf & ZUV 11 | openjdk11-jre \ 12 | # for pdftotext & friends 13 | poppler-utils \ 14 | # for identifying images 15 | imagemagick \ 16 | # for 'kill' 17 | procps \ 18 | # temporary for installation below 19 | wget \ 20 | unzip \ 21 | # GNU tar needed by actions/cache 22 | tar 23 | 24 | RUN mkdir /opt/verapdf 25 | WORKDIR /opt/verapdf 26 | RUN wget http://downloads.verapdf.org/rel/verapdf-installer.zip \ 27 | && unzip verapdf-installer.zip \ 28 | && mv verapdf-greenfield* verapdf-greenfield \ 29 | && chmod +x verapdf-greenfield/verapdf-install 30 | COPY .github/docker/auto-install.xml /opt/verapdf/verapdf-greenfield 31 | RUN ./verapdf-greenfield/verapdf-install auto-install.xml 32 | 33 | WORKDIR /opt/zuv 34 | RUN wget https://github.com/ZUGFeRD/ZUV/releases/download/v0.8.3/ZUV-0.8.3.jar 35 | ENV ZUV_JAR /opt/zuv/ZUV-0.8.3.jar 36 | 37 | RUN apk del \ 38 | wget \ 39 | unzip \ 40 | && rm -rf /var/lib/apt/lists/* 41 | -------------------------------------------------------------------------------- /.github/docker/Dockerfile_alpine-3-18-4: -------------------------------------------------------------------------------- 1 | FROM hexpm/elixir:1.15.7-erlang-26.2-alpine-3.18.4 2 | 3 | USER root 4 | 5 | RUN apk update \ 6 | && apk add --no-cache \ 7 | # Will install chromium 119.0.6045.159-r0 & ghostscript 10.02.0 8 | chromium \ 9 | ghostscript \ 10 | # for verapdf & ZUV 11 | openjdk11-jre \ 12 | # for pdftotext & friends 13 | poppler-utils \ 14 | # for identifying images 15 | imagemagick \ 16 | # for 'kill' 17 | procps \ 18 | # temporary for installation below 19 | wget \ 20 | unzip \ 21 | # GNU tar needed by actions/cache 22 | tar 23 | 24 | RUN mkdir /opt/verapdf 25 | WORKDIR /opt/verapdf 26 | RUN wget http://downloads.verapdf.org/rel/verapdf-installer.zip \ 27 | && unzip verapdf-installer.zip \ 28 | && mv verapdf-greenfield* verapdf-greenfield \ 29 | && chmod +x verapdf-greenfield/verapdf-install 30 | COPY .github/docker/auto-install.xml /opt/verapdf/verapdf-greenfield 31 | RUN ./verapdf-greenfield/verapdf-install auto-install.xml 32 | 33 | WORKDIR /opt/zuv 34 | RUN wget https://github.com/ZUGFeRD/ZUV/releases/download/v0.8.3/ZUV-0.8.3.jar 35 | ENV ZUV_JAR /opt/zuv/ZUV-0.8.3.jar 36 | 37 | RUN apk del \ 38 | wget \ 39 | unzip \ 40 | && rm -rf /var/lib/apt/lists/* 41 | -------------------------------------------------------------------------------- /.github/docker/Dockerfile_debian-buster: -------------------------------------------------------------------------------- 1 | FROM hexpm/elixir:1.14.0-erlang-25.1-debian-buster-20220801 2 | 3 | USER root 4 | 5 | RUN apt-get update \ 6 | && apt-get install -y --no-install-recommends \ 7 | chromium \ 8 | chromium-sandbox \ 9 | ghostscript \ 10 | # for verapdf & ZUV 11 | openjdk-11-jre \ 12 | # for pdftotext & friends 13 | poppler-utils \ 14 | # for identifying images 15 | imagemagick \ 16 | # for 'kill' 17 | procps \ 18 | # temporary for installation below 19 | wget \ 20 | unzip 21 | 22 | RUN mkdir /opt/verapdf 23 | WORKDIR /opt/verapdf 24 | RUN wget http://downloads.verapdf.org/rel/verapdf-installer.zip \ 25 | && unzip verapdf-installer.zip \ 26 | && mv verapdf-greenfield* verapdf-greenfield \ 27 | && chmod +x verapdf-greenfield/verapdf-install 28 | COPY .github/docker/auto-install.xml /opt/verapdf/verapdf-greenfield 29 | RUN ./verapdf-greenfield/verapdf-install auto-install.xml 30 | 31 | WORKDIR /opt/zuv 32 | RUN wget https://github.com/ZUGFeRD/ZUV/releases/download/v0.8.3/ZUV-0.8.3.jar 33 | ENV ZUV_JAR /opt/zuv/ZUV-0.8.3.jar 34 | 35 | RUN apt-get remove -y \ 36 | wget \ 37 | unzip \ 38 | && apt-get autoremove -y \ 39 | && rm -rf /var/lib/apt/lists/* 40 | -------------------------------------------------------------------------------- /.github/docker/Dockerfile_elixir-1-11: -------------------------------------------------------------------------------- 1 | FROM hexpm/elixir:1.11.4-erlang-22.3.4.26-debian-buster-20210902 2 | 3 | USER root 4 | 5 | RUN apt-get update \ 6 | && apt-get install -y --no-install-recommends \ 7 | chromium \ 8 | chromium-sandbox \ 9 | ghostscript \ 10 | # for verapdf & ZUV 11 | openjdk-11-jre \ 12 | # for pdftotext & friends 13 | poppler-utils \ 14 | # for identifying images 15 | imagemagick \ 16 | # for 'kill' 17 | procps \ 18 | # temporary for installation below 19 | wget \ 20 | unzip 21 | 22 | RUN mkdir /opt/verapdf 23 | WORKDIR /opt/verapdf 24 | RUN wget http://downloads.verapdf.org/rel/verapdf-installer.zip \ 25 | && unzip verapdf-installer.zip \ 26 | && mv verapdf-greenfield* verapdf-greenfield \ 27 | && chmod +x verapdf-greenfield/verapdf-install 28 | COPY .github/docker/auto-install.xml /opt/verapdf/verapdf-greenfield 29 | RUN ./verapdf-greenfield/verapdf-install auto-install.xml 30 | 31 | WORKDIR /opt/zuv 32 | RUN wget https://github.com/ZUGFeRD/ZUV/releases/download/v0.8.3/ZUV-0.8.3.jar 33 | ENV ZUV_JAR /opt/zuv/ZUV-0.8.3.jar 34 | 35 | RUN apt-get remove -y \ 36 | wget \ 37 | unzip \ 38 | && apt-get autoremove -y \ 39 | && rm -rf /var/lib/apt/lists/* 40 | 41 | -------------------------------------------------------------------------------- /.github/docker/README.md: -------------------------------------------------------------------------------- 1 | # How to update 2 | 3 | ## Build new image for the CI 4 | 5 | - Create a new Dockerfile, named `Dockerfile_${relevant suffix(es)}` 6 | - Edit `.github/workflows/publish-image.yml` and add the suffixes to the `strategy.matrix.dockerfile` list 7 | - Open your PR and merge it. 8 | - On push to `main`, the workflow `publish-image` should be triggered, and you should see its status in the Github Actions tab. If it's green, your image will be available in the [package registry](https://github.com/bitcrowd/chromic_pdf/pkgs/container/chromic_pdf) 9 | 10 | ## Build new image for local test 11 | 12 | ``` 13 | 14 | docker build -t bitcrowd/chromic_pdf-test-image:x.y.z -f .github/docker/[DOCKERFILE] . 15 | ``` 16 | 17 | ## Test the image 18 | 19 | By method of your choice. Disable the default seccomp profile. This will work for example: 20 | 21 | ``` 22 | docker run -it -v $(pwd):/src --rm --security-opt seccomp=unconfined bitcrowd/chromic_pdf-test-image:x.y.z 23 | ``` 24 | 25 | Inside container: 26 | 27 | ``` 28 | # there are other means of getting files into the container, but this is simple enough 29 | $ cp -r /src . 30 | $ cd src 31 | $ mix deps.get 32 | $ mix test 33 | ``` 34 | -------------------------------------------------------------------------------- /.github/docker/auto-install.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | /usr/local/bin 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: [pull_request] 3 | jobs: 4 | test: 5 | name: Lint 6 | env: 7 | MIX_ENV: test 8 | runs-on: ubuntu-latest 9 | container: 10 | image: ghcr.io/bitcrowd/chromic_pdf:alpine-3-17-3 11 | credentials: 12 | username: ${{ github.actor }} 13 | password: ${{ secrets.GITHUB_TOKEN }} 14 | options: --privileged --user 1001 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Dependencies cache 18 | id: deps-cache 19 | uses: actions/cache@v3 20 | with: 21 | path: deps 22 | key: ${{ runner.os }}-deps-v1-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 23 | - name: Build artifacts cache 24 | id: build-and-plts-cache 25 | uses: actions/cache@v3 26 | with: 27 | path: | 28 | _build 29 | .plts 30 | key: ${{ runner.os }}-build-and-plts-v1-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 31 | - run: mix local.hex --force && mix local.rebar 32 | - run: mix deps.get 33 | - run: mix compile 34 | - run: mix lint 35 | -------------------------------------------------------------------------------- /.github/workflows/publish-image.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/actions/publishing-packages/publishing-docker-images 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | # GitHub recommends pinning actions to a commit SHA. 9 | # To get a newer version, you will need to update the SHA. 10 | # You can also reference a tag or branch, but the action may change without warning. 11 | 12 | name: Create and publish a Docker image to Github Packages 13 | 14 | on: 15 | push: 16 | branches: ['main'] 17 | paths: 18 | - ".github/workflows/publish-image.yml" 19 | - ".github/docker/*" 20 | 21 | env: 22 | REGISTRY: ghcr.io 23 | IMAGE_NAME: ${{ github.repository }} 24 | 25 | jobs: 26 | build-and-push-image: 27 | name: Build 'Dockerfile_${{ matrix.dockerfile }}' and publish to Github Packages 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: read 31 | packages: write 32 | strategy: 33 | matrix: 34 | dockerfile: ['elixir-1-11', 'debian-buster', 'alpine-3-17-3', 'alpine-3-18-4'] 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v3 39 | 40 | - uses: dorny/paths-filter@v2 41 | id: paths-filter 42 | with: 43 | filters: | 44 | dockerfile: 45 | - .github/docker/Dockerfile_${{matrix.dockerfile}} 46 | 47 | - name: Log in to the Container registry 48 | if: steps.paths-filter.outputs.dockerfile == 'true' 49 | uses: docker/login-action@v3 50 | with: 51 | registry: ${{ env.REGISTRY }} 52 | username: ${{ github.actor }} 53 | password: ${{ secrets.GITHUB_TOKEN }} 54 | 55 | - name: Build and push Docker image 56 | if: steps.paths-filter.outputs.dockerfile == 'true' 57 | uses: docker/build-push-action@v5 58 | with: 59 | context: . 60 | file: .github/docker/Dockerfile_${{matrix.dockerfile}} 61 | tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ matrix.dockerfile }} 62 | push: true 63 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [pull_request] 3 | jobs: 4 | test: 5 | name: ${{ matrix.tag }} 6 | strategy: 7 | matrix: 8 | tag: ['elixir-1-11', 'debian-buster', 'alpine-3-17-3', 'alpine-3-18-4'] 9 | env: 10 | MIX_ENV: test 11 | runs-on: ubuntu-latest 12 | container: 13 | image: ghcr.io/bitcrowd/chromic_pdf:${{ matrix.tag }} 14 | credentials: 15 | username: ${{ github.actor }} 16 | password: ${{ secrets.GITHUB_TOKEN }} 17 | options: --privileged --user 1001 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Dependencies cache 21 | id: deps-cache 22 | uses: actions/cache@v3 23 | with: 24 | path: deps 25 | key: ${{ runner.os }}-${{ matrix.tag }}-deps-v1-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 26 | - name: Build artifacts cache 27 | id: build-cache 28 | uses: actions/cache@v3 29 | with: 30 | path: _build 31 | key: ${{ runner.os }}-${{ matrix.tag }}-build-v1-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 32 | - run: mix local.hex --force && mix local.rebar 33 | - run: mix deps.get 34 | - run: mix compile 35 | - run: mix chromic_pdf.warm_up 36 | # only retry failed tests if the tests ran and actually failed (exit status 2), and don't retry if e.g. they didn't compile 37 | - run: mix test || if [[ $? = 2 ]]; then mix test --failed || if [[ $? = 2 ]]; then mix test --failed; else false; fi; else false; fi 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | chromic_pdf-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # Misc. 29 | /log/ 30 | /.plts/* 31 | !/.plts/.keep 32 | -------------------------------------------------------------------------------- /.plts/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/chromic_pdf/225ed053a6ccece2f9599d9330590220e5d83d30/.plts/.keep -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | # locked to the versions we use in the lint CI job 2 | elixir 1.14.5 3 | erlang 25.3.1 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](assets/logo.svg) 2 | 3 | [![CircleCI](https://circleci.com/gh/bitcrowd/chromic_pdf.svg?style=shield)](https://circleci.com/gh/bitcrowd/chromic_pdf) 4 | [![Module Version](https://img.shields.io/hexpm/v/chromic_pdf.svg)](https://hex.pm/packages/chromic_pdf) 5 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/chromic_pdf/) 6 | [![Total Download](https://img.shields.io/hexpm/dt/chromic_pdf.svg)](https://hex.pm/packages/chromic_pdf) 7 | [![License](https://img.shields.io/hexpm/l/chromic_pdf.svg)](https://github.com/bitcrowd/chromic_pdf/blob/master/LICENSE) 8 | [![Last Updated](https://img.shields.io/github/last-commit/bitcrowd/chromic_pdf.svg)](https://github.com/bitcrowd/chromic_pdf/commits/master) 9 | 10 | ChromicPDF is a HTML-to-PDF renderer for Elixir, based on headless Chrome. 11 | 12 | ## Features 13 | 14 | * **Node-free**: In contrast to [many other](https://hex.pm/packages?search=pdf&sort=recent_downloads) packages, it does not use [puppeteer](https://github.com/puppeteer/puppeteer), and hence does not require Node.js. It communicates directly with Chrome's [DevTools API](https://chromedevtools.github.io/devtools-protocol/) over pipes, offering the same performance as puppeteer, if not better. 15 | * **Header/Footer**: Using the DevTools API allows to apply the full set of options of the [`printToPDF`](https://chromedevtools.github.io/devtools-protocol/tot/Page#method-printToPDF) function. Most notably, it supports header and footer HTML templates. 16 | * **PDF/A**: It can convert printed files to PDF/A using Ghostscript. Converted files pass the [verapdf](https://verapdf.org/) validator. 17 | 18 | ## Requirements 19 | 20 | - Chromium or Chrome 21 | - Ghostscript (optional, for PDF/A support and concatenation of multiple sources) 22 | 23 | ChromicPDF is tested in the following configurations: 24 | 25 | | Elixir | Erlang/OTP | Distribution | Chromium | Ghostscript | 26 | | ------ | ---------- | --------------- | --------------- | ----------- | 27 | | 1.15.7 | 26.2 | Alpine 3.18 | 119.0.6045.159 | 10.02.0 | 28 | | 1.14.5 | 25.3.1 | Alpine 3.17 | 112.0.5615.165 | 10.01.1 | 29 | | 1.14.0 | 25.1 | Debian Buster | 90.0.4430.212-1 | 9.27 | 30 | | 1.11.4 | 22.3.4.26 | Debian Buster | 90.0.4430.212-1 | 9.27 | 31 | 32 | ## Installation 33 | 34 | ChromicPDF is a supervision tree (rather than an application). You will need to inject it into the supervision tree of your application. First, add ChromicPDF to your runtime dependencies: 35 | 36 | ```elixir 37 | def deps do 38 | [ 39 | {:chromic_pdf, "~> 1.17"} 40 | ] 41 | end 42 | ``` 43 | 44 | Next, start ChromicPDF as part of your application: 45 | 46 | ```elixir 47 | # lib/my_app/application.ex 48 | def MyApp.Application do 49 | def start(_type, _args) do 50 | children = [ 51 | # other apps... 52 | ChromicPDF 53 | ] 54 | 55 | Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor) 56 | end 57 | end 58 | ``` 59 | 60 | ## Usage 61 | 62 | ### Main API 63 | 64 | Here's how you generate a PDF from an external URL and store it in the local filesystem. 65 | 66 | ```elixir 67 | # Prints a local HTML file to PDF. 68 | ChromicPDF.print_to_pdf({:url, "https://example.net"}, output: "example.pdf") 69 | ``` 70 | 71 | The next example shows how to print a local HTML file to PDF/A, as well as the use of a callback 72 | function that receives the generated PDF as path to a temporary file. 73 | 74 | ```elixir 75 | ChromicPDF.print_to_pdfa({:url, "file:///example.html"}, output: fn pdf -> 76 | # Send pdf via mail, upload to S3, ... 77 | end) 78 | ``` 79 | 80 | ### Template API 81 | 82 | [ChromicPDF.Template](https://hexdocs.pm/chromic_pdf/ChromicPDF.Template.html) contains 83 | additional functionality for controlling page dimensions of your PDF. 84 | 85 | ```elixir 86 | [content: "

Hello Template

", size: :a4] 87 | |> ChromicPDF.Template.source_and_options() 88 | |> ChromicPDF.print_to_pdf() 89 | ``` 90 | 91 | ### Multiple sources 92 | 93 | Multiple sources can be automatically concatenated using Ghostscript. 94 | 95 | ```elixir 96 | ChromicPDF.print_to_pdf([{:html, "page 1"}, {:html, "page 2"}], output: "joined.pdf") 97 | ``` 98 | 99 | ### Examples 100 | 101 | * There is an outdated example of how to integrate ChromicPDF in a Phoenix application, see [examples/phoenix](https://github.com/bitcrowd/chromic_pdf/tree/v1.14.0/examples/phoenix). 102 | 103 | ## Development 104 | 105 | This should get you started: 106 | 107 | ``` 108 | mix deps.get 109 | mix test 110 | ``` 111 | 112 | For running the full suite of integration tests, please install and have in your `$PATH`: 113 | 114 | * [`verapdf`](https://verapdf.org/) 115 | * For `pdfinfo` and `pdftotext`, you need `poppler-utils` (most Linux distributions) or [Xpdf](https://www.xpdfreader.com/) (OSX) 116 | * For the odd ZUGFeRD test in [`zugferd_test.exs`](https://github.com/bitcrowd/chromic_pdf/tree/main/test/integration/zugferd_test.exs), you need to download [ZUV](https://github.com/ZUGFeRD/ZUV) and set the `$ZUV_JAR` environment variable. 117 | 118 | ## Acknowledgements 119 | 120 | * The PDF/A conversion is inspired by the `pdf2archive` script originally created by [@matteosecli](https://github.com/matteosecli/pdf2archive) and later enhanced by [@JaimeChavarriaga](https://github.com/JaimeChavarriaga/pdf2archive/tree/feature/support_pdf2b). 121 | 122 | ## Copyright and License 123 | 124 | Copyright (c) 2019–2023 Bitcrowd GmbH 125 | 126 | Licensed under the Apache License 2.0. See [LICENSE](LICENSE) file for details. 127 | -------------------------------------------------------------------------------- /assets/COPYRIGHT: -------------------------------------------------------------------------------- 1 | ## Logos 2 | 3 | * created by @maltoe 4 | * svg handcrafted 5 | * font pathing done in [google-font-to-svg-path](https://danmarshall.github.io/google-font-to-svg-path/) 6 | * pngs rendered with inkscape 7 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/chromic_pdf/225ed053a6ccece2f9599d9330590220e5d83d30/assets/icon.png -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /assets/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/chromic_pdf/225ed053a6ccece2f9599d9330590220e5d83d30/assets/social.png -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | import Config 4 | 5 | # Set this to true to see protocol messages. 6 | config :chromic_pdf, debug_protocol: false 7 | 8 | if Mix.env() in [:dev, :test] do 9 | config :chromic_pdf, dev_pool_size: 1 10 | end 11 | -------------------------------------------------------------------------------- /lib/chromic_pdf/api.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.API do 4 | @moduledoc false 5 | 6 | import ChromicPDF.{Telemetry, Utils} 7 | 8 | alias ChromicPDF.{ 9 | Browser, 10 | CaptureScreenshot, 11 | ExportOptions, 12 | GhostscriptPool, 13 | PrintToPDF, 14 | ProtocolOptions 15 | } 16 | 17 | @spec print_to_pdf( 18 | ChromicPDF.Supervisor.services(), 19 | ChromicPDF.source() | [ChromicPDF.source()], 20 | [ChromicPDF.pdf_option() | ChromicPDF.shared_option()] 21 | ) :: ChromicPDF.result() 22 | def print_to_pdf(services, sources, opts) when is_list(sources) and is_list(opts) do 23 | with_tmp_dir(fn tmp_dir -> 24 | paths = 25 | Enum.map(sources, fn source -> 26 | tmp_path = Path.join(tmp_dir, random_file_name(".pdf")) 27 | :ok = print_to_pdf(services, source, Keyword.put(opts, :output, tmp_path)) 28 | tmp_path 29 | end) 30 | 31 | output_path = Path.join(tmp_dir, random_file_name(".pdf")) 32 | 33 | with_telemetry(:join_pdfs, opts, fn -> 34 | :ok = GhostscriptPool.join(services.ghostscript_pool, paths, opts, output_path) 35 | ExportOptions.feed_file_into_output(output_path, opts) 36 | end) 37 | end) 38 | end 39 | 40 | def print_to_pdf(services, %{source: source, opts: opts}, overrides) 41 | when is_tuple(source) and is_list(opts) and is_list(overrides) do 42 | print_to_pdf(services, source, Keyword.merge(opts, overrides)) 43 | end 44 | 45 | def print_to_pdf(services, source, opts) when is_tuple(source) and is_list(opts) do 46 | {protocol, opts} = 47 | opts 48 | |> ProtocolOptions.prepare_print_to_pdf_options(source) 49 | |> Keyword.pop(:protocol, PrintToPDF) 50 | 51 | chrome_export(services, :print_to_pdf, protocol, opts) 52 | end 53 | 54 | @spec capture_screenshot(ChromicPDF.Supervisor.services(), ChromicPDF.source(), [ 55 | ChromicPDF.capture_screenshot_option() | ChromicPDF.shared_option() 56 | ]) :: 57 | ChromicPDF.result() 58 | def capture_screenshot(services, %{source: source, opts: opts}, overrides) 59 | when is_tuple(source) and is_list(opts) and is_list(overrides) do 60 | capture_screenshot(services, source, Keyword.merge(opts, overrides)) 61 | end 62 | 63 | def capture_screenshot(services, source, opts) when is_tuple(source) and is_list(opts) do 64 | {protocol, opts} = 65 | opts 66 | |> ProtocolOptions.prepare_capture_screenshot_options(source) 67 | |> Keyword.pop(:protocol, CaptureScreenshot) 68 | 69 | chrome_export(services, :capture_screenshot, protocol, opts) 70 | end 71 | 72 | @spec run_protocol(ChromicPDF.Supervisor.services(), module(), [ 73 | ChromicPDF.shared_option() | ChromicPDF.protocol_option() 74 | ]) :: ChromicPDF.result() 75 | def run_protocol(services, protocol, opts) when is_atom(protocol) and is_list(opts) do 76 | chrome_export(services, :run_protocol, protocol, opts) 77 | end 78 | 79 | defp chrome_export(services, operation, protocol, opts) do 80 | with_telemetry(operation, opts, fn -> 81 | services.browser 82 | |> Browser.new_protocol(protocol, opts) 83 | |> ExportOptions.feed_chrome_data_into_output(opts) 84 | end) 85 | end 86 | 87 | @spec convert_to_pdfa(ChromicPDF.Supervisor.services(), ChromicPDF.path(), [ 88 | ChromicPDF.pdfa_option() | ChromicPDF.shared_option() 89 | ]) :: 90 | ChromicPDF.result() 91 | def convert_to_pdfa(services, pdf_path, opts) when is_binary(pdf_path) and is_list(opts) do 92 | with_tmp_dir(fn tmp_dir -> 93 | do_convert_to_pdfa(services, pdf_path, opts, tmp_dir) 94 | end) 95 | end 96 | 97 | @spec print_to_pdfa( 98 | ChromicPDF.Supervisor.services(), 99 | ChromicPDF.source() | [ChromicPDF.source()], 100 | [ 101 | ChromicPDF.pdf_option() | ChromicPDF.pdfa_option() | ChromicPDF.shared_option() 102 | ] 103 | ) :: 104 | ChromicPDF.result() 105 | def print_to_pdfa(services, source, opts) when is_list(opts) do 106 | with_tmp_dir(fn tmp_dir -> 107 | pdf_path = Path.join(tmp_dir, random_file_name(".pdf")) 108 | :ok = print_to_pdf(services, source, Keyword.put(opts, :output, pdf_path)) 109 | do_convert_to_pdfa(services, pdf_path, opts, tmp_dir) 110 | end) 111 | end 112 | 113 | defp do_convert_to_pdfa(services, pdf_path, opts, tmp_dir) do 114 | pdfa_path = Path.join(tmp_dir, random_file_name(".pdf")) 115 | 116 | with_telemetry(:convert_to_pdfa, opts, fn -> 117 | :ok = GhostscriptPool.convert(services.ghostscript_pool, pdf_path, opts, pdfa_path) 118 | ExportOptions.feed_file_into_output(pdfa_path, opts) 119 | end) 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/chromic_pdf/api/chrome_error.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.ChromeError do 4 | @moduledoc """ 5 | Exception in the communication with Chrome. 6 | """ 7 | 8 | defexception [:error, :opts, :message] 9 | 10 | @impl true 11 | def message(%__MODULE__{error: error, opts: opts}) do 12 | """ 13 | #{title_for_error(error)} 14 | 15 | #{hint_for_error(error, opts)} 16 | """ 17 | end 18 | 19 | defp title_for_error({:exception_thrown, _}) do 20 | "Unhandled exception in JS runtime" 21 | end 22 | 23 | defp title_for_error({:console_api_called, _}) do 24 | "Console API called in JS runtime" 25 | end 26 | 27 | defp title_for_error({:evaluate, _}) do 28 | "Exception in :evaluate expression" 29 | end 30 | 31 | defp title_for_error(error) do 32 | error 33 | end 34 | 35 | defp hint_for_error("net::ERR_INTERNET_DISCONNECTED", _opts) do 36 | """ 37 | You are trying to navigate to a remote URL but Chrome is not able to establish a connection 38 | to the remote host. Please make sure that you have access to the internet and that Chrome is 39 | allowed to open a connection to the remote host by your firewall policy. 40 | 41 | In case you are running ChromicPDF in "offline mode" this error is to be expected. 42 | """ 43 | end 44 | 45 | defp hint_for_error("net::ERR_CERT" <> _, _opts) do 46 | """ 47 | You are trying to navigate to a remote URL via HTTPS and Chrome is not able to verify the 48 | remote host's SSL certificate. If the remote is a production system, please make sure its 49 | certificate is valid and has not expired. 50 | 51 | In case you are connecting to a development/test system with a self-signed certificate, you 52 | can disable certificate verification by passing the `:ignore_certificate_errors` flag. 53 | 54 | {ChromicPDF, ignore_certificate_errors: true} 55 | """ 56 | end 57 | 58 | defp hint_for_error({:exception_thrown, description}, _opts) do 59 | """ 60 | Exception: 61 | 62 | #{indent(description)} 63 | """ 64 | end 65 | 66 | defp hint_for_error({:console_api_called, {type, args}}, _opts) do 67 | """ 68 | console.#{type} called: 69 | 70 | #{indent(args)} 71 | """ 72 | end 73 | 74 | defp hint_for_error({:evaluate, error}, opts) do 75 | %{ 76 | "exception" => %{"description" => description}, 77 | "lineNumber" => line_number 78 | } = error 79 | 80 | %{expression: expression} = Keyword.fetch!(opts, :evaluate) 81 | 82 | """ 83 | Exception: 84 | 85 | #{indent(description)} 86 | 87 | Evaluated expression: 88 | 89 | #{indent(expression, line_number)} 90 | """ 91 | end 92 | 93 | defp hint_for_error(_other, _opts) do 94 | """ 95 | Chrome has responded with the above error error while you were trying to print a PDF. 96 | """ 97 | end 98 | 99 | defp indent(expression, line_number \\ nil) do 100 | expression 101 | |> String.trim() 102 | |> String.split("\n") 103 | |> Enum.with_index() 104 | |> Enum.map_join("\n", fn 105 | {line, ^line_number} -> "!!! #{line}" 106 | {line, _line_number} -> " #{line}" 107 | end) 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/chromic_pdf/api/export_options.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.ExportOptions do 4 | @moduledoc false 5 | 6 | import ChromicPDF.Utils 7 | alias ChromicPDF.ChromeError 8 | 9 | def feed_file_into_output(pdf_path, opts) do 10 | case Keyword.get(opts, :output) do 11 | path when is_binary(path) -> 12 | File.cp!(pdf_path, path) 13 | :ok 14 | 15 | fun when is_function(fun, 1) -> 16 | {:ok, fun.(pdf_path)} 17 | 18 | nil -> 19 | data = 20 | pdf_path 21 | |> File.read!() 22 | |> Base.encode64() 23 | 24 | {:ok, data} 25 | end 26 | end 27 | 28 | def feed_chrome_data_into_output({:error, error}, opts) do 29 | raise ChromeError, error: error, opts: opts 30 | end 31 | 32 | def feed_chrome_data_into_output({:ok, data}, opts) do 33 | case Keyword.get(opts, :output) do 34 | path when is_binary(path) -> 35 | File.write!(path, Base.decode64!(data)) 36 | :ok 37 | 38 | fun when is_function(fun, 1) -> 39 | result_from_callback = 40 | with_tmp_dir(fn tmp_dir -> 41 | path = Path.join(tmp_dir, random_file_name(".pdf")) 42 | File.write!(path, Base.decode64!(data)) 43 | fun.(path) 44 | end) 45 | 46 | {:ok, result_from_callback} 47 | 48 | nil -> 49 | {:ok, data} 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/chromic_pdf/api/protocol_options.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.ProtocolOptions do 4 | @moduledoc false 5 | 6 | require EEx 7 | import ChromicPDF.Utils, only: [rendered_to_binary: 1] 8 | 9 | def prepare_print_to_pdf_options(opts, source) do 10 | opts 11 | |> prepare_navigate_options(source) 12 | |> stringify_map_keys(:print_to_pdf) 13 | |> sanitize_binary_option([:print_to_pdf, "headerTemplate"]) 14 | |> sanitize_binary_option([:print_to_pdf, "footerTemplate"]) 15 | end 16 | 17 | def prepare_capture_screenshot_options(opts, source) do 18 | opts 19 | |> prepare_navigate_options(source) 20 | |> stringify_map_keys(:capture_screenshot) 21 | end 22 | 23 | defp prepare_navigate_options(opts, source) do 24 | opts 25 | |> put_source(source) 26 | |> replace_wait_for_with_evaluate() 27 | |> sanitize_binary_option(:html) 28 | end 29 | 30 | defp put_source(opts, {:file, source}), do: put_source(opts, {:url, source}) 31 | defp put_source(opts, {:path, source}), do: put_source(opts, {:url, source}) 32 | defp put_source(opts, {:html, source}), do: put_source(opts, :html, source) 33 | 34 | if Code.ensure_loaded?(Plug) && Code.ensure_loaded?(Plug.Crypto) do 35 | defp put_source(opts, {:plug, plug_opts}) do 36 | if Keyword.has_key?(opts, :set_cookie) do 37 | raise "plug source conflicts with set_cookie" 38 | end 39 | 40 | {url, plug_opts} = Keyword.pop!(plug_opts, :url) 41 | 42 | set_cookie_opts = 43 | plug_opts 44 | |> ChromicPDF.Plug.start_agent_and_get_cookie() 45 | |> Map.put(:url, url) 46 | |> Map.put(:secure, String.starts_with?(url, "https")) 47 | 48 | opts 49 | |> Keyword.put(:set_cookie, set_cookie_opts) 50 | |> put_source(:url, url) 51 | end 52 | end 53 | 54 | defp put_source(opts, {:url, source}) do 55 | url = 56 | if File.exists?(source) do 57 | # This works for relative paths as "local" Chromiums start with the same pwd. 58 | "file://#{Path.expand(source)}" 59 | else 60 | source 61 | end 62 | 63 | put_source(opts, :url, url) 64 | end 65 | 66 | defp put_source(opts, source_type, source) do 67 | opts 68 | |> Keyword.put_new(:source_type, source_type) 69 | |> Keyword.put_new(source_type, source) 70 | end 71 | 72 | EEx.function_from_string( 73 | :defp, 74 | :render_wait_for_script, 75 | """ 76 | const waitForAttribute = async (selector, attribute) => { 77 | while (!document.querySelector(selector).hasAttribute(attribute)) { 78 | await new Promise(resolve => requestAnimationFrame(resolve)); 79 | } 80 | }; 81 | 82 | waitForAttribute('<%= selector %>', '<%= attribute %>'); 83 | """, 84 | [:selector, :attribute] 85 | ) 86 | 87 | defp replace_wait_for_with_evaluate(opts) do 88 | case Keyword.pop(opts, :wait_for) do 89 | {nil, opts} -> opts 90 | {wait_for, opts} -> do_replace_wait_for_with_evaluate(opts, wait_for) 91 | end 92 | end 93 | 94 | defp do_replace_wait_for_with_evaluate(opts, %{selector: selector, attribute: attribute}) do 95 | wait_for_script = render_wait_for_script(selector, attribute) 96 | 97 | Keyword.update(opts, :evaluate, %{expression: wait_for_script}, fn evaluate -> 98 | Map.update!(evaluate, :expression, fn user_script -> 99 | """ 100 | #{user_script} 101 | #{wait_for_script} 102 | """ 103 | end) 104 | end) 105 | end 106 | 107 | def stringify_map_keys(opts, key) do 108 | Keyword.update(opts, key, %{}, &do_stringify_map_keys/1) 109 | end 110 | 111 | defp do_stringify_map_keys(map) do 112 | Enum.into(map, %{}, fn {k, v} -> {to_string(k), v} end) 113 | end 114 | 115 | defp sanitize_binary_option(opts, path) do 116 | update_in(opts, List.wrap(path), fn 117 | nil -> "" 118 | other -> rendered_to_binary(other) 119 | end) 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/chromic_pdf/api/telemetry.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.Telemetry do 4 | @moduledoc false 5 | 6 | def with_telemetry(operation, opts, fun) do 7 | metadata = Keyword.get(opts, :telemetry_metadata, %{}) 8 | 9 | :telemetry.span([:chromic_pdf, operation], metadata, fn -> 10 | {fun.(), metadata} 11 | end) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/browser.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.Browser do 4 | @moduledoc false 5 | 6 | use Supervisor 7 | import ChromicPDF.Utils, only: [find_supervisor_child!: 2, supervisor_children: 2] 8 | alias ChromicPDF.Browser.{Channel, ExecutionError, SessionPool, SessionPoolConfig} 9 | alias ChromicPDF.{CloseTarget, Protocol, SpawnSession} 10 | 11 | # ------------- API ---------------- 12 | 13 | @spec start_link(Keyword.t()) :: Supervisor.on_start() 14 | def start_link(config) do 15 | Supervisor.start_link(__MODULE__, config) 16 | end 17 | 18 | @spec new_protocol(pid(), module(), keyword()) :: {:ok, any()} | {:error, term()} 19 | def new_protocol(supervisor, protocol_mod, params) do 20 | {session_pool, pool_config} = find_session_pool_with_config(supervisor, params) 21 | 22 | checkout_opts = [ 23 | skip_session_use_count: Keyword.get(params, :skip_session_use_count, false), 24 | timeout: Keyword.fetch!(pool_config, :checkout_timeout) 25 | ] 26 | 27 | SessionPool.checkout!(session_pool, checkout_opts, fn %{session_id: session_id} -> 28 | protocol = protocol_mod.new(session_id, Keyword.merge(pool_config, params)) 29 | timeout = Keyword.fetch!(pool_config, :timeout) 30 | 31 | run_protocol(supervisor, protocol, timeout) 32 | end) 33 | end 34 | 35 | # ------------ Callbacks ----------- 36 | 37 | @impl Supervisor 38 | def init(config) do 39 | children = [{Channel, config} | session_pools(config)] 40 | 41 | Supervisor.init(children, strategy: :one_for_all) 42 | end 43 | 44 | defp session_pools(config) do 45 | browser = self() 46 | 47 | pools = SessionPoolConfig.pools_from_config(config) 48 | agent = {Agent, fn -> Map.new(pools) end} 49 | 50 | pools = 51 | for {id, pool_config} <- pools do 52 | {SessionPool, 53 | {id, 54 | pool_size: Keyword.fetch!(pool_config, :size), 55 | max_uses: Keyword.fetch!(pool_config, :max_uses), 56 | init_worker: fn -> 57 | protocol = SpawnSession.new(pool_config) 58 | timeout = Keyword.fetch!(pool_config, :init_timeout) 59 | 60 | {:ok, %{"sessionId" => sid, "targetId" => tid}} = 61 | run_protocol(browser, protocol, timeout) 62 | 63 | %{session_id: sid, target_id: tid} 64 | end, 65 | terminate_worker: fn %{target_id: target_id} -> 66 | protocol = CloseTarget.new(targetId: target_id) 67 | timeout = Keyword.fetch!(pool_config, :close_timeout) 68 | 69 | {:ok, _} = run_protocol(browser, protocol, timeout) 70 | end}} 71 | end 72 | 73 | [agent | pools] 74 | end 75 | 76 | # ---------- Dealing with supervisor children ----------- 77 | 78 | defp run_protocol(supervisor, %Protocol{} = protocol, timeout) do 79 | supervisor 80 | |> find_channel() 81 | |> Channel.run_protocol(protocol, timeout) 82 | end 83 | 84 | defp find_agent(supervisor), do: find_supervisor_child!(supervisor, Agent) 85 | defp find_channel(supervisor), do: find_supervisor_child!(supervisor, Channel) 86 | 87 | defp find_session_pool_with_config(supervisor, params) do 88 | name = SessionPoolConfig.pool_name_from_params(params) 89 | 90 | pool = 91 | supervisor 92 | |> supervisor_children(SessionPool) 93 | |> Enum.find(fn {{SessionPool, id}, _pid} -> id == name end) 94 | |> case do 95 | {_, pid} -> 96 | pid 97 | 98 | nil -> 99 | raise ExecutionError, """ 100 | Could not find session pool named #{inspect(name)}!" 101 | """ 102 | end 103 | 104 | config = 105 | supervisor 106 | |> find_agent() 107 | |> Agent.get(& &1) 108 | |> Map.fetch!(name) 109 | 110 | {pool, config} 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/browser/channel.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.Browser.Channel do 4 | @moduledoc false 5 | 6 | use GenServer 7 | require Logger 8 | alias ChromicPDF.Browser.ExecutionError 9 | alias ChromicPDF.{Connection, Protocol} 10 | alias ChromicPDF.JsonRPC 11 | 12 | # ------------- API ---------------- 13 | 14 | @spec start_link(Keyword.t()) :: GenServer.on_start() 15 | def start_link(config) do 16 | GenServer.start_link(__MODULE__, config) 17 | end 18 | 19 | @spec run_protocol(pid(), Protocol.t(), timeout()) :: {:ok, any()} | {:error, term()} 20 | def run_protocol(pid, %Protocol{} = protocol, timeout) do 21 | GenServer.call(pid, {:run_protocol, protocol, make_ref()}, timeout) 22 | catch 23 | :exit, {:timeout, {GenServer, :call, [pid, {:run_protocol, _protocol, ref}, _timeout]}} -> 24 | raise(ExecutionError, """ 25 | Timeout in Channel.run_protocol/3! 26 | 27 | The underlying GenServer.call/3 exited with a timeout. This happens when the browser was 28 | not able to complete the current operation (= PDF print job) within the configured 29 | #{timeout} milliseconds. 30 | 31 | If you are printing large PDFs and expect long processing times, please consult the 32 | documentation for the `timeout` option of the session pool. 33 | 34 | If you are *not* printing large PDFs but your print jobs still time out, this is likely a 35 | bug in ChromicPDF. Please open an issue on the issue tracker. 36 | 37 | --- 38 | 39 | Current protocol: 40 | 41 | #{pid |> GenServer.call({:cancel_protocol, ref}) |> inspect(pretty: true)} 42 | """) 43 | end 44 | 45 | # ----------- Callbacks ------------ 46 | 47 | @impl GenServer 48 | def init(config) do 49 | {:ok, conn_pid} = Connection.start_link(config) 50 | 51 | {:ok, 52 | %{ 53 | conn_pid: conn_pid, 54 | waitlist: [], 55 | next_call_id: 1 56 | }} 57 | end 58 | 59 | # Starts protocol processing, asynchronously sends result message when done. 60 | @impl GenServer 61 | def handle_call({:run_protocol, protocol, ref}, from, state) do 62 | task = %{protocol: protocol, from: from, ref: ref} 63 | 64 | {:noreply, handle_run_protocol(task, state)} 65 | end 66 | 67 | # Removes protocol from list and sends current state to client. 68 | @impl GenServer 69 | def handle_call({:cancel_protocol, ref}, _from, state) do 70 | {protocol, state} = handle_cancel_protocol(ref, state) 71 | 72 | {:reply, protocol, state} 73 | end 74 | 75 | # Data packets coming in from connection. 76 | @impl GenServer 77 | def handle_info({:msg, msg}, state) do 78 | msg = JsonRPC.decode(msg) 79 | 80 | warn_on_inspector_crash(msg) 81 | 82 | {:noreply, handle_chrome_message(msg, state)} 83 | end 84 | 85 | @impl GenServer 86 | def terminate(:normal, _state), do: :ok 87 | 88 | def terminate(:shutdown, %{conn_pid: conn_pid, next_call_id: next_call_id}) do 89 | # Graceful shutdown: Dispatch the Browser.close call to Chrome which will cause it to detach 90 | # all debugging sessions and close the port. 91 | Connection.send_msg(conn_pid, JsonRPC.encode({"Browser.close", %{}}, next_call_id)) 92 | 93 | :ok 94 | end 95 | 96 | def terminate(_exception, _state), do: :ok 97 | 98 | defp warn_on_inspector_crash(msg) do 99 | if match?(%{"method" => "Inspector.targetCrashed"}, msg) do 100 | Logger.error(""" 101 | ChromicPDF received an 'Inspector.targetCrashed' message. 102 | 103 | This means an active Chrome tab has died and your current operation is going to time out. 104 | 105 | Known causes: 106 | 107 | 1) External URLs in tags in the header/footer templates cause Chrome to crash. 108 | 2) Shared memory exhaustion can cause Chrome to crash. Depending on your environment, the 109 | available shared memory at /dev/shm may be too small for your use-case. This may 110 | especially affect you if you run ChromicPDF in a container, as, for instance, the 111 | Docker runtime provides only 64 MB to containers by default. 112 | 113 | Pass --disable-dev-shm-usage as a Chrome flag to use /tmp for this purpose instead 114 | (via the chrome_args option), or increase the amount of shared memory available to 115 | the container (see --shm-size for Docker). 116 | """) 117 | end 118 | end 119 | 120 | # -------- Task execution ---------- 121 | 122 | # "Runs" the protocol processing until done or await instruction reached. 123 | defp handle_run_protocol(task, %{conn_pid: conn_pid, next_call_id: next_call_id} = state) do 124 | {protocol, result} = Protocol.step(task.protocol, next_call_id) 125 | 126 | # For simplicity, we update next_call_id regardless of whether call was sent. 127 | task = %{task | protocol: protocol} 128 | state = %{state | next_call_id: next_call_id + 1} 129 | 130 | case result do 131 | {:call, call} -> 132 | :ok = Connection.send_msg(conn_pid, JsonRPC.encode(call, next_call_id)) 133 | handle_run_protocol(task, state) 134 | 135 | :await -> 136 | push_to_waitlist(state, task) 137 | 138 | {:halt, result} -> 139 | GenServer.reply(task.from, result) 140 | state 141 | end 142 | end 143 | 144 | # Removes matching task from waitlist and returns protocol. 145 | defp handle_cancel_protocol(ref, state) do 146 | pop_from_waitlist(state, fn task -> 147 | if task.ref == ref do 148 | task.protocol 149 | end 150 | end) 151 | end 152 | 153 | # Removes task for which protocol can consume incoming chrome message from waitlist and 154 | # passes it back to `handle_run_protocol` for further processing and re-enqueueing. 155 | defp handle_chrome_message(msg, state) do 156 | case pop_from_waitlist(state, &match_chrome_message(&1, msg)) do 157 | {nil, state} -> 158 | state 159 | 160 | {task, state} -> 161 | handle_run_protocol(task, state) 162 | end 163 | end 164 | 165 | # Matches message against single protocol and updates task on match. 166 | defp match_chrome_message(task, msg) do 167 | case Protocol.match_chrome_message(task.protocol, msg) do 168 | :no_match -> false 169 | {:match, protocol} -> %{task | protocol: protocol} 170 | end 171 | end 172 | 173 | # -------- State management -------- 174 | 175 | defp push_to_waitlist(%{waitlist: waitlist} = state, task) do 176 | %{state | waitlist: [task | waitlist]} 177 | end 178 | 179 | # Pops a value for which predicate is truthy from waitlist, returns predicate result. 180 | defp pop_from_waitlist(%{waitlist: waitlist} = state, fun) do 181 | {matched, waitlist} = do_pop_from_waitlist(waitlist, fun, []) 182 | {matched, %{state | waitlist: waitlist}} 183 | end 184 | 185 | defp do_pop_from_waitlist([], _fun, acc), do: {nil, acc} 186 | 187 | defp do_pop_from_waitlist([task | rest], fun, acc) do 188 | if value = fun.(task) do 189 | {value, rest ++ acc} 190 | else 191 | do_pop_from_waitlist(rest, fun, [task | acc]) 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/browser/execution_error.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.Browser.ExecutionError do 4 | @moduledoc """ 5 | Exception in interaction with the session pool. 6 | """ 7 | 8 | defexception [:message] 9 | end 10 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/browser/session_pool.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.Browser.SessionPool do 4 | @moduledoc false 5 | 6 | @behaviour NimblePool 7 | 8 | require Logger 9 | alias ChromicPDF.Browser.ExecutionError 10 | 11 | @type session :: any() 12 | 13 | @type pool_state :: %{ 14 | init_worker: (() -> session()), 15 | terminate_worker: (session() -> any()), 16 | max_uses: non_neg_integer() 17 | } 18 | 19 | @type worker_state :: %{ 20 | session: session(), 21 | uses: non_neg_integer() 22 | } 23 | 24 | @type checkout_option :: {:skip_session_use_count, boolean()} | {:timeout, timeout()} 25 | @type checkout_result :: any() 26 | 27 | @spec child_spec({atom(), keyword()}) :: Supervisor.child_spec() 28 | def child_spec({id, opts}) do 29 | %{ 30 | id: {__MODULE__, id}, 31 | start: {__MODULE__, :start_link, [opts]} 32 | } 33 | end 34 | 35 | @spec start_link(Keyword.t()) :: GenServer.on_start() 36 | def start_link(opts) do 37 | {pool_size, opts} = Keyword.pop!(opts, :pool_size) 38 | 39 | NimblePool.start_link(worker: {__MODULE__, Map.new(opts)}, pool_size: pool_size) 40 | end 41 | 42 | @spec checkout!(pid, [checkout_option], (session -> checkout_result)) :: checkout_result 43 | def checkout!(pid, opts, fun) do 44 | command = 45 | if Keyword.fetch!(opts, :skip_session_use_count) do 46 | :checkout 47 | else 48 | :checkout_and_count 49 | end 50 | 51 | timeout = Keyword.fetch!(opts, :timeout) 52 | 53 | try do 54 | NimblePool.checkout!( 55 | pid, 56 | command, 57 | fn _, {_pool_state, session} -> {fun.(session), :ok} end, 58 | timeout 59 | ) 60 | catch 61 | :exit, {:timeout, _} -> 62 | raise(ExecutionError, """ 63 | Caught EXIT signal from NimblePool.checkout!/4 64 | 65 | ** (EXIT) time out 66 | 67 | This means that your operation was unable to acquire a worker from the pool 68 | within #{timeout}ms, as all workers are currently occupied. 69 | 70 | Two scenarios where this may happen: 71 | 72 | 1) You suffer from this error at boot time or in your CI. For instance, 73 | you're running PDF printing tests in CI and occasionally the first of these test 74 | fails. This may be caused by Chrome being delayed by initialization tasks when 75 | it is first launched. 76 | 77 | See ChromicPDF.warm_up/1 for a possible mitigation. 78 | 79 | 2) You're experiencing this error randomly under load. This would indicate that 80 | the number of concurrent print jobs exceeds the total number of workers in 81 | the pool, so that all workers are occupied. 82 | 83 | To fix this, you need to increase your resources, e.g. by increasing the number 84 | of workers with the `session_pool: [size: ...]` option. 85 | 86 | Please also consult the session pool concurrency section in the documentation. 87 | """) 88 | end 89 | end 90 | 91 | # ------------ Callbacks ----------- 92 | 93 | @impl NimblePool 94 | def init_worker(pool_state) do 95 | {:async, fn -> do_init_worker(pool_state) end, pool_state} 96 | end 97 | 98 | defp do_init_worker(%{init_worker: init_worker}) do 99 | %{session: init_worker.(), uses: 0} 100 | end 101 | 102 | @impl NimblePool 103 | def handle_checkout(:checkout_and_count, from, worker_state, pool_state) do 104 | handle_checkout(:checkout, from, increment_uses_count(worker_state), pool_state) 105 | end 106 | 107 | def handle_checkout(:checkout, _from, worker_state, pool_state) do 108 | {:ok, {pool_state, worker_state.session}, worker_state, pool_state} 109 | end 110 | 111 | defp increment_uses_count(%{uses: uses} = worker_state) do 112 | %{worker_state | uses: uses + 1} 113 | end 114 | 115 | @impl NimblePool 116 | def handle_checkin(:ok, _from, worker_state, pool_state) do 117 | if worker_state.uses >= pool_state.max_uses do 118 | {:remove, :max_uses_reached, pool_state} 119 | else 120 | {:ok, worker_state, pool_state} 121 | end 122 | end 123 | 124 | @impl NimblePool 125 | # Reasons we want to gracefully clean up the target in the Browser: 126 | # - max_uses_reached, our own mechanism for keeping memory bloat in check 127 | # - error, when an exception is raised in the Channel 128 | # - DOWN, client link is broken (when client process is terminated externally) 129 | def terminate_worker(reason, worker_state, pool_state) 130 | when reason in [:max_uses_reached, :error, :DOWN] do 131 | if reason == :DOWN do 132 | Logger.warning(""" 133 | ChromicPDF received a :DOWN message from the process that called `print_to_pdf/2`! 134 | 135 | This means that the process was terminated externally. For instance, your HTTP server 136 | may have terminated your request after it took too long. 137 | """) 138 | end 139 | 140 | Task.async(fn -> 141 | pool_state.terminate_worker.(worker_state.session) 142 | end) 143 | 144 | {:ok, pool_state} 145 | end 146 | 147 | # We do not put in the effort to clean up individual targets shortly before we terminate the 148 | # external process. 149 | def terminate_worker(:shutdown, _worker_state, pool_state) do 150 | {:ok, pool_state} 151 | end 152 | 153 | # Unexpected other terminate reasons: :timeout | :throw | :exit 154 | end 155 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/browser/session_pool_config.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.Browser.SessionPoolConfig do 4 | @moduledoc false 5 | 6 | import ChromicPDF.Utils, only: [default_pool_size: 0] 7 | 8 | @default_timeout 5000 9 | @default_init_timeout 5000 10 | @default_close_timeout 1000 11 | @default_checkout_timeout 5000 12 | @default_max_uses 1000 13 | 14 | @default_pool_name :default 15 | 16 | # Normalizes global :session_pool option (keywords for default pool or map of named pools) into 17 | # a list of supervisor ids and pool options as combined from globals and named pool overrides. 18 | @spec pools_from_config(keyword()) :: [{__MODULE__, keyword()}] 19 | def pools_from_config(config) do 20 | config 21 | |> extract_named_pools() 22 | |> merge_globals_and_put_defaults(config) 23 | end 24 | 25 | defp extract_named_pools(config) do 26 | case Keyword.get(config, :session_pool, []) do 27 | opts when is_list(opts) -> %{@default_pool_name => opts} 28 | named_pools when is_map(named_pools) -> named_pools 29 | end 30 | end 31 | 32 | defp merge_globals_and_put_defaults(named_pools, config) do 33 | for {name, opts} <- named_pools do 34 | merged = 35 | config 36 | |> Keyword.merge(opts) 37 | |> Keyword.put_new(:size, default_pool_size()) 38 | |> Keyword.put_new(:timeout, @default_timeout) 39 | |> Keyword.put_new(:init_timeout, @default_init_timeout) 40 | |> Keyword.put_new(:close_timeout, @default_close_timeout) 41 | |> Keyword.put_new(:checkout_timeout, @default_checkout_timeout) 42 | |> put_default_max_uses() 43 | |> Keyword.put_new(:offline, false) 44 | |> Keyword.put_new(:ignore_certificate_errors, false) 45 | |> Keyword.put_new(:unhandled_runtime_exceptions, :log) 46 | 47 | {name, merged} 48 | end 49 | end 50 | 51 | defp put_default_max_uses(config) do 52 | cond do 53 | Keyword.has_key?(config, :max_uses) -> 54 | config 55 | 56 | Keyword.has_key?(config, :max_session_uses) -> 57 | {max_session_uses, config} = Keyword.pop(config, :max_session_uses) 58 | 59 | IO.warn(""" 60 | [ChromicPDF] :max_session_uses option is deprecated, change your config to: 61 | 62 | [session_pool: [max_uses: #{max_session_uses}]] 63 | """) 64 | 65 | Keyword.put(config, :max_uses, max_session_uses) 66 | 67 | true -> 68 | Keyword.put(config, :max_uses, @default_max_uses) 69 | end 70 | end 71 | 72 | # Returns the targeted session pool from a map of PDF job params or the default pool name. 73 | @spec pool_name_from_params(keyword()) :: atom() 74 | def pool_name_from_params(pdf_params) do 75 | Keyword.get(pdf_params, :session_pool, @default_pool_name) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/chrome_runner.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.ChromeRunner do 4 | @moduledoc false 5 | 6 | import ChromicPDF.Utils, only: [system_cmd!: 3, with_app_config_cache: 2] 7 | 8 | @spec port_open(keyword()) :: port() 9 | def port_open(opts) do 10 | port_opts = append_if([:binary], :nouse_stdio, !discard_stderr?(opts)) 11 | port_cmd = shell_command("--remote-debugging-pipe", opts) 12 | 13 | Port.open({:spawn, port_cmd}, port_opts) 14 | end 15 | 16 | @spec warm_up(keyword()) :: {:ok, binary()} 17 | def warm_up(opts) do 18 | stderr = 19 | ["--dump-dom", "about:blank"] 20 | |> shell_command(opts) 21 | |> String.to_charlist() 22 | |> :os.cmd() 23 | |> to_string() 24 | |> String.replace("\n", "") 25 | 26 | {:ok, stderr} 27 | end 28 | 29 | @spec version() :: binary() 30 | def version do 31 | :chrome_version 32 | |> with_app_config_cache(&get_version_from_chrome/0) 33 | |> extract_version() 34 | end 35 | 36 | defp get_version_from_chrome do 37 | system_cmd!(executable(), ["--version"], stderr_to_stdout: true) 38 | rescue 39 | e -> 40 | reraise( 41 | """ 42 | Failed to determine Chrome version. 43 | 44 | If you're using a remote chrome instance, please configure ChromicPDF manually: 45 | 46 | config :chromic_pdf, chrome_version: "Google Chrome 120.0.6099.71" 47 | 48 | Afterwards, force a recompilation with: 49 | 50 | mix deps.compile --force chromic_pdf 51 | 52 | --- original exception -- 53 | 54 | #{Exception.format(:error, e, __STACKTRACE__)} 55 | """, 56 | __STACKTRACE__ 57 | ) 58 | end 59 | 60 | defp extract_version(value) do 61 | [version] = Regex.run(~r/\d+\.\d+\.\d+\.\d+/, value) 62 | version 63 | end 64 | 65 | # Public for unit tests. 66 | @doc false 67 | @spec shell_command(keyword()) :: binary() 68 | @spec shell_command(binary() | [binary()], keyword()) :: binary() 69 | def shell_command(extra_args \\ "", opts) do 70 | Enum.join([~s("#{executable(opts)}") | args(extra_args, opts)], " ") 71 | end 72 | 73 | @default_executables [ 74 | "chromium-browser", 75 | "chromium", 76 | "google-chrome", 77 | "chrome", 78 | "chrome.exe", 79 | "/usr/bin/chromium-browser", 80 | "/usr/bin/chromium", 81 | "/usr/bin/google-chrome", 82 | "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", 83 | "/Applications/Chromium.app/Contents/MacOS/Chromium" 84 | ] 85 | 86 | defp executable(opts \\ []) do 87 | executable = 88 | Keyword.get_lazy(opts, :chrome_executable, fn -> 89 | @default_executables 90 | |> Stream.map(&System.find_executable/1) 91 | |> Enum.find(& &1) 92 | end) 93 | 94 | executable || raise "could not find executable from #{inspect(@default_executables)}" 95 | end 96 | 97 | # For the most part, this list is shamelessly stolen from Puppeteer. Kudos to the Puppeteer team 98 | # for figuring all these out. 99 | # 100 | # https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/node/ChromeLauncher.ts 101 | # 102 | # Some of this may arguably be cargo cult. Some options have since become the default in newer 103 | # Chrome versions (e.g. --export-tagged-pdf since Chrome 91) but they're kept around to support 104 | # older browsers. 105 | # 106 | # We do not have the --disable-dev-shm-usage option set (as historically it worked without), 107 | # instead the targetCrashed exception handler explains that it can be set in case of shared 108 | # memory exhaustion. 109 | # 110 | # For a description of the options, see 111 | # 112 | # https://github.com/GoogleChrome/chrome-launcher/blob/main/docs/chrome-flags-for-tools.md 113 | # https://peter.sh/experiments/chromium-command-line-switches/ 114 | # 115 | # One major difference is that we're connecting to Chrome via the newer --remote-debugging-pipe 116 | # option (i.e. via pipes) instead of via a socket. 117 | 118 | @default_args [ 119 | "--headless", 120 | "--disable-accelerated-2d-canvas", 121 | "--disable-gpu", 122 | "--allow-pre-commit-input", 123 | "--disable-background-networking", 124 | "--disable-background-timer-throttling", 125 | "--disable-backgrounding-occluded-windows", 126 | "--disable-breakpad", 127 | "--disable-client-side-phishing-detection", 128 | "--disable-component-extensions-with-background-pages", 129 | "--disable-component-update", 130 | "--disable-default-apps", 131 | "--disable-extensions", 132 | "--disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints", 133 | "--disable-hang-monitor", 134 | "--disable-ipc-flooding-protection", 135 | "--disable-popup-blocking", 136 | "--disable-prompt-on-repost", 137 | "--disable-renderer-backgrounding", 138 | "--disable-sync", 139 | "--enable-automation", 140 | "--enable-features=NetworkServiceInProcess2", 141 | "--export-tagged-pdf", 142 | "--force-color-profile=srgb", 143 | "--hide-scrollbars", 144 | "--metrics-recording-only", 145 | "--no-default-browser-check", 146 | "--no-first-run", 147 | "--no-service-autorun", 148 | "--password-store=basic", 149 | "--use-mock-keychain" 150 | ] 151 | 152 | @spec default_args() :: [binary()] 153 | def default_args, do: @default_args 154 | 155 | # NOTE: The redirection is needed due to obscure behaviour of Ports that use more than 2 FDs. 156 | # https://github.com/bitcrowd/chromic_pdf/issues/76 157 | defp args(extra, opts) do 158 | default_args() 159 | |> append_if("--no-sandbox", no_sandbox?(opts)) 160 | |> apply_chrome_args(opts[:chrome_args]) 161 | |> Kernel.++(List.wrap(extra)) 162 | |> append_if("2>/dev/null 3<&0 4>&1", discard_stderr?(opts)) 163 | end 164 | 165 | defp append_if(list, _value, false), do: list 166 | defp append_if(list, value, true), do: append(list, value) 167 | 168 | defp append(list, value), do: list ++ List.wrap(value) 169 | 170 | defp apply_chrome_args(list, nil), do: list 171 | 172 | defp apply_chrome_args(list, chrome_args) when is_binary(chrome_args) do 173 | append(list, chrome_args) 174 | end 175 | 176 | defp apply_chrome_args(list, extended) when is_list(extended) do 177 | append = Keyword.get(extended, :append, []) 178 | remove = Keyword.get(extended, :remove, []) |> List.wrap() 179 | 180 | list 181 | |> Enum.reject(&Enum.member?(remove, &1)) 182 | |> append(append) 183 | end 184 | 185 | defp no_sandbox?(opts), do: Keyword.get(opts, :no_sandbox, false) 186 | defp discard_stderr?(opts), do: Keyword.get(opts, :discard_stderr, true) 187 | end 188 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/connection.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | # credo:disable-for-this-file Credo.Check.Design.AliasUsage 4 | defmodule ChromicPDF.Connection do 5 | @moduledoc false 6 | 7 | @type state :: map() 8 | @type msg :: binary() 9 | 10 | @callback start_link(keyword()) :: {:ok, pid()} 11 | @callback handle_init(keyword()) :: {:ok, state()} 12 | @callback handle_msg(msg(), state()) :: :ok 13 | 14 | @spec start_link(keyword()) :: GenServer.on_start() 15 | def start_link(opts) do 16 | if Keyword.has_key?(opts, :chrome_address) do 17 | start_inet(opts) 18 | else 19 | ChromicPDF.Connection.Local.start_link(opts) 20 | end 21 | end 22 | 23 | if Code.ensure_loaded?(WebSockex) do 24 | defp start_inet(opts), do: ChromicPDF.Connection.Inet.start_link(opts) 25 | else 26 | defp start_inet(_opts) do 27 | raise(""" 28 | `:chrome_address` flag given but websockex is not present. 29 | 30 | Please add :websockex to your application's list of dependencies. Afterwards, please 31 | recompile ChromicPDF. 32 | 33 | mix deps.compile --force chromic_pdf 34 | """) 35 | end 36 | end 37 | 38 | @spec send_msg(pid(), binary()) :: :ok 39 | def send_msg(pid, msg) do 40 | :ok = GenServer.cast(pid, {:msg, msg}) 41 | end 42 | 43 | defmacro __using__(_) do 44 | quote do 45 | use GenServer 46 | alias ChromicPDF.Connection 47 | 48 | @behaviour Connection 49 | 50 | @impl Connection 51 | def start_link(opts) do 52 | GenServer.start_link(__MODULE__, {self(), opts}) 53 | end 54 | 55 | @impl GenServer 56 | def init({channel_pid, opts}) do 57 | {:ok, state} = handle_init(opts) 58 | 59 | {:ok, Map.put(state, :channel_pid, channel_pid)} 60 | end 61 | 62 | @impl GenServer 63 | def handle_cast({:msg, msg}, state) do 64 | :ok = handle_msg(msg, state) 65 | 66 | {:noreply, state} 67 | end 68 | 69 | defp send_msg_to_channel(msg, %{channel_pid: channel_pid} = state) do 70 | send(channel_pid, {:msg, msg}) 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/connection/connection_lost_error.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.Connection.ConnectionLostError do 4 | @moduledoc """ 5 | Exception raised when Chrome process has stopped unexpectedly. 6 | """ 7 | 8 | defexception [:message] 9 | end 10 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/connection/inet.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | if Code.ensure_loaded?(WebSockex) do 4 | defmodule ChromicPDF.Connection.Inet do 5 | @moduledoc false 6 | 7 | use ChromicPDF.Connection 8 | alias ChromicPDF.Connection.ConnectionLostError 9 | 10 | defmodule Websocket do 11 | @moduledoc false 12 | 13 | use WebSockex 14 | 15 | @spec start_link(binary()) :: GenServer.on_start() 16 | def start_link(websocket_debugger_url) do 17 | WebSockex.start_link(websocket_debugger_url, __MODULE__, %{parent_pid: self()}) 18 | end 19 | 20 | @impl WebSockex 21 | def handle_frame({:text, msg}, %{parent_pid: parent_pid} = state) do 22 | send(parent_pid, {:frame, msg}) 23 | 24 | {:ok, state} 25 | end 26 | 27 | @spec send_frame(pid(), binary()) :: :ok 28 | def send_frame(pid, msg) do 29 | :ok = WebSockex.send_frame(pid, {:text, msg}) 30 | end 31 | end 32 | 33 | @impl ChromicPDF.Connection 34 | def handle_init(opts) do 35 | {:ok, ws_pid} = 36 | opts 37 | |> Keyword.fetch!(:chrome_address) 38 | |> websocket_debugger_url() 39 | |> Websocket.start_link() 40 | 41 | {:ok, %{ws_pid: ws_pid}} 42 | end 43 | 44 | @impl ChromicPDF.Connection 45 | def handle_msg(msg, %{ws_pid: ws_pid}) do 46 | Websocket.send_frame(ws_pid, msg) 47 | end 48 | 49 | @impl GenServer 50 | def handle_info({:frame, msg}, state) do 51 | send_msg_to_channel(msg, state) 52 | 53 | {:noreply, state} 54 | end 55 | 56 | defp websocket_debugger_url({host, port}) do 57 | # Ensure inets app is started. Ignore error if it was already. 58 | :inets.start() 59 | 60 | url = String.to_charlist("http://#{host}:#{port}/json/version") 61 | headers = [{~c"accept", ~c"application/json"}] 62 | http_request_opts = [ssl: [verify: :verify_none]] 63 | 64 | case :httpc.request(:get, {url, headers}, http_request_opts, []) do 65 | {:ok, {_, _, body}} -> 66 | body 67 | |> Jason.decode!() 68 | |> Map.fetch!("webSocketDebuggerUrl") 69 | 70 | {:error, {:failed_connect, _}} -> 71 | raise ConnectionLostError, "failed to connect to #{url}" 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/connection/local.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.Connection.Local do 4 | @moduledoc false 5 | 6 | use ChromicPDF.Connection 7 | alias ChromicPDF.ChromeRunner 8 | alias ChromicPDF.Connection.{ConnectionLostError, Tokenizer} 9 | 10 | @type state :: %{ 11 | port: port(), 12 | parent_pid: pid(), 13 | tokenizer: Tokenizer.t() 14 | } 15 | 16 | @impl ChromicPDF.Connection 17 | def handle_init(opts) do 18 | port = 19 | opts 20 | |> Keyword.take([:chrome_args, :discard_stderr, :no_sandbox, :chrome_executable]) 21 | |> ChromeRunner.port_open() 22 | 23 | Port.monitor(port) 24 | 25 | {:ok, %{port: port, tokenizer: Tokenizer.init()}} 26 | end 27 | 28 | @impl ChromicPDF.Connection 29 | def handle_msg(msg, %{port: port}) do 30 | send(port, {self(), {:command, msg <> "\0"}}) 31 | 32 | :ok 33 | end 34 | 35 | @impl GenServer 36 | def handle_info({_port, {:data, data}}, %{tokenizer: tokenizer} = state) do 37 | {msgs, tokenizer} = Tokenizer.tokenize(data, tokenizer) 38 | 39 | for msg <- msgs do 40 | send_msg_to_channel(msg, state) 41 | end 42 | 43 | {:noreply, %{state | tokenizer: tokenizer}} 44 | end 45 | 46 | # Message triggered by Port.monitor/1. 47 | # Port is down, likely due to the external process having been killed. 48 | def handle_info({:DOWN, _ref, :port, _port, _exit_state}, _state) do 49 | raise(ConnectionLostError, """ 50 | Chrome has stopped or was terminated by an external program. 51 | 52 | If this happened while you were printing a PDF, this may be a problem with Chrome itelf. 53 | If this happens at startup and you are running inside a Docker container with a Linux-based 54 | image, please see the "Chrome Sandbox in Docker containers" section of the documentation. 55 | 56 | Either way, to see Chrome's error output, configure ChromicPDF with the option 57 | 58 | discard_stderr: false 59 | """) 60 | end 61 | 62 | @spec port_info(pid()) :: {atom(), term()} | nil 63 | def port_info(pid) do 64 | GenServer.call(pid, :port_info) 65 | end 66 | 67 | @impl GenServer 68 | def handle_call(:port_info, _from, %{port: port} = state) do 69 | {:reply, Port.info(port), state} 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/connection/tokenizer.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.Connection.Tokenizer do 4 | @moduledoc false 5 | 6 | @type t :: list() 7 | 8 | # Returns initial memo. 9 | def init do 10 | [] 11 | end 12 | 13 | # Returns {[msgs(), memo()]}, msgs can be consumed, memo should be saved for next data blob. 14 | def tokenize(data, memo) do 15 | data 16 | |> String.split("\0") 17 | |> handle_chunks(memo) 18 | end 19 | 20 | defp handle_chunks([blob], memo), do: {[], [blob | memo]} 21 | defp handle_chunks([blob, ""], memo), do: {[join_chunks([blob | memo])], []} 22 | 23 | defp handle_chunks([blob | rest], memo) do 24 | msg = join_chunks([blob | memo]) 25 | {msgs, memo} = handle_chunks(rest, []) 26 | {[msg | msgs], memo} 27 | end 28 | 29 | defp join_chunks(memo) do 30 | memo 31 | |> Enum.reverse() 32 | |> Enum.join() 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/json_rpc.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.JsonRPC do 4 | @moduledoc false 5 | 6 | @type call_id :: integer() 7 | @type session_id :: binary() 8 | @type method :: binary() 9 | @type params :: map() 10 | 11 | @type message :: map() 12 | 13 | @type call :: browser_call() | session_call() 14 | @type browser_call :: {method(), params()} 15 | @type session_call :: {session_id(), method(), params()} 16 | 17 | if Application.compile_env(:chromic_pdf, :debug_protocol) do 18 | defmodule JasonWithDebugLogging do 19 | @moduledoc false 20 | require Logger 21 | 22 | def encode!(data) do 23 | Logger.debug("[ChromicPDF] msg out: #{inspect(data)}") 24 | Jason.encode!(data) 25 | end 26 | 27 | def decode!(msg) do 28 | data = Jason.decode!(msg) 29 | Logger.debug("[ChromicPDF] msg in: #{inspect(data)}") 30 | data 31 | end 32 | end 33 | 34 | @jason JasonWithDebugLogging 35 | else 36 | @jason Jason 37 | end 38 | 39 | @spec encode(call(), call_id()) :: binary() 40 | def encode({method, params}, call_id) do 41 | @jason.encode!(%{ 42 | "method" => method, 43 | "params" => params, 44 | "id" => call_id 45 | }) 46 | end 47 | 48 | def encode({session_id, method, params}, call_id) do 49 | @jason.encode!(%{ 50 | "sessionId" => session_id, 51 | "method" => method, 52 | "params" => params, 53 | "id" => call_id 54 | }) 55 | end 56 | 57 | @spec decode(binary()) :: message() 58 | def decode(data), do: @jason.decode!(data) 59 | 60 | @spec response?(message(), call_id()) :: boolean() 61 | def response?(msg, call_id) do 62 | (Map.has_key?(msg, "result") or Map.has_key?(msg, "error")) and 63 | msg["id"] == call_id 64 | end 65 | 66 | @spec notification?(message(), method()) :: boolean() 67 | def notification?(msg, method) do 68 | Map.has_key?(msg, "method") && msg["method"] == method 69 | end 70 | 71 | @spec is_error?(message()) :: boolean() 72 | def is_error?(msg) do 73 | Map.has_key?(msg, "error") 74 | end 75 | 76 | @spec extract_error(message()) :: String.t() 77 | def extract_error(%{"error" => error}) do 78 | "#{error["message"]} (Code #{error["code"]})" 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/protocol.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.Protocol do 4 | @moduledoc false 5 | 6 | alias ChromicPDF.JsonRPC 7 | 8 | # A protocol is a sequence of JsonRPC calls and responses/notifications. 9 | # 10 | # * It is created for each client request. 11 | # * It's goal is to fulfill the client request. 12 | # * A protocol's `steps` queue is a list of functions. When it is empty, the protocol is done. 13 | # * Besides, a protocol has a `state` map of arbitrary values. 14 | 15 | @type message :: JsonRPC.message() 16 | 17 | @type error :: {:error, term()} 18 | @type state :: map() | error() 19 | @type step :: call_step() | await_step() | output_step() 20 | 21 | # A protocol knows three types of steps: calls, awaits, and output. 22 | # * The call step transforms the state and produces a protocol call to send to the browser. 23 | # Multiple call steps in sequence are executed sequentially until the next await step is found. 24 | # * Await steps are steps that try to match on messages received from the browser. When a 25 | # message is matched, the await step can be removed from the queue (depending on the second 26 | # element of the return tuple, `:keep | :remove`). Multiple await steps in sequence are 27 | # matched **out of order** as messages from the browser are often received out of order as 28 | # well, from different OS processes. 29 | # * The output step is a simple function executed at the end of a protocol to fetch the result 30 | # of the operation from the state. The result is then passed to the client in the channel. 31 | # If no output step exists, the protocol returns `:ok`. 32 | # Output step has to be the last step of the protocol. 33 | 34 | @type call_fun :: (state() -> {state(), JsonRPC.call()}) 35 | @type call_step :: {:call, call_fun()} 36 | 37 | @type await_fun :: 38 | (state(), message() -> :no_match | {:match, :keep | :remove, state()} | error()) 39 | @type await_step :: {:await, await_fun()} 40 | 41 | @type output_fun :: (state() -> any()) 42 | @type output_step :: {:output, output_fun()} 43 | 44 | @type result :: :ok | {:ok, any()} | error() 45 | 46 | @callback new(keyword()) :: t() 47 | @callback new(JsonRPC.session_id(), keyword()) :: t() 48 | 49 | @type t :: %__MODULE__{ 50 | steps: [step()], 51 | state: state() 52 | } 53 | 54 | @enforce_keys [:steps, :state] 55 | defstruct [:steps, :state] 56 | 57 | @spec new([step()], state()) :: t() 58 | def new(steps, initial_state \\ %{}) do 59 | %__MODULE__{steps: steps, state: initial_state} 60 | end 61 | 62 | # Steps a single :call instruction until all done or await instruction reached. 63 | @spec step(t(), JsonRPC.call_id()) :: 64 | {t(), {:call, JsonRPC.call()} | :await | {:halt, result()}} 65 | def step(%__MODULE__{state: {:error, error}} = protocol, _call_id) do 66 | {protocol, {:halt, {:error, error}}} 67 | end 68 | 69 | def step(%__MODULE__{steps: []} = protocol, _call_id), do: {protocol, {:halt, :ok}} 70 | 71 | def step(%__MODULE__{steps: [{:await, _fun} | _rest]} = protocol, _call_id), 72 | do: {protocol, :await} 73 | 74 | def step(%__MODULE__{steps: [{:call, fun} | rest], state: state} = protocol, call_id) do 75 | {state, call} = fun.(state, call_id) 76 | 77 | {%{protocol | steps: rest, state: state}, {:call, call}} 78 | end 79 | 80 | def step(%__MODULE__{steps: [{:output, output_fun}], state: state} = protocol, _call_id) do 81 | {protocol, {:halt, {:ok, output_fun.(state)}}} 82 | end 83 | 84 | # Returns updated protocol if message could be matched, :no_match otherwise. 85 | @spec match_chrome_message(t(), JsonRPC.message()) :: 86 | :no_match | {:match, t()} 87 | def match_chrome_message(%__MODULE__{steps: steps, state: state} = protocol, msg) do 88 | {awaits, rest} = Enum.split_while(steps, fn {type, _fun} -> type == :await end) 89 | 90 | case do_match_chrome_message(awaits, [], state, msg) do 91 | :no_match -> 92 | :no_match 93 | 94 | {:error, error} -> 95 | {:match, %{protocol | state: {:error, error}}} 96 | 97 | {new_head, new_state} -> 98 | {:match, %{protocol | steps: new_head ++ rest, state: new_state}} 99 | end 100 | end 101 | 102 | defp do_match_chrome_message([], _acc, _state, _msg), do: :no_match 103 | 104 | defp do_match_chrome_message([{:await, fun} | rest], acc, state, msg) do 105 | case fun.(state, msg) do 106 | :no_match -> 107 | do_match_chrome_message(rest, acc ++ [{:await, fun}], state, msg) 108 | 109 | {:match, :keep, new_state} -> 110 | {acc ++ [{:await, fun}] ++ rest, new_state} 111 | 112 | {:match, :remove, new_state} -> 113 | {acc ++ rest, new_state} 114 | 115 | {:error, error} -> 116 | {:error, error} 117 | end 118 | end 119 | 120 | defimpl Inspect do 121 | @filtered "[FILTERED]" 122 | 123 | @allowed_values %{ 124 | steps: true, 125 | state: %{ 126 | :capture_screenshot => %{ 127 | "format" => true, 128 | "quality" => true, 129 | "clip" => true, 130 | "fromSurface" => true, 131 | "captureBeyondViewport" => true 132 | }, 133 | :print_to_pdf => %{ 134 | "landscape" => true, 135 | "displayHeaderFooter" => true, 136 | "printBackground" => true, 137 | "scale" => true, 138 | "paperWidth" => true, 139 | "paperHeight" => true, 140 | "marginTop" => true, 141 | "marginBottom" => true, 142 | "marginLeft" => true, 143 | "marginRight" => true, 144 | "pageRanges" => true, 145 | "preferCSSPageSize" => true 146 | }, 147 | :source_type => true, 148 | "sessionId" => true, 149 | "targetId" => true, 150 | "frameId" => true, 151 | :last_call_id => true, 152 | :wait_for => true, 153 | :evaluate => true, 154 | :size => true, 155 | :init_timeout => true, 156 | :timeout => true, 157 | :offline => true, 158 | :disable_scripts => true, 159 | :max_session_uses => true, 160 | :session_pool => true, 161 | :no_sandbox => true, 162 | :discard_stderr => true, 163 | :chrome_args => true, 164 | :chrome_executable => true, 165 | :ignore_certificate_errors => true, 166 | :ghostscript_pool => true, 167 | :on_demand => true, 168 | :unhandled_runtime_exceptions => true, 169 | :console_api_calls => true, 170 | :__protocol__ => true 171 | } 172 | } 173 | 174 | def inspect(%ChromicPDF.Protocol{} = protocol, opts) do 175 | map = 176 | protocol 177 | |> Map.from_struct() 178 | |> filter(@allowed_values) 179 | 180 | ChromicPDF.Protocol 181 | |> struct!(map) 182 | |> Inspect.Any.inspect(opts) 183 | end 184 | 185 | defp filter(map, allowed) when is_map(map) and is_map(allowed) do 186 | Map.new(map, fn {key, value} -> 187 | case Map.get(allowed, key) do 188 | nil -> {key, @filtered} 189 | true -> {key, value} 190 | nested when is_map(nested) -> {key, filter(value, nested)} 191 | end 192 | end) 193 | end 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/protocol_macros.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.ProtocolMacros do 4 | @moduledoc false 5 | 6 | require Logger 7 | alias ChromicPDF.JsonRPC 8 | 9 | # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity 10 | defmacro steps(do: block) do 11 | quote do 12 | alias ChromicPDF.JsonRPC 13 | alias ChromicPDF.Protocol 14 | 15 | @behaviour Protocol 16 | 17 | Module.register_attribute(__MODULE__, :steps, accumulate: true) 18 | 19 | unquote(block) 20 | 21 | @impl Protocol 22 | def new(opts \\ []) do 23 | Protocol.new( 24 | build_steps(opts), 25 | initial_state(opts) 26 | ) 27 | end 28 | 29 | @impl Protocol 30 | def new(session_id, opts) do 31 | Protocol.new( 32 | build_steps(opts), 33 | initial_state(session_id, opts) 34 | ) 35 | end 36 | 37 | defp initial_state(opts) do 38 | opts 39 | |> Enum.into(%{}) 40 | |> Map.put(:__protocol__, __MODULE__) 41 | end 42 | 43 | defp initial_state(session_id, opts) do 44 | opts 45 | |> initial_state() 46 | |> Map.put("sessionId", session_id) 47 | end 48 | 49 | defp build_steps(opts) do 50 | @steps 51 | |> Enum.reverse() 52 | |> do_build_steps([], opts) 53 | end 54 | 55 | defp do_build_steps([], acc, _opts), do: acc 56 | 57 | defp do_build_steps([:end | rest], acc, opts) do 58 | do_build_steps(rest, acc, opts) 59 | end 60 | 61 | defp do_build_steps([{:if_option, key} | rest], acc, opts) do 62 | if Keyword.has_key?(opts, key) do 63 | do_build_steps(rest, acc, opts) 64 | else 65 | skip_branch(rest, acc, opts) 66 | end 67 | end 68 | 69 | defp do_build_steps([{:if_option, key, value} | rest], acc, opts) do 70 | if Keyword.get(opts, key) in List.wrap(value) do 71 | do_build_steps(rest, acc, opts) 72 | else 73 | skip_branch(rest, acc, opts) 74 | end 75 | end 76 | 77 | defp do_build_steps([{:include_protocol, protocol_mod} | rest], acc, opts) do 78 | do_build_steps(rest, acc ++ protocol_mod.new(opts).steps, opts) 79 | end 80 | 81 | defp do_build_steps([{type, name, arity} | rest], acc, opts) do 82 | do_build_steps( 83 | rest, 84 | acc ++ [{type, Function.capture(__MODULE__, name, arity)}], 85 | opts 86 | ) 87 | end 88 | 89 | defp skip_branch([:end | rest], acc, opts), do: do_build_steps(rest, acc, opts) 90 | defp skip_branch([_skipped | rest], acc, opts), do: skip_branch(rest, acc, opts) 91 | end 92 | end 93 | 94 | defmacro if_option({test_key, test_value}, do: block) do 95 | quote do 96 | @steps {:if_option, unquote(test_key), unquote(test_value)} 97 | unquote(block) 98 | @steps :end 99 | end 100 | end 101 | 102 | defmacro if_option(test_key, do: block) do 103 | quote do 104 | @steps {:if_option, unquote(test_key)} 105 | unquote(block) 106 | @steps :end 107 | end 108 | end 109 | 110 | defmacro include_protocol(protocol_mod) do 111 | quote do 112 | @steps {:include_protocol, unquote(protocol_mod)} 113 | end 114 | end 115 | 116 | defmacro call(name, method, params_from_state, default_params) do 117 | quote do 118 | @steps {:call, unquote(name), 2} 119 | def unquote(name)(state, call_id) do 120 | params = 121 | fetch_params_for_call( 122 | state, 123 | unquote(params_from_state), 124 | unquote(default_params) 125 | ) 126 | 127 | call = 128 | case Map.get(state, "sessionId") do 129 | nil -> {unquote(method), params} 130 | session_id -> {session_id, unquote(method), params} 131 | end 132 | 133 | {Map.put(state, :last_call_id, call_id), call} 134 | end 135 | end 136 | end 137 | 138 | def fetch_params_for_call(state, fun, defaults) when is_function(fun, 1) do 139 | Map.merge(defaults, fun.(state)) 140 | end 141 | 142 | def fetch_params_for_call(state, keys, defaults) when is_list(keys) do 143 | Enum.into(keys, defaults, &fetch_param_for_call(state, &1)) 144 | end 145 | 146 | defp fetch_param_for_call(state, {name, key_path}) do 147 | {name, get_in!(state, key_path)} 148 | end 149 | 150 | defp fetch_param_for_call(state, key) do 151 | fetch_param_for_call(state, {key, key}) 152 | end 153 | 154 | defmacro defawait({name, _, args} = fundef, do: block) do 155 | quote generated: true do 156 | @steps {:await, unquote(name), unquote(length(args))} 157 | 158 | def unquote(fundef) do 159 | with :no_match <- intercept_exception_thrown(unquote_splicing(args)), 160 | :no_match <- intercept_console_api_called(unquote_splicing(args)) do 161 | unquote(block) 162 | end 163 | end 164 | end 165 | end 166 | 167 | defmacro await_response(name, put_keys, do: block) do 168 | quote generated: true do 169 | await_response(unquote(name), unquote(put_keys)) 170 | await_response_callback(unquote(name), do: unquote(block)) 171 | end 172 | end 173 | 174 | defmacro await_response_callback(name, do: block) do 175 | quote generated: true do 176 | def unquote(:"#{name}_callback")(var!(state), var!(msg)) do 177 | unquote(block) 178 | end 179 | end 180 | end 181 | 182 | defmacro await_response(name, put_keys) do 183 | cb_name = :"#{name}_callback" 184 | 185 | quote do 186 | defawait unquote(name)(state, msg) do 187 | last_call_id = Map.fetch!(state, :last_call_id) 188 | 189 | if JsonRPC.response?(msg, last_call_id) do 190 | cond do 191 | function_exported?(__MODULE__, unquote(cb_name), 2) -> 192 | apply(__MODULE__, unquote(cb_name), [state, msg]) 193 | 194 | JsonRPC.is_error?(msg) -> 195 | {:error, JsonRPC.extract_error(msg)} 196 | 197 | true -> 198 | :ok 199 | end 200 | |> case do 201 | :ok -> 202 | state = extract_from_payload(msg, "result", unquote(put_keys), state) 203 | {:match, :remove, state} 204 | 205 | {:error, error} -> 206 | {:error, error} 207 | end 208 | else 209 | :no_match 210 | end 211 | end 212 | end 213 | end 214 | 215 | defmacro await_notification(name, method, match_keys, put_keys) do 216 | quote do 217 | defawait unquote(name)(state, msg) do 218 | with true <- JsonRPC.notification?(msg, unquote(method)), 219 | true <- state["sessionId"] == msg["sessionId"], 220 | true <- Enum.all?(unquote(match_keys), ¬ification_matches?(state, msg, &1)) do 221 | state = extract_from_payload(msg, "params", unquote(put_keys), state) 222 | 223 | {:match, :remove, state} 224 | else 225 | _ -> :no_match 226 | end 227 | end 228 | end 229 | end 230 | 231 | def intercept_exception_thrown(state, msg) do 232 | with true <- JsonRPC.notification?(msg, "Runtime.exceptionThrown"), 233 | true <- state["sessionId"] == msg["sessionId"] do 234 | exception = get_in!(msg, ["params", "exceptionDetails"]) 235 | prefix = get_in(exception, ["text"]) 236 | suffix = get_in(exception, ["exception", "description"]) || "undefined" 237 | 238 | description = "#{prefix} #{suffix}" 239 | 240 | case Map.get(state, :unhandled_runtime_exceptions, :log) do 241 | :ignore -> 242 | {:match, :keep, state} 243 | 244 | :log -> 245 | Logger.warning(""" 246 | [ChromicPDF] Unhandled exception in JS runtime 247 | 248 | #{description} 249 | """) 250 | 251 | {:match, :keep, state} 252 | 253 | :raise -> 254 | {:error, {:exception_thrown, description}} 255 | end 256 | else 257 | _ -> :no_match 258 | end 259 | end 260 | 261 | def intercept_console_api_called(state, msg) do 262 | with true <- JsonRPC.notification?(msg, "Runtime.consoleAPICalled"), 263 | true <- state["sessionId"] == msg["sessionId"] do 264 | type = get_in!(msg, ["params", "type"]) 265 | args = get_in!(msg, ["params", "args"]) |> Jason.encode!(pretty: true) 266 | 267 | case Map.get(state, :console_api_calls, :ignore) do 268 | :ignore -> 269 | {:match, :keep, state} 270 | 271 | :log -> 272 | Logger.warning(""" 273 | [ChromicPDF] console.#{type} called in JS runtime 274 | 275 | #{args} 276 | """) 277 | 278 | {:match, :keep, state} 279 | 280 | :raise -> 281 | {:error, {:console_api_called, {type, args}}} 282 | end 283 | else 284 | _ -> :no_match 285 | end 286 | end 287 | 288 | def extract_from_payload(msg, payload_key, put_keys, state) do 289 | Enum.into( 290 | put_keys, 291 | state, 292 | fn 293 | {path, key} -> {key, get_in!(msg, [payload_key | path])} 294 | key -> {key, get_in!(msg, [payload_key, key])} 295 | end 296 | ) 297 | end 298 | 299 | def notification_matches?(state, msg, {msg_path, key}) do 300 | get_in(msg, ["params" | msg_path]) == get_in!(state, key) 301 | end 302 | 303 | def notification_matches?(state, msg, key), do: notification_matches?(state, msg, {[key], key}) 304 | 305 | defmacro output(key) when is_binary(key) do 306 | quote do 307 | @steps {:output, :output, 1} 308 | def output(state), do: Map.fetch!(state, unquote(key)) 309 | end 310 | end 311 | 312 | defmacro output(keys) when is_list(keys) do 313 | quote do 314 | @steps {:output, :output, 1} 315 | def output(state), do: Map.take(state, unquote(keys)) 316 | end 317 | end 318 | 319 | defp get_in!(map, keys) do 320 | accessor = 321 | keys 322 | |> List.wrap() 323 | |> Enum.map(&Access.key!(&1)) 324 | 325 | get_in(map, accessor) 326 | end 327 | end 328 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/protocols/capture_screenshot.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.CaptureScreenshot do 4 | @moduledoc false 5 | 6 | import ChromicPDF.ProtocolMacros 7 | 8 | steps do 9 | include_protocol(ChromicPDF.Navigate) 10 | 11 | if_option :full_page do 12 | call(:get_layout_metrics, "Page.getLayoutMetrics", [], %{}) 13 | await_response(:layout_metrics_got, ["cssContentSize"]) 14 | 15 | call( 16 | :set_device_metrics_override, 17 | "Emulation.setDeviceMetricsOverride", 18 | [ 19 | {"width", ["cssContentSize", "width"]}, 20 | {"height", ["cssContentSize", "height"]} 21 | ], 22 | %{"mobile" => false, "deviceScaleFactor" => 1} 23 | ) 24 | 25 | await_response(:device_metrics_override_set, []) 26 | end 27 | 28 | call(:capture, "Page.captureScreenshot", &Map.get(&1, :capture_screenshot, %{}), %{}) 29 | await_response(:captured, ["data"]) 30 | 31 | include_protocol(ChromicPDF.ResetTarget) 32 | 33 | output("data") 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/protocols/close_target.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.CloseTarget do 4 | @moduledoc false 5 | 6 | import ChromicPDF.ProtocolMacros 7 | 8 | steps do 9 | call(:close_target, "Target.closeTarget", [:targetId], %{}) 10 | await_response(:target_closed, ["success"]) 11 | output("success") 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/protocols/navigate.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.Navigate do 4 | @moduledoc false 5 | 6 | import ChromicPDF.ProtocolMacros 7 | 8 | steps do 9 | if_option :set_cookie do 10 | call(:set_cookie, "Network.setCookie", &Map.fetch!(&1, :set_cookie), %{httpOnly: true}) 11 | 12 | await_response(:cookie_set, []) 13 | end 14 | 15 | if_option :timezone do 16 | call( 17 | :timezone, 18 | "Emulation.setTimezoneOverride", 19 | &%{"timezoneId" => Map.fetch!(&1, :timezone)}, 20 | %{} 21 | ) 22 | 23 | await_response(:timezone_set, []) 24 | end 25 | 26 | if_option {:source_type, :html} do 27 | call(:get_frame_tree, "Page.getFrameTree", [], %{}) 28 | await_response(:frame_tree, [{["frameTree", "frame", "id"], "frameId"}]) 29 | call(:set_content, "Page.setDocumentContent", [:html, "frameId"], %{}) 30 | await_response(:content_set, []) 31 | await_notification(:page_load_event, "Page.loadEventFired", [], []) 32 | end 33 | 34 | if_option {:source_type, :url} do 35 | call(:navigate, "Page.navigate", [:url], %{}) 36 | 37 | await_response(:navigated, ["frameId"]) do 38 | case get_in(msg, ["result", "errorText"]) do 39 | nil -> 40 | :ok 41 | 42 | error -> 43 | {:error, error} 44 | end 45 | end 46 | 47 | await_notification(:frame_stopped_loading, "Page.frameStoppedLoading", ["frameId"], []) 48 | end 49 | 50 | if_option :evaluate do 51 | call(:evaluate, "Runtime.evaluate", [{"expression", [:evaluate, :expression]}], %{ 52 | awaitPromise: true 53 | }) 54 | 55 | await_response(:evaluated, []) do 56 | case get_in(msg, ["result", "exceptionDetails"]) do 57 | nil -> 58 | :ok 59 | 60 | error -> 61 | {:error, {:evaluate, error}} 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/protocols/print_to_pdf.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.PrintToPDF do 4 | @moduledoc false 5 | 6 | import ChromicPDF.ProtocolMacros 7 | 8 | steps do 9 | include_protocol(ChromicPDF.Navigate) 10 | 11 | call(:print_to_pdf, "Page.printToPDF", &Map.get(&1, :print_to_pdf, %{}), %{}) 12 | await_response(:printed, ["data"]) 13 | 14 | include_protocol(ChromicPDF.ResetTarget) 15 | 16 | output("data") 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/protocols/reset_target.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.ResetTarget do 4 | @moduledoc false 5 | 6 | import ChromicPDF.ProtocolMacros 7 | import ChromicPDF.Utils, only: [priv_asset: 1] 8 | 9 | defp blank_url do 10 | "file://#{priv_asset("blank.html")}" 11 | end 12 | 13 | steps do 14 | call(:reset_history, "Page.resetNavigationHistory", [], %{}) 15 | await_response(:history_reset, []) 16 | 17 | if_option :set_cookie do 18 | call(:clear_cookies, "Network.clearBrowserCookies", [], %{}) 19 | await_response(:cleared, []) 20 | end 21 | 22 | call(:blank, "Page.navigate", [], %{"url" => blank_url()}) 23 | await_response(:blanked, ["frameId"]) 24 | await_notification(:fsl_after_blank, "Page.frameStoppedLoading", ["frameId"], []) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdf/protocols/spawn_session.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.SpawnSession do 4 | @moduledoc false 5 | 6 | import ChromicPDF.ProtocolMacros 7 | 8 | @version Mix.Project.config()[:version] 9 | 10 | steps do 11 | call(:create_browser_context, "Target.createBrowserContext", [], %{"disposeOnDetach" => true}) 12 | await_response(:browser_context_created, ["browserContextId"]) 13 | 14 | call(:create_target, "Target.createTarget", ["browserContextId"], %{"url" => "about:blank"}) 15 | await_response(:target_created, ["targetId"]) 16 | 17 | call(:attach, "Target.attachToTarget", ["targetId"], %{"flatten" => true}) 18 | 19 | await_notification( 20 | :attached, 21 | "Target.attachedToTarget", 22 | [{["targetInfo", "targetId"], "targetId"}], 23 | ["sessionId"] 24 | ) 25 | 26 | call(:set_user_agent, "Emulation.setUserAgentOverride", [], %{ 27 | "userAgent" => "ChromicPDF #{@version}" 28 | }) 29 | 30 | if_option {:offline, true} do 31 | call( 32 | :offline_mode, 33 | "Network.emulateNetworkConditions", 34 | [], 35 | %{ 36 | "offline" => true, 37 | "latency" => 0, 38 | "downloadThroughput" => 0, 39 | "uploadThroughput" => 0 40 | } 41 | ) 42 | 43 | # Intentionally not awaiting the response to speed up session spawning. 44 | end 45 | 46 | # Enable Runtime (JS) events, mostly for Runtime.exceptionThrown. 47 | # Again, no need to wait for result. 48 | if_option {:unhandled_runtime_exceptions, [:log, :raise]} do 49 | call(:runtime_enable, "Runtime.enable", [], %{}) 50 | end 51 | 52 | if_option :disable_scripts do 53 | call( 54 | :disable_scripts, 55 | "Emulation.setScriptExecutionDisabled", 56 | [{"value", :disable_scripts}], 57 | %{} 58 | ) 59 | 60 | # Intentionally not awaiting the response to speed up session spawning. 61 | end 62 | 63 | if_option {:ignore_certificate_errors, true} do 64 | call(:ignore_certificate_errors, "Security.setIgnoreCertificateErrors", [], %{ 65 | "ignore" => true 66 | }) 67 | 68 | await_response(:certificate_errors_ignored, []) 69 | end 70 | 71 | call(:enable_page, "Page.enable", [], %{}) 72 | await_response(:page_enabled, []) 73 | 74 | include_protocol(ChromicPDF.ResetTarget) 75 | 76 | output(["targetId", "sessionId"]) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdfa/PDFA_def.ps.eex: -------------------------------------------------------------------------------- 1 | %! 2 | % This is a sample prefix file for creating a PDF/A document. 3 | % 4 | % Ripped from Ghostscript 9.22's documentation. 5 | % https://www.ghostscript.com/doc/lib/PDFA_def.ps 6 | % 7 | % Stripped and customized. 8 | 9 | [ 10 | <%= if @title do %> 11 | /Title (<%= @title %>) 12 | <% end %> 13 | <%= if @author do %> 14 | /Author (<%= @author %>) 15 | <% end %> 16 | <%= if @subject do %> 17 | /Subject (<%= @subject %>) 18 | <% end %> 19 | <%= if @keywords do %> 20 | /Keywords (<%= @keywords %>) 21 | <% end %> 22 | <%= if @creator do %> 23 | /Creator (<%= @creator %>) 24 | <% end %> 25 | <%= if @creation_date do %> 26 | /CreationDate (<%= @creation_date %>) 27 | <% end %> 28 | <%= if @mod_date do %> 29 | /ModDate (<%= @mod_date %>) 30 | <% end %> 31 | <%= if @trapped do %> 32 | /Trapped <%= @trapped %> 33 | <% end %> 34 | 35 | /DOCINFO pdfmark 36 | 37 | /ICCProfile (<%= @eci_icc %>) 38 | 39 | def 40 | [/_objdef {icc_PDFA} /type /stream /OBJ pdfmark 41 | 42 | [{icc_PDFA} << /N 3 >> /PUT pdfmark 43 | [{icc_PDFA} ICCProfile (r) file /PUT pdfmark 44 | 45 | [/_objdef {OutputIntent_PDFA} /type /dict /OBJ pdfmark 46 | [{OutputIntent_PDFA} << 47 | /Type /OutputIntent % Must be so (the standard requires). 48 | /S /GTS_PDFA1 % Must be so (the standard requires). 49 | /DestOutputProfile {icc_PDFA} % Must be so (see above). 50 | /OutputConditionIdentifier (sRGB) % Customize 51 | >> /PUT pdfmark 52 | [{Catalog} <> /PUT pdfmark 53 | 54 | <%= @pdfa_def_ext %> 55 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdfa/ghostscript_pool.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.GhostscriptPool do 4 | @moduledoc false 5 | 6 | @behaviour NimblePool 7 | 8 | import ChromicPDF.Utils, only: [default_pool_size: 0] 9 | alias ChromicPDF.GhostscriptWorker 10 | 11 | # ------------- API ---------------- 12 | 13 | @spec child_spec(keyword()) :: Supervisor.child_spec() 14 | def child_spec(args) do 15 | %{ 16 | id: __MODULE__, 17 | start: {__MODULE__, :start_link, [args]} 18 | } 19 | end 20 | 21 | @spec start_link(Keyword.t()) :: GenServer.on_start() 22 | def start_link(args) do 23 | NimblePool.start_link( 24 | worker: {__MODULE__, args}, 25 | pool_size: pool_size(args) 26 | ) 27 | end 28 | 29 | defp pool_size(args) do 30 | get_in(args, [:ghostscript_pool, :size]) || default_pool_size() 31 | end 32 | 33 | # Converts a PDF to PDF-A/2 using Ghostscript. 34 | @spec convert(pid(), binary(), keyword(), binary()) :: :ok 35 | def convert(pool, pdf_path, params, output_path) do 36 | NimblePool.checkout!(pool, :checkout, fn _from, _worker_state -> 37 | {GhostscriptWorker.convert(pdf_path, params, output_path), :ok} 38 | end) 39 | end 40 | 41 | # Concatenates multiple PDF files using Ghostscript. 42 | @spec join(pid(), list(binary()), keyword(), binary()) :: :ok 43 | def join(pool, pdf_paths, params, output_path) do 44 | NimblePool.checkout!(pool, :checkout, fn _from, _worker_state -> 45 | {GhostscriptWorker.join(pdf_paths, params, output_path), :ok} 46 | end) 47 | end 48 | 49 | # ------------ Callbacks ----------- 50 | 51 | @impl NimblePool 52 | def init_worker(pool_state) do 53 | {:ok, nil, pool_state} 54 | end 55 | 56 | @impl NimblePool 57 | def handle_checkout(:checkout, _from, worker_state, pool_state) do 58 | {:ok, worker_state, worker_state, pool_state} 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdfa/ghostscript_runner.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.GhostscriptRunner do 4 | @moduledoc false 5 | 6 | import ChromicPDF.Utils, only: [semver_compare: 2, system_cmd!: 3, with_app_config_cache: 2] 7 | 8 | @default_args [ 9 | "-sstdout=/dev/null", 10 | "-dQUIET", 11 | "-dBATCH", 12 | "-dNOPAUSE", 13 | "-dNOOUTERSAVE" 14 | ] 15 | 16 | @pdfwrite_default_args [ 17 | "-sDEVICE=pdfwrite", 18 | "-dEmbedAllFonts=true", 19 | "-dSubsetFonts=true", 20 | "-dCompressFonts=true", 21 | "-dCompressPages=true", 22 | "-dDownsampleMonoImages=false", 23 | "-dDownsampleGrayImages=false", 24 | "-dDownsampleColorImages=false", 25 | "-dAutoFilterColorImages=false", 26 | "-dAutoFilterGrayImages=false" 27 | ] 28 | 29 | @ghostscript_bin "gs" 30 | @ghostscript_safer_version [9, 28] 31 | @ghostscript_new_interpreter_version {[9, 56], [10, 2]} 32 | 33 | @spec run_postscript(binary(), binary()) :: binary() 34 | def run_postscript(pdf_path, ps_path) do 35 | ghostscript_cmd!(%{ 36 | read: [pdf_path, ps_path], 37 | write: [], 38 | args: [ 39 | @default_args, 40 | "-dNODISPLAY", 41 | "-q", 42 | "-sFile=#{pdf_path}", 43 | ps_path 44 | ] 45 | }) 46 | end 47 | 48 | @spec pdfwrite([binary()], binary()) :: :ok 49 | @spec pdfwrite([binary()], binary(), keyword()) :: :ok 50 | def pdfwrite(source_paths, output_path, opts \\ []) do 51 | %{ 52 | read: source_paths, 53 | write: [output_path], 54 | args: [ 55 | @default_args, 56 | @pdfwrite_default_args, 57 | "-sOutputFile=#{output_path}", 58 | source_paths 59 | ] 60 | } 61 | |> maybe_add_compatibility_level(opts) 62 | |> maybe_add_pdfa_args(opts) 63 | |> add_user_permit_reads(opts) 64 | |> ghostscript_cmd!() 65 | 66 | :ok 67 | end 68 | 69 | defp maybe_add_compatibility_level(command, opts) do 70 | if compatibility_level = Keyword.get(opts, :compatibility_level) do 71 | %{command | args: ["-dCompatibilityLevel=#{compatibility_level}" | command.args]} 72 | else 73 | command 74 | end 75 | end 76 | 77 | defp maybe_add_pdfa_args(command, opts) do 78 | if pdfa_opts = Keyword.get(opts, :pdfa) do 79 | version = Keyword.fetch!(pdfa_opts, :version) 80 | icc_path = Keyword.fetch!(pdfa_opts, :icc_path) 81 | 82 | args = [ 83 | "-sOutputICCProfile=#{icc_path}", 84 | "-sProcessColorModel=DeviceRGB", 85 | "-sColorConversionStrategy=RGB", 86 | "-dPDFA=#{version}", 87 | # http://git.ghostscript.com/?p=ghostpdl.git;a=commitdiff;h=094d5a1880f1cb9ed320ca9353eb69436e09b594 88 | "-dPDFACompatibilityPolicy=1" 89 | ] 90 | 91 | %{command | read: [icc_path | command.read], args: args ++ command.args} 92 | else 93 | command 94 | end 95 | end 96 | 97 | defp add_user_permit_reads(command, opts) do 98 | values = Keyword.get(opts, :permit_read, []) 99 | 100 | %{command | read: values ++ command.read} 101 | end 102 | 103 | defp ghostscript_cmd!(command) do 104 | args = 105 | List.flatten([ 106 | maybe_safer_args(command), 107 | maybe_disable_new_interpreter(), 108 | command.args 109 | ]) 110 | 111 | system_cmd!(ghostscript_executable(), args, []) 112 | end 113 | 114 | defp maybe_safer_args(command) do 115 | if semver_compare(ghostscript_version(), @ghostscript_safer_version) in [:eq, :gt] do 116 | [ 117 | "-dSAFER", 118 | Enum.map(command.read, &"--permit-file-read=#{&1}"), 119 | Enum.map(command.write, &"--permit-file-write=#{&1}") 120 | ] 121 | else 122 | [] 123 | end 124 | end 125 | 126 | defp maybe_disable_new_interpreter do 127 | {bad, good} = @ghostscript_new_interpreter_version 128 | 129 | if semver_compare(ghostscript_version(), bad) in [:eq, :gt] && 130 | semver_compare(ghostscript_version(), good) == :lt do 131 | # We get segmentation faults with the new intepreter (see https://github.com/bitcrowd/chromic_pdf/issues/153): 132 | # 133 | # /usr/bin/gs exited with status 139! 134 | # 135 | # Ghostscript provides us with a workaround until they iron out all the issues: 136 | # 137 | # > In this (9.56.0) release, the new PDF interpreter is now ENABLED by default in Ghostscript, 138 | # > but the old PDF interpreter can be used as a fallback by specifying -dNEWPDF=false. We've 139 | # > provided this so users that encounter issues with the new interpreter can keep working while 140 | # > we iron out those issues, the option will not be available in the long term. 141 | ["-dNEWPDF=false"] 142 | else 143 | [] 144 | end 145 | end 146 | 147 | defp ghostscript_executable do 148 | System.find_executable(@ghostscript_bin) || raise("could not find ghostscript") 149 | end 150 | 151 | defp ghostscript_version do 152 | with_app_config_cache(:ghostscript_version, &do_ghostscript_version/0) 153 | end 154 | 155 | defp do_ghostscript_version do 156 | output = system_cmd!(ghostscript_executable(), ["-v"], stderr_to_stdout: true) 157 | [version] = Regex.run(~r/\d+\.\d+/, output) 158 | version 159 | rescue 160 | e -> 161 | reraise( 162 | """ 163 | Failed to determine Ghostscript version number! (#{e.__struct__}) 164 | 165 | --- original exception -- 166 | 167 | #{Exception.format(:error, e, __STACKTRACE__)} 168 | """, 169 | __STACKTRACE__ 170 | ) 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /lib/chromic_pdf/pdfa/ghostscript_worker.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.GhostscriptWorker do 4 | @moduledoc false 5 | 6 | require EEx 7 | import ChromicPDF.Utils 8 | alias ChromicPDF.GhostscriptRunner 9 | 10 | @psdef_ps Path.expand("../PDFA_def.ps.eex", __ENV__.file) 11 | @external_resource @psdef_ps 12 | 13 | @pdfinfo_keys %{ 14 | "__knowninfoTitle" => :title, 15 | "__knowninfoAuthor" => :author, 16 | "__knowninfoSubject" => :subject, 17 | "__knowninfoKeywords" => :keywords, 18 | "__knowninfoCreator" => :creator, 19 | "__knowninfoCreationDate" => :creation_date, 20 | "__knowninfoModDate" => :mod_date, 21 | "__knowninfoTrapped" => :trapped 22 | } 23 | 24 | @spec convert(binary(), keyword(), binary()) :: :ok 25 | def convert(pdf_path, params, output_path) do 26 | pdf_path = Path.expand(pdf_path) 27 | pdfa_def_ps_path = Path.join(Path.dirname(output_path), random_file_name(".ps")) 28 | 29 | create_pdfa_def_ps!(pdf_path, params, pdfa_def_ps_path) 30 | convert_to_pdfa!(pdf_path, params, pdfa_def_ps_path, output_path) 31 | 32 | :ok 33 | end 34 | 35 | @spec join(list(binary()), keyword(), binary()) :: :ok 36 | def join(pdf_paths, _params, output_path) do 37 | GhostscriptRunner.pdfwrite(pdf_paths, output_path) 38 | 39 | :ok 40 | end 41 | 42 | EEx.function_from_file(:defp, :render_pdfa_def_ps, @psdef_ps, [:assigns]) 43 | 44 | defp create_pdfa_def_ps!(pdf_path, params, pdfa_def_ps_path) do 45 | info = Keyword.get(params, :info, %{}) 46 | pdfa_def_ext = Keyword.get(params, :pdfa_def_ext) 47 | 48 | rendered = 49 | pdf_path 50 | |> pdfinfo() 51 | |> Map.merge(info) 52 | |> Enum.into(%{}, &cast_info_value/1) 53 | |> Map.put(:eci_icc, priv_asset("eciRGB_v2.icc")) 54 | |> Map.put(:pdfa_def_ext, pdfa_def_ext) 55 | |> render_pdfa_def_ps() 56 | 57 | File.write!(pdfa_def_ps_path, rendered) 58 | end 59 | 60 | defp convert_to_pdfa!(pdf_path, params, pdfa_def_ps_path, output_path) do 61 | paths = [ 62 | pdf_path, 63 | pdfa_def_ps_path 64 | ] 65 | 66 | opts = [ 67 | pdfa: [ 68 | version: Keyword.get(params, :pdfa_version, 3), 69 | icc_path: priv_asset("eciRGB_v2.icc") 70 | ], 71 | permit_read: Keyword.get_values(params, :permit_read), 72 | compatibility_level: Keyword.get(params, :compatibility_level) 73 | ] 74 | 75 | :ok = GhostscriptRunner.pdfwrite(paths, output_path, opts) 76 | end 77 | 78 | defp pdfinfo(pdf_path) do 79 | infos_from_file = 80 | pdf_path 81 | |> GhostscriptRunner.run_postscript(priv_asset("pdfinfo.ps")) 82 | |> String.trim() 83 | |> String.split("\n") 84 | |> Enum.map(&parse_info_line/1) 85 | |> Enum.reject(&is_nil/1) 86 | |> Enum.into(%{}) 87 | 88 | Enum.into( 89 | @pdfinfo_keys, 90 | %{}, 91 | fn {ext, int} -> {int, Map.get(infos_from_file, ext, "")} end 92 | ) 93 | end 94 | 95 | defp parse_info_line(line) do 96 | if String.contains?(line, ": ") do 97 | [key, value] = String.split(line, ": ", parts: 2) 98 | 99 | if Map.has_key?(@pdfinfo_keys, key) do 100 | {key, value} 101 | end 102 | end 103 | end 104 | 105 | defp cast_info_value({:trapped, value}) do 106 | cast = 107 | case String.downcase(value) do 108 | "/true" -> "/True" 109 | "/false" -> "/False" 110 | _ -> nil 111 | end 112 | 113 | {:trapped, cast} 114 | end 115 | 116 | defp cast_info_value({key, %DateTime{} = value}) do 117 | {key, to_postscript_date(value)} 118 | end 119 | 120 | defp cast_info_value(other), do: other 121 | end 122 | -------------------------------------------------------------------------------- /lib/chromic_pdf/plug.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Plug.Crypto) do 2 | defmodule ChromicPDF.Plug do 3 | @moduledoc """ 4 | This module implements a "request forwarding" mechanism from an internal endpoint serving 5 | incoming requests by Chrome to the `print_to_pdf/2` caller process. 6 | 7 | ## Usage 8 | 9 | In your router: 10 | 11 | forward "/makepdf", ChromicPDF.Plug 12 | 13 | On the caller side: 14 | 15 | ChromicPDF.print_to_pdf( 16 | {:plug, 17 | url: "http://localhost:4000/makepdf", 18 | forward: {MyTemplate, :render, [%{hello: :world}] 19 | } 20 | ) 21 | 22 | defmodule MyTemplate do 23 | def render(conn, assigns) do 24 | # send response via conn (and return conn) or return content to be sent by the plug 25 | end 26 | end 27 | """ 28 | 29 | defmodule MissingCookieError do 30 | @moduledoc false 31 | defexception [:message, plug_status: 403] 32 | end 33 | 34 | defmodule InvalidCookieError do 35 | @moduledoc false 36 | defexception [:message, plug_status: 403] 37 | end 38 | 39 | @behaviour Plug 40 | 41 | import ChromicPDF.Utils, only: [rendered_to_iodata: 1] 42 | alias Plug.{Conn, Crypto} 43 | 44 | # max age of a "session", i.e. time between print_to_pdf and incoming request from Chrome. 45 | # This needs to be greater than the time it takes from the `print_to_pdf/2` call to the 46 | # incoming request from chrome. Should be around queue wait time of the job + a constant bit 47 | # for the navigation & network. Could be made dependent on `checkout_timeout` at some point. 48 | # We just set it to a long value for now to get it out of the way. 49 | @max_age 600 50 | 51 | # "secret_key_base" is generated at compile-time which ties the running Chrome instance 52 | # to the compiled module. Potentially this loses requests at the edges when using 53 | # _external_ chrome instances (i.e. accessed via TCP) in a clustered environment, or when 54 | # hot deploying the application. Waiting for this unlikely issue to arise before making this 55 | # configurable / persistent between builds. 56 | @secret_key_base :crypto.strong_rand_bytes(32) 57 | 58 | # Salt is irrelevant. 59 | @salt :crypto.strong_rand_bytes(8) 60 | 61 | @cookie "chromic_pdf_cookie" 62 | 63 | @doc false 64 | @spec start_agent_and_get_cookie(keyword) :: map 65 | def start_agent_and_get_cookie(job_opts) do 66 | value = 67 | job_opts 68 | |> start_agent() 69 | |> sign_and_encode() 70 | 71 | %{name: @cookie, value: value} 72 | end 73 | 74 | defp start_agent(job_opts) do 75 | ref = make_ref() 76 | 77 | {:ok, pid} = Agent.start_link(fn -> {ref, job_opts} end) 78 | 79 | :erlang.term_to_binary({pid, ref}) 80 | end 81 | 82 | defp sign_and_encode(token) do 83 | signed = Crypto.sign(@secret_key_base, @salt, token) 84 | 85 | {:v1, signed} 86 | |> :erlang.term_to_binary() 87 | |> Base.url_encode64() 88 | end 89 | 90 | @impl Plug 91 | def init(opts), do: opts 92 | 93 | @impl Plug 94 | def call(conn, opts) do 95 | case conn.req_cookies do 96 | %{@cookie => cookie} -> 97 | cookie 98 | |> decode_and_verify() 99 | |> fetch_from_agent() 100 | |> forward(conn) 101 | 102 | %Conn.Unfetched{} -> 103 | # Custom endpoints may not have the cookies fetched. 104 | conn 105 | |> Conn.fetch_cookies() 106 | |> call(opts) 107 | 108 | _ -> 109 | raise MissingCookieError 110 | end 111 | end 112 | 113 | defp decode_and_verify(encoded) do 114 | with {:ok, binary} <- Base.url_decode64(encoded), 115 | {:ok, term} <- safe_binary_to_term(binary), 116 | {:v1, signed} <- term, 117 | {:ok, token} <- Crypto.verify(@secret_key_base, @salt, signed, max_age: @max_age) do 118 | token 119 | else 120 | _ -> 121 | raise InvalidCookieError, "cookie was invalid or contained invalid or expired signature" 122 | end 123 | end 124 | 125 | defp safe_binary_to_term(binary) do 126 | {:ok, Crypto.non_executable_binary_to_term(binary, [:safe])} 127 | rescue 128 | ArgumentError -> :error 129 | end 130 | 131 | defp fetch_from_agent(token) do 132 | # No need to safely decode this as it was signed. 133 | {pid, ref} = :erlang.binary_to_term(token) 134 | 135 | # Likewise, no need for secure_compare as authenticity is already established. 136 | {^ref, job_opts} = Agent.get(pid, & &1) 137 | 138 | # Prevent process accumulation in case the client process is reused. 139 | Agent.stop(pid) 140 | 141 | job_opts 142 | end 143 | 144 | defp forward(job_opts, conn) do 145 | job_opts 146 | |> Keyword.fetch!(:forward) 147 | |> do_forward(conn) 148 | |> case do 149 | %Conn{} = conn -> 150 | conn 151 | 152 | value -> 153 | conn 154 | |> Conn.put_resp_content_type("text/html") 155 | |> Conn.send_resp(200, rendered_to_iodata(value)) 156 | end 157 | end 158 | 159 | defp do_forward(f, conn) when is_function(f) do 160 | f.(conn) 161 | end 162 | 163 | defp do_forward({m, f, a}, conn) when is_atom(m) and is_atom(f) and is_list(a) do 164 | apply(m, f, [conn | a]) 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /lib/chromic_pdf/utils.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.Utils do 4 | @moduledoc false 5 | 6 | @dev_pool_size Application.compile_env(:chromic_pdf, :dev_pool_size) 7 | @chars String.codepoints("abcdefghijklmnopqrstuvwxyz0123456789") 8 | 9 | @spec random_file_name(binary()) :: binary() 10 | def random_file_name(ext \\ "") do 11 | @chars 12 | |> Enum.shuffle() 13 | |> Enum.take(12) 14 | |> Enum.join() 15 | |> Kernel.<>(ext) 16 | end 17 | 18 | @spec with_tmp_dir((binary() -> any())) :: any() 19 | def with_tmp_dir(cb) do 20 | path = 21 | Path.join( 22 | System.tmp_dir!(), 23 | random_file_name() 24 | ) 25 | 26 | File.mkdir!(path) 27 | 28 | try do 29 | cb.(path) 30 | after 31 | File.rm_rf!(path) 32 | end 33 | end 34 | 35 | @spec to_postscript_date(DateTime.t()) :: binary() 36 | def to_postscript_date(%DateTime{} = value) do 37 | date = 38 | [:year, :month, :day, :hour, :minute, :second] 39 | |> Enum.map(&Map.fetch!(value, &1)) 40 | |> Enum.map_join(&pad_two_digits/1) 41 | 42 | "D:#{date}+#{pad_two_digits(value.utc_offset)}'00'" 43 | end 44 | 45 | defp pad_two_digits(i) do 46 | String.pad_leading(to_string(i), 2, "0") 47 | end 48 | 49 | @spec system_cmd!(binary(), [binary()]) :: binary() 50 | @spec system_cmd!(binary(), [binary()], keyword()) :: binary() 51 | def system_cmd!(cmd, args, opts \\ []) do 52 | case System.cmd(cmd, args, opts) do 53 | {output, 0} -> 54 | output 55 | 56 | {output, exit_status} -> 57 | raise(""" 58 | #{cmd} exited with status #{exit_status}! 59 | 60 | #{output} 61 | """) 62 | end 63 | end 64 | 65 | @spec find_supervisor_child!(pid() | atom(), module()) :: pid() | no_return() 66 | def find_supervisor_child!(supervisor, module) when is_atom(supervisor) or is_pid(supervisor) do 67 | find_supervisor_child(supervisor, module) || 68 | raise("can't find #{module} child of supervisor #{inspect(supervisor)}") 69 | end 70 | 71 | @spec find_supervisor_child(pid() | atom(), module()) :: pid() | nil 72 | def find_supervisor_child(supervisor, module) do 73 | supervisor 74 | |> supervisor_children(module) 75 | |> case do 76 | [] -> nil 77 | [{_id, pid}] -> pid 78 | end 79 | end 80 | 81 | @spec supervisor_children(pid() | atom(), module()) :: [{term(), pid()}] 82 | def supervisor_children(supervisor, module) when is_atom(supervisor) do 83 | supervisor 84 | |> Process.whereis() 85 | |> supervisor_children(module) 86 | end 87 | 88 | def supervisor_children(supervisor, module) when is_pid(supervisor) do 89 | supervisor 90 | |> Supervisor.which_children() 91 | |> Enum.filter(fn {_, _, _, [mod | _]} -> mod == module end) 92 | |> Enum.map(fn {id, pid, _, _} -> {id, pid} end) 93 | end 94 | 95 | @spec priv_asset(binary()) :: binary() 96 | def priv_asset(filename) do 97 | Path.join([Application.app_dir(:chromic_pdf), "priv", filename]) 98 | end 99 | 100 | @spec default_pool_size() :: non_neg_integer() 101 | 102 | if @dev_pool_size do 103 | def default_pool_size, do: @dev_pool_size 104 | else 105 | def default_pool_size, do: max(div(System.schedulers_online(), 2), 1) 106 | end 107 | 108 | @spec rendered_to_binary(iodata | tuple | struct) :: binary 109 | def rendered_to_binary(rendered) do 110 | rendered 111 | |> rendered_to_iodata() 112 | |> :erlang.iolist_to_binary() 113 | end 114 | 115 | @spec rendered_to_iodata(iodata | tuple | struct) :: iodata 116 | def rendered_to_iodata(value) when is_binary(value) or is_list(value), do: value 117 | 118 | if Code.ensure_loaded?(Phoenix.HTML.Safe) do 119 | # credo:disable-for-next-line Credo.Check.Design.AliasUsage 120 | def rendered_to_iodata(value), do: Phoenix.HTML.Safe.to_iodata(value) 121 | end 122 | 123 | @spec with_app_config_cache(atom, function) :: any 124 | def with_app_config_cache(key, function) do 125 | case Application.get_env(:chromic_pdf, key) do 126 | nil -> 127 | result = function.() 128 | Application.put_env(:chromic_pdf, key, result) 129 | result 130 | 131 | value -> 132 | value 133 | end 134 | end 135 | 136 | @spec semver_compare(binary, list) :: :lt | :eq | :gt 137 | def semver_compare(x, y) do 138 | x 139 | |> String.trim() 140 | |> String.split(".") 141 | |> Enum.map(&String.to_integer/1) 142 | |> Enum.zip(y) 143 | |> do_semver_compare() 144 | end 145 | 146 | defp do_semver_compare([]), do: :eq 147 | defp do_semver_compare([{x, y} | _rest]) when x < y, do: :lt 148 | defp do_semver_compare([{x, y} | _rest]) when x > y, do: :gt 149 | defp do_semver_compare([{x, y} | rest]) when x == y, do: do_semver_compare(rest) 150 | end 151 | -------------------------------------------------------------------------------- /lib/mix/tasks/chromic_pdf.warm_up.ex: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule Mix.Tasks.ChromicPdf.WarmUp do 4 | @moduledoc """ 5 | Runs a one-off Chrome process to allow Chrome to initialize its caches. 6 | 7 | This function mitigates timeout errors on certain CI environments where Chrome would 8 | occasionally take a long time to respond to the first DevTools commands. 9 | 10 | See `ChromicPDF.warm_up/1` for details. 11 | """ 12 | 13 | use Mix.Task 14 | 15 | @doc false 16 | @shortdoc "Launches a one-off Chrome process to warm up Chrome's caches" 17 | @spec run(any()) :: :ok 18 | def run(_) do 19 | {usec, _} = :timer.tc(fn -> ChromicPDF.warm_up() end) 20 | 21 | IO.puts("[ChromicPDF] Chrome warm-up finished in #{trunc(usec / 1_000)}ms.") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPdf.MixProject do 4 | use Mix.Project 5 | 6 | @source_url "https://github.com/bitcrowd/chromic_pdf" 7 | @version "1.17.0" 8 | 9 | def project do 10 | [ 11 | app: :chromic_pdf, 12 | version: @version, 13 | elixir: "~> 1.11", 14 | start_permanent: Mix.env() == :prod, 15 | dialyzer: [ 16 | plt_add_apps: [:ex_unit, :mix, :websockex, :inets, :phoenix_html, :plug, :plug_crypto], 17 | plt_file: {:no_warn, ".plts/dialyzer.plt"} 18 | ], 19 | elixirc_paths: elixirc_paths(Mix.env()), 20 | deps: deps(), 21 | aliases: aliases(), 22 | 23 | # hex.pm 24 | package: package(), 25 | 26 | # hexdocs.pm 27 | name: "ChromicPDF", 28 | source_url: @source_url, 29 | homepage_url: @source_url, 30 | docs: docs() 31 | ] 32 | end 33 | 34 | def application do 35 | [ 36 | extra_applications: [:eex, :inets, :logger] 37 | ] 38 | end 39 | 40 | defp package do 41 | [ 42 | description: "Fast HTML-2-PDF/A renderer based on Chrome & Ghostscript", 43 | maintainers: ["@bitcrowd"], 44 | licenses: ["Apache-2.0"], 45 | links: %{ 46 | Changelog: "https://hexdocs.pm/chromic_pdf/changelog.html", 47 | GitHub: @source_url 48 | } 49 | ] 50 | end 51 | 52 | defp elixirc_paths(:dev), do: ["lib"] 53 | defp elixirc_paths(:test), do: ["lib", "test"] 54 | defp elixirc_paths(:prod), do: ["lib"] 55 | 56 | defp docs do 57 | [ 58 | source_ref: "v#{@version}", 59 | main: "ChromicPDF", 60 | logo: "assets/icon.png", 61 | extras: [ 62 | "README.md": [title: "Read Me"], 63 | "CHANGELOG.md": [title: "Changelog"], 64 | LICENSE: [title: "License"] 65 | ], 66 | skip_undefined_reference_warnings_on: ["CHANGELOG.md"], 67 | formatters: ["html"], 68 | assets: "assets" 69 | ] 70 | end 71 | 72 | defp deps do 73 | [ 74 | {:jason, "~> 1.1"}, 75 | {:nimble_pool, "~> 0.2 or ~> 1.0"}, 76 | {:plug, "~> 1.11", optional: true}, 77 | {:plug_crypto, "~> 1.2 or ~> 2.0", optional: true}, 78 | {:phoenix_html, "~> 2.14 or ~> 3.3 or ~> 4.0", optional: true}, 79 | {:telemetry, "~> 0.4 or ~> 1.0"}, 80 | {:websockex, ">= 0.4.3", optional: true}, 81 | {:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false}, 82 | {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, 83 | {:ex_doc, ">= 0.0.0", only: [:test, :dev], runtime: false}, 84 | {:junit_formatter, "~> 3.1", only: [:test]}, 85 | {:bandit, "~> 0.5.11", only: [:test]} 86 | ] 87 | end 88 | 89 | defp aliases do 90 | [ 91 | lint: [ 92 | "format --check-formatted", 93 | "credo --strict", 94 | "dialyzer --format dialyxir" 95 | ] 96 | ] 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bandit": {:hex, :bandit, "0.5.11", "d855a0645dbdf851e0101f90619f41fb334d6029e7fc668b8a673d32516e2e18", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:thousand_island, "~> 0.5.10", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.4.3", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "208975c1f3083b61dd12b670494b0687de5e019579dac3ac0a33cbe7e1b6fc76"}, 3 | "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, 4 | "credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"}, 5 | "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 7 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 8 | "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, 9 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 10 | "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, 11 | "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, 12 | "junit_formatter": {:hex, :junit_formatter, "3.3.1", "c729befb848f1b9571f317d2fefa648e9d4869befc4b2980daca7c1edc468e40", [:mix], [], "hexpm", "761fc5be4b4c15d8ba91a6dafde0b2c2ae6db9da7b8832a55b5a1deb524da72b"}, 13 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 14 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 15 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 16 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 17 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 18 | "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, 19 | "phoenix": {:hex, :phoenix, "1.6.15", "0a1d96bbc10747fd83525370d691953cdb6f3ccbac61aa01b4acb012474b047d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d70ab9fbf6b394755ea88b644d34d79d8b146e490973151f248cacd122d20672"}, 20 | "phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"}, 21 | "plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"}, 22 | "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, 23 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 24 | "thousand_island": {:hex, :thousand_island, "0.5.17", "efbb2f045105c77dbe8c1da722d064f7afcfb503f6ab2441d05a04c7dd6294de", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aeba8d43358c4db77abb87cf47c3f483aebc377bf0dbbdb1b6a20473905dfb70"}, 25 | "websock": {:hex, :websock, "0.4.3", "184ac396bdcd3dfceb5b74c17d221af659dd559a95b1b92041ecb51c9b728093", [:mix], [], "hexpm", "5e4dd85f305f43fd3d3e25d70bec4a45228dfed60f0f3b072d8eddff335539cf"}, 26 | "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"}, 27 | } 28 | -------------------------------------------------------------------------------- /priv/blank.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/chromic_pdf/225ed053a6ccece2f9599d9330590220e5d83d30/priv/blank.html -------------------------------------------------------------------------------- /priv/eciRGB_v2.icc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/chromic_pdf/225ed053a6ccece2f9599d9330590220e5d83d30/priv/eciRGB_v2.icc -------------------------------------------------------------------------------- /priv/pdfinfo.ps: -------------------------------------------------------------------------------- 1 | %!PS 2 | % Extract PDF info in a minimal way. 3 | % Inspired by 'toolbin/pdf_info.ps'. 4 | 5 | /QUIET true def 6 | File dup (r) file runpdfbegin 7 | Trailer /Info knownoget { 8 | dup /Title knownoget { (__knowninfoTitle: ) print = flush } if 9 | dup /Author knownoget { (__knowninfoAuthor: ) print = flush } if 10 | dup /Subject knownoget { (__knowninfoSubject: ) print = flush } if 11 | dup /Keywords knownoget { (__knowninfoKeywords: ) print = flush } if 12 | dup /Creator knownoget { (__knowninfoCreator: ) print = flush } if 13 | %dup /Producer knownoget { (__knowninfoProducer: ) print = flush } if 14 | dup /CreationDate knownoget { (__knowninfoCreationDate: ) print = flush } if 15 | dup /ModDate knownoget { (__knowninfoModDate: ) print = flush } if 16 | dup /Trapped knownoget { (__knowninfoTrapped: ) print = flush } if 17 | } if 18 | quit 19 | -------------------------------------------------------------------------------- /test/integration/connection_lost_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.ConnectionLostTest do 4 | use ChromicPDF.Case, async: false 5 | alias ChromicPDF.Connection 6 | alias ChromicPDF.Connection.{ConnectionLostError, Local} 7 | 8 | describe "when the local Chrome process fails at startup or is killed externally" do 9 | @tag :capture_log 10 | test "an exception with a nice error message is raised" do 11 | {:ok, pid} = Connection.start_link([]) 12 | 13 | port_info = Local.port_info(pid) 14 | 15 | Process.unlink(pid) 16 | Process.monitor(pid) 17 | 18 | System.cmd("kill", [to_string(port_info[:os_pid])]) 19 | 20 | receive do 21 | {:DOWN, _ref, :process, ^pid, {%ConnectionLostError{message: msg}, _trace}} -> 22 | assert String.contains?( 23 | msg, 24 | "Chrome has stopped or was terminated by an external program." 25 | ) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/integration/custom_protocol_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | defmodule ChromicPDF.CustomProtocolTest do 4 | use ChromicPDF.Case, async: false 5 | import ChromicPDF.TestAPI 6 | 7 | defmodule BypassCSP do 8 | import ChromicPDF.ProtocolMacros 9 | 10 | steps do 11 | call(:set_bypass_csp, "Page.setBypassCSP", [], %{"enabled" => true}) 12 | await_response(:bypass_csp_set, []) 13 | 14 | include_protocol(ChromicPDF.PrintToPDF) 15 | end 16 | end 17 | 18 | defmodule FixedScreenMetrics do 19 | import ChromicPDF.ProtocolMacros 20 | 21 | steps do 22 | call( 23 | :device_metrics, 24 | "Emulation.setDeviceMetricsOverride", 25 | [], 26 | %{"width" => 200, "height" => 200, "mobile" => false, "deviceScaleFactor" => 1} 27 | ) 28 | 29 | await_response(:device_metrics_response, []) 30 | 31 | include_protocol(ChromicPDF.CaptureScreenshot) 32 | end 33 | end 34 | 35 | defmodule GetUserAgent do 36 | import ChromicPDF.ProtocolMacros 37 | 38 | steps do 39 | call(:get_version, "Browser.getVersion", [], %{}) 40 | await_response(:version, ["userAgent"]) 41 | 42 | output("userAgent") 43 | end 44 | end 45 | 46 | setup do 47 | start_supervised!(ChromicPDF) 48 | :ok 49 | end 50 | 51 | describe ":protocol option to print_to_pdf/2" do 52 | @html_with_csp """ 53 | 54 | 55 | 56 | 57 | 58 |