=> clean slate software_

A Scanning App Written in Elm

This project turns an ordinary scanner connected to a GNU/Linux computer into a network scanner with an app interface aimed primarily at smart phones. The computer hosts a web frontend written in Elm. It controls the scanner with a backend written in Python. This backend communicates with the browser via Ajax requests intercepted by Python Flask. Scanned images are stored in a local cache on the server, which also provides Network Attached Storage (NAS). Individual images can be saved onto this NAS, as well as PDFs. These PDFs are generated from all the images on display, and saved, with a single button!

The frontend uses a simple and intuitive UI for individual images. Images can be easily deleted, rotated 180 degrees, and moved up and down in order. This is as simple and easy as scanning gets, and in my view the way it should work. Unfortunately, this arrangement requires some network administration that is beyond the scope of this page. And although it serves my personal use flawlessly, it is merely a prototype that does not cover all corner cases.

Screen Shot.

License

The MIT License (MIT)

Copyright © 2021 Dean Lee

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- Copyright © 2021 Dean Lee -->
<!DOCTYPE HTML>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Main</title>
  <style>body { padding: 0; margin: 0; }</style>
</head>

<body>

<div id="app"></div>
<script src="elm.js"></script>
<script>
    var app = Elm.Main.init({
        node: document.getElementById('app'),
        flags: { viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, url: window.location.href }
    });
</script>

</body>
</html>

scanning.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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
# Copyright © 2021 Dean Lee

import shutil
from datetime import datetime
from flask import Flask
import sane
from PIL import Image

app = Flask(__name__)

cache = 'image-cache/'
outputPath = '/mnt/nas/share'
currentTime = datetime.now()
timeString = currentTime.isoformat(sep='_', timespec='seconds')
precision = currentTime.microsecond
newFilename = timeString + '.jpg'

def readImageFile(imageFile):
    image = Image.open(cache + imageFile)
    return image

@app.route('/get-scanner')
def getScanner():
    sane.init()
    devices = sane.get_devices(True)
    return devices[0][0]

@app.route('/scan/<path:scanner>')
def scan(scanner):
    sane.init()
    scanner = sane.open(scanner)
    image = scanner.scan()
    image.save(cache + newFilename)
    scanner.close()
    return newFilename

@app.route('/rotate/<string:imageFile>')
def rotate(imageFile):
    image = readImageFile(imageFile)
    image = image.transpose(Image.ROTATE_180)
    image.save(cache + newFilename)
    return newFilename

@app.route('/save/<string:imageFile>')
def saveImage(imageFile):
    shutil.copy((cache + imageFile), outputPath)
    return imageFile

@app.route('/pdf/<path:images>')
def makePdf(images):
    imageList = images.split('/')
    imageList = list(map(readImageFile, imageList))
    pdfFilename = timeString + '.pdf'
    imageList[0].save((outputPath + '/' + pdfFilename), save_all=True, append_images=imageList[1:])
    return pdfFilename

Main.elm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
-- Copyright © 2021 Dean Lee

module Main exposing (..)

import Browser
import Browser.Events
import Element exposing (..)
import Element.Background as Background
import Element.Border as Border
import Element.Events
import Element.Font as Font
import Element.Input as Input
import Html exposing (Html)
import Http
import Ionicon

main =
  Browser.element
    { init = init
    , update = update
    , subscriptions = subscriptions
    , view = view
    }

type ConfirmDialog
  = PdfMade
  | JpegMade
  | NoDialog

type alias Model =
  { viewportWidth : Int
  , viewportHeight : Int
  , appUrl : String
  , scanner : String
  , scannerReady : Bool
  , images : List String
  , selectedImage : Maybe Int
  , confirmDialog : ConfirmDialog
  }

init : { viewportWidth : Int, viewportHeight : Int, url : String } -> (Model, Cmd Msg)
init browserData =
  let appUrl = browserData.url ++ "app/" in
  ( { viewportWidth = browserData.viewportWidth
    , viewportHeight = browserData.viewportHeight
    , appUrl = appUrl
    , scanner = ""
    , scannerReady = False
    , images = []
    , selectedImage = Nothing
    , confirmDialog = NoDialog
    }
  , Http.get
    { url = appUrl ++ "get-scanner"
    , expect = Http.expectString AddScanner
    }
  )

type Msg
  = UpdateViewport (Int, Int)
  | AddScanner (Result Http.Error String)
  | ScanImage
  | AddImage (Result Http.Error String)
  | SelectImage Int
  | RemoveImage
  | ImageUp
  | ImageDown
  | RotateImage
  | ReplaceImage (Result Http.Error String)
  | SaveImage
  | MakePdf
  | ShowPdfMadeDialog (Result Http.Error String)
  | ShowJpegMadeDialog (Result Http.Error String)
  | ClearDialog
  | Noop (Result Http.Error String)

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  let
    splitList3 : Int -> Int -> List a -> (List a, List a, List a)
    splitList3 middleStart middleLength list =
      ( List.take middleStart list
      , List.drop middleStart list |> List.take middleLength
      , List.drop (middleStart + middleLength) list
      )
  in
  case msg of
    UpdateViewport (width, height) ->
      ( { model | viewportWidth = width, viewportHeight = height }
      , Cmd.none
      )
    AddScanner result ->
      case result of
        Ok scanner ->
          ( { model | scanner = scanner, scannerReady = True }
          , Cmd.none
          )
        Err _ -> ( model, Cmd.none )
    ScanImage ->
      case model.scannerReady of
        True ->
          ( { model | scannerReady = False }
          , Http.get
            { url = model.appUrl ++ "scan/" ++ model.scanner
            , expect = Http.expectString AddImage
            }
          )
        False -> ( model, Cmd.none )
    AddImage result ->
      case result of
        Ok image ->
          ( { model | scannerReady = True, images = model.images ++ [image] }
          , Cmd.none
          )
        Err _ -> ( { model | scannerReady = True }, Cmd.none )
    SelectImage index ->
      ( { model | selectedImage = Just index }, Cmd.none )
    RemoveImage ->
      case model.selectedImage of
        Just index ->
          let
            (listA, _, listC) = splitList3 index 1 model.images
            newImages = listA ++ listC
          in
          ( { model | images = newImages, selectedImage = Nothing }, Cmd.none )
        Nothing -> ( model, Cmd.none )
    ImageUp ->
      case model.selectedImage of
        Just index ->
          if index /= 0 then
            let
              (listA, listB, listC) = splitList3 (index - 1) 2 model.images
              newImages = listA ++ (List.reverse listB) ++ listC
            in
            ( { model | images = newImages, selectedImage = Nothing }, Cmd.none )
          else ( model, Cmd.none )
        Nothing -> ( model, Cmd.none )
    ImageDown ->
      case model.selectedImage of
        Just index ->
          if index /= (List.length model.images) - 1 then
            let
              (listA, listB, listC) = splitList3 index 2 model.images
              newImages = listA ++ (List.reverse listB) ++ listC
            in
            ( { model | images = newImages, selectedImage = Nothing }, Cmd.none )
          else ( model, Cmd.none )
        Nothing -> ( model, Cmd.none )
    RotateImage ->
      case model.scannerReady of
        True ->
          case model.selectedImage of
            Just index ->
              case List.head <| List.drop index model.images of
                Just value ->
                  ( { model | scannerReady = False }
                  , Http.get
                    { url = model.appUrl ++ "rotate/" ++ value
                    , expect = Http.expectString ReplaceImage
                    }
                  )
                Nothing -> ( model, Cmd.none ) 
            Nothing -> ( model, Cmd.none )
        False -> ( model, Cmd.none )
    ReplaceImage result ->
      case result of
        Ok image ->
          case model.selectedImage of
            Just index ->
              let
                (listA, _, listC) = splitList3 index 1 model.images
                newImages = listA ++ [image] ++ listC
              in
              ( { model | scannerReady = True, images = newImages }, Cmd.none )
            Nothing -> ( model, Cmd.none )
        Err _ -> ( { model | scannerReady = True }, Cmd.none )
    SaveImage ->
      case model.selectedImage of
        Just index ->
          case List.head <| List.drop index model.images of
            Just value ->
              ( model
              , Http.get
                { url = model.appUrl ++ "save/" ++ value
                , expect = Http.expectString ShowJpegMadeDialog
                }
              )
            Nothing -> ( model, Cmd.none )
        Nothing -> ( model, Cmd.none )
    MakePdf ->
      let path = model.images |> List.intersperse "/" |> List.foldr (++) "" in
      ( model
      , Http.get
        { url = model.appUrl ++ "pdf/" ++ path
        , expect = Http.expectString ShowPdfMadeDialog
        }
      )
    ShowPdfMadeDialog _ ->
      ( { model | confirmDialog = PdfMade }, Cmd.none )
    ShowJpegMadeDialog _ ->
      ( { model | confirmDialog = JpegMade }, Cmd.none )
    ClearDialog ->
      ( { model | confirmDialog = NoDialog }, Cmd.none )
    Noop _ ->
      ( model, Cmd.none )

subscriptions : Model -> Sub Msg
subscriptions _ =
  Browser.Events.onResize (\w h -> UpdateViewport (w, h))


-- user interface

-- for use with Ionicon
type alias RGBA =
  { red : Float
  , green : Float
  , blue : Float
  , alpha : Float
  }

primaryButtonColor = rgb255 150 150 255
secondaryButtonColor = rgb255 25 175 25
headerColor = rgb255 230 230 230
mainBackgroundColor = rgb255 50 50 50
primaryTextColor = rgb255 255 255 255

dialog messageString headerHeight appWidth =
  [ column
    [ height fill
    , width fill
    ]
    [ el
      [ centerX
      , centerY
      , Font.size <| appWidth // 16
      ]
      <| text messageString
    , Input.button
      [ height <| px <| headerHeight // 3
      , width <| px <| appWidth // 5
      , Background.color primaryButtonColor
      , Border.rounded <| appWidth // 80
      , centerX
      , alignBottom
      , Font.size <| appWidth // 20
      , Font.color primaryTextColor
      ]
      { onPress = Just ClearDialog
      , label = text "OK"
      }
    ]
  ]

globalUI headerHeight appWidth model =
  let
    globalButtonAttributes =
      [ height fill
      , width fill
      , Background.color primaryButtonColor
      , Border.rounded <| appWidth // 30
      , Font.size <| appWidth // 10
      , Font.color primaryTextColor
      ]
  in
  case model.confirmDialog of
    PdfMade -> dialog "PDF has been saved to NAS." headerHeight appWidth
    JpegMade -> dialog "JPEG has been saved to NAS." headerHeight appWidth
    NoDialog ->
      if not model.scannerReady then
        [ text "Waiting for scanner..." ]
      else
        [ Input.button
          globalButtonAttributes
          { onPress = Just ScanImage
          , label = text "Scan"
          }
        , if not <| List.isEmpty model.images then
            Input.button
            globalButtonAttributes
            { onPress = Just MakePdf
            , label = text "PDF"
            }
          else none
        ]

imageUI imageWidth =
  let
    imageHeight =
      (toFloat imageWidth) * 1.376 |> round
    buttonSize =
      imageWidth // 6
    localButtonAttributes =
      [ height <| px buttonSize
      , width <| px buttonSize
      , Background.color secondaryButtonColor
      , Border.rounded <| imageWidth // 20
      ]
    makeIcon icon =
      html <| icon buttonSize <| RGBA 1 1 1 1    
  in
  column
  [ height <| px imageHeight
  , width <| px imageWidth
  ]
  [ column
    [ height <| px imageWidth
    , width <| px imageWidth
    , centerY
    , padding <| imageWidth // 10
    ]
    [ row
      [ height <| px buttonSize
      , width fill
      ]
      [ Input.button
        ( centerX :: localButtonAttributes )
        { onPress = Just ImageUp
        , label = makeIcon Ionicon.arrowUpC
        }
      ]
    , row
      [ height <| px buttonSize
      , width fill
      , centerY
      ]
      [ Input.button
        ( alignLeft :: localButtonAttributes )
        { onPress = Just SaveImage
        , label = makeIcon Ionicon.disc
        }
      , Input.button
        ( centerX :: localButtonAttributes )
        { onPress = Just RotateImage
        , label = makeIcon Ionicon.loop
        }
      , Input.button
        ( alignRight :: localButtonAttributes )
        { onPress = Just RemoveImage
        , label = makeIcon Ionicon.trashA
        }
      ]
    , row
      [ height <| px buttonSize
      , width fill
      , alignBottom
      ]
      [ Input.button
        ( centerX :: localButtonAttributes )
        { onPress = Just ImageDown
        , label = makeIcon Ionicon.arrowDownC
        }
      ]
    ]
  ]

renderImages appWidth model =
  let
    imageWidth =
      appWidth - (appWidth // 10)
    
    renderImage index fileName =
      let
        imageAttributes =
          [ width <| px imageWidth
          , centerX
          , Element.Events.onClick <| SelectImage index
          ]
      in
      image
      (
      case model.selectedImage of
        Nothing ->
          imageAttributes
        Just value ->
          if value == index then
            (Element.inFront <| imageUI imageWidth) :: imageAttributes
          else imageAttributes
      )
      { src = "image-cache/" ++ fileName
      , description = "scanned image"
      }
  in
  List.indexedMap renderImage model.images

viewPrimary : Model -> Element Msg
viewPrimary model =
  let
    headerHeight =
      model.viewportHeight // 5
    appWidth =
      if model.viewportWidth <= 500 then
        model.viewportWidth
      else 500
  in
  column
  [ height fill, width fill ]
  [ row 
    [ height <| px headerHeight
    , width fill
    , Background.color headerColor
    ]
    [ row
      [ height fill
      , width <| px appWidth 
      , centerX
      , padding <| appWidth // 40
      , spacing <| appWidth // 40
      , Font.center
      ]
      <| globalUI headerHeight appWidth model
    ]
  , row
    [ height <| px <| model.viewportHeight - headerHeight
    , width fill
    , scrollbarY
    , Background.color mainBackgroundColor
    ]
    [ column
      [ height fill
      , width <| px appWidth
      , centerX
      , Font.color primaryTextColor
      , paddingXY 0 10
      , spacing 20
      ]
      <| renderImages appWidth model
    ]
  ]

view : Model -> Html Msg
view model =
  let
    style =
      focusStyle
      { borderColor = Nothing
      , backgroundColor = Nothing, shadow = Nothing
      }
  in
  layoutWith { options = [ style ] } [] <| viewPrimary model