Open and Annotate PDFs from Your Elm App

Table of contents

    Open and Annotate PDFs from Your Elm App

    In this article, we’ll explore how to add advanced PDF features to your Elm app using PSPDFKit for Web(opens in a new tab). It builds upon our Open a PDF in Elm blog post, so be sure to check that out first.

    To get started, we’ll need a free PSPDFKit for Web demo license. Follow the trial signup steps, and then make a note of your NPM_KEY and LICENSE_KEY, which we’ll need shortly.

    ℹ️ Note: If you don’t want to follow along, you can see the finished code in our PSPDFKit for Web Elm example project(opens in a new tab), along with examples for several other popular languages and frameworks(opens in a new tab).

    Setup

    To set up our project, let’s first create the initial structure for our app:

    Terminal window
    mkdir pspdfkit-web-example-elm
    cd pspdfkit-web-example-elm
    mkdir src assets
    touch src/index.js src/Main.elm

    Then we’ll install our dependencies:

    Terminal window
    npm init -y
    npm install -D elm elm-webpack-loader html-webpack-plugin webpack webpack-dev-server
    npm install -D https://my.nutrient.io/npm/YOUR_NPM_KEY_GOES_HERE/latest.tar.gz

    Note that we’re using webpack(opens in a new tab) to automate some of the grunt work, along with elm-webpack-loader(opens in a new tab) to tell webpack how to handle Elm files. The webpack setup is not that relevant here, so we’ll skip over the details, but you can see the full webpack configuration in the finished example project(opens in a new tab).

    First Iteration: Rendering

    As in the previous article, our initial goal is simply to render a PDF in the browser. Here’s an example PDF to use if you don’t have one handy. Be sure to place it in the assets folder we created previously.

    On the Elm side (in src/Main.elm), we’ll start simple and just create a div with an id of PSPDFKitContainer to contain our PSPDFKit instance:

    module Main exposing (init, main, update, view)
    import Browser
    import Html exposing (div)
    import Html.Attributes exposing (id, style)
    -- MODEL
    init =
    {}
    -- UPDATE
    update msg model =
    model
    -- VIEW
    view model =
    div [ id "PSPDFKitContainer", style "height" "100vh" ] []
    -- PROGRAM
    main =
    Browser.sandbox { init = init, update = update, view = view }

    On the JavaScript side (in src/index.js), we just need to tell PSPDFKit to render into the PSPDFKitContainer div created by Elm and pass in both the license key we obtained earlier and the path to our PDF document:

    import { Elm } from "./Main";
    import PSPDFKit from "pspdfkit";
    Elm.Main.init({ node: document.body });
    PSPDFKit.load({
    document: "example.pdf",
    container: "#PSPDFKitContainer",
    licenseKey: YOUR_LICENSE_KEY_GOES_HERE
    });

    With that, our PDF is now shown in the browser:

    1st Iteration PDF Rendering

    Second Iteration: Configuration

    PSPDFKit for Web has a rich set of configuration options(opens in a new tab). In this second iteration, our aim is to specify these in Elm and pass them over to JS using Ports(opens in a new tab).

    On the Elm side, we create a configure port which we can use to send our data to JS. We also define some data types that tell Elm the shape and format of our configuration object.

    Finally, in the init function, we send the desired configuration to the configure port. Here we’re specifying that we want to jump straight to page 2 of the document and show thumbnails of the document pages in the sidebar.

    ℹ️ Note: In a production app, rather than sidebarMode : String, you’d instead define a custom type(opens in a new tab) (i.e. type SidebarMode = Thumbnails | Annotations | ...) and encode(opens in a new tab) it before passing it to the port.

    port module Main exposing (..)
    import Browser
    import Html exposing (div)
    import Html.Attributes exposing (id, style)
    -- MODEL
    type alias Model =
    { pdf : String
    , container : String
    , licenseKey : String
    , viewState : ViewState
    }
    type alias ViewState =
    { currentPageIndex : Int
    , sidebarMode : String
    }
    type alias Flags =
    { licenseKey : String }
    init : Flags -> ( Model, Cmd Msg )
    init flags =
    let
    model =
    { pdf = "example.pdf"
    , container = "#PSPDFKitContainer"
    , licenseKey = flags.licenseKey
    , annotations = []
    , viewState =
    { currentPageIndex = 1
    , sidebarMode = "THUMBNAILS"
    }
    }
    in
    ( model, configure model )
    -- UPDATE
    type Msg
    = Model
    update msg model =
    ( model, load model )
    -- PORT
    port configure : Model -> Cmd msg
    -- VIEW
    view model =
    div [ id "PSPDFKitContainer", style "height" "100vh" ] []
    -- PROGRAM
    main =
    Browser.element
    { init = init
    , update = update
    , view = view
    , subscriptions = \_ -> Sub.none
    }

    On the JS side, we subscribe to the configure port, which receives the configuration object and uses it to initialize PSPDFKit:

    const app = Elm.Main.init({
    node: document.body,
    flags: {
    licenseKey: YOUR_LICENSE_KEY_GOES_HERE
    }
    });
    app.ports.configure.subscribe(data => {
    const initialViewState = new PSPDFKit.ViewState(data.viewState);
    const config = { ...data, initialViewState };
    PSPDFKit.load(config);
    });

    Here’s how our second iteration looks in the browser:

    2nd Iteration PDF Configuration

    Third Iteration: Interaction

    For our third and final iteration, our aim is to be able to add annotations to the document using Elm.

    To do this, we’ll add a button with an onClick handler that calls our update function. If it receives the CreateAnnotation message, our update function creates a new Annotation and updates our Model. Finally, it dispatches our updated model to a new annotate port:

    port module Main exposing (..)
    import Browser
    import Html exposing (Html, button, div, footer, text)
    import Html.Attributes exposing (id, style)
    import Html.Events exposing (onClick)
    -- MODEL
    type alias Model =
    { pdf : String
    , container : String
    , licenseKey : String
    , viewState : ViewState
    , annotations : List Annotation
    }
    type alias ViewState =
    { currentPageIndex : Int
    , sidebarMode : String
    }
    type alias Annotation =
    { pageIndex : Int
    , text : String
    , fontSize : Int
    , isBold : Bool
    , fontColor : String
    , backgroundColor : String
    , horizontalAlign : String
    , verticalAlign : String
    , boundingBox : Rect
    }
    type alias Rect =
    { left : Int
    , top : Int
    , width : Int
    , height : Int
    }
    type alias Flags =
    { licenseKey : String }
    init : Flags -> ( Model, Cmd Msg )
    init flags =
    let
    model =
    { pdf = "example.pdf"
    , container = "#PSPDFKitContainer"
    , licenseKey = flags.licenseKey
    , annotations = []
    , viewState =
    { currentPageIndex = 1
    , sidebarMode = "THUMBNAILS"
    }
    }
    in
    ( model, configure model )
    -- UPDATE
    type Msg
    = SetConfig
    | CreateAnnotation
    update : Msg -> Model -> ( Model, Cmd Msg )
    update msg model =
    case msg of
    SetConfig ->
    ( model, configure model )
    CreateAnnotation ->
    let
    annotation =
    { pageIndex = 1
    , text = "Hello from Elm 🌳"
    , fontSize = 50
    , isBold = True
    , fontColor = "WHITE"
    , backgroundColor = "GREEN"
    , horizontalAlign = "center"
    , verticalAlign = "center"
    , boundingBox =
    { left = 100
    , top = 100
    , width = 500
    , height = 200
    }
    }
    in
    ( model, annotate { model | annotations = [ annotation ] } )
    -- PORT
    port configure : Model -> Cmd msg
    port annotate : Model -> Cmd msg
    -- VIEW
    view model =
    div
    []
    [ div [ id "PSPDFKitContainer", style "height" "90vh" ] []
    , footer [ style "text-align" "center", style "line-height" "10vh" ]
    [ button [ onClick CreateAnnotation ] [ text "Create Annotation" ]
    ]
    ]
    -- PROGRAM
    main =
    Browser.element
    { init = init
    , update = update
    , view = view
    , subscriptions = \_ -> Sub.none
    }

    The only significant change on the JS side is that we now need to subscribe to the annotate port to receive the updated model. There we simply loop through the annotations and coerce their attributes into the types that PSPDFKit is expecting:

    import { Elm } from "./Main";
    import PSPDFKit from "pspdfkit";
    let instance;
    const app = Elm.Main.init({
    node: document.body,
    flags: {
    licenseKey: YOUR_LICENSE_KEY_GOES_HERE
    }
    });
    app.ports.configure.subscribe(data => {
    const initialViewState = new PSPDFKit.ViewState(data.viewState);
    const config = { ...data, initialViewState };
    PSPDFKit.load(config).then(async pspdfkit => {
    instance = pspdfkit;
    });
    });
    app.ports.annotate.subscribe(data => {
    data.annotations.forEach(a => {
    const annotation = new PSPDFKit.Annotations.TextAnnotation({
    ...a,
    fontColor: new PSPDFKit.Color(PSPDFKit.Color[a.fontColor]),
    backgroundColor: new PSPDFKit.Color(PSPDFKit.Color[a.backgroundColor]),
    boundingBox: new PSPDFKit.Geometry.Rect(a.boundingBox)
    });
    instance.createAnnotation(annotation);
    });
    });

    Here’s how our third iteration looks in the browser:

    3rd Iteration PDF Interaction

    Conclusion

    I hope this post has given you further insight into how JS interop works in Elm. These principles can be applied to working with any JavaScript library, and the basic pattern is the same: We define explicitly what and how data flows between Elm and the browser via ports, which gives us access to the things we need from JS while allowing us to keep the core of our application logic in Elm.

    This might seem like a lot of overhead, but in many ways, it is not that different from talking to an API or delegating to some native code, and the beauty of the Elm language, architecture(opens in a new tab), and compiler really make it worth exploring.

    William Meleyal

    William Meleyal

    Explore related topics

    FREE TRIAL Ready to get started?