diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 087c76cc..05fb826c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -9,8 +9,8 @@ on: branches: - 'master' - 'staging' - tags: - - 'v*' + release: + types: [published] jobs: docker: diff --git a/Dockerfile b/Dockerfile index 76ab05e9..9d61710f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,9 @@ RUN apt-get update \ && sed -i \ 's/policy domain="coder" rights="none" pattern="PDF"/policy domain="coder" rights="read | write" pattern="PDF"'/ \ /etc/ImageMagick-6/policy.xml \ + && sed -i \ + 's/policy domain="resource" name="disk" value="1GiB"/policy domain="resource" name="disk" value="8GiB"'/ \ + /etc/ImageMagick-6/policy.xml \ && npm install -g npm@7.11.2 # Create a known user diff --git a/README.md b/README.md index 1141f0a1..eb8b496f 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,11 @@ Copyright 2016-2021 [Sam Strachan](https://github.com/sbs20) > broken, meaning the device is useless by itself because one cannot trigger > scans, but with this project I can trigger it remotely just fine. + +> Absolutely love untethering my scanner from my laptop. Also means that I know +> it will work "forever", regardless of OS updates, since its all just a docker +> container. + ## About scanservjs is a web UI frontend for your scanner. It allows you to share one or @@ -42,8 +47,8 @@ complicated installation. * Filters: Autolevels, Threshold, Blur * Configurable overrides for all defaults as well as filters and formats * Multipage scanning (with collation for double sided scans) -* International translations: Czech, French, German, Italian, Mandarin, Polish, - Portuguese (BR), Russian, Spanish; +* International translations: Czech, Dutch, French, German, Italian, Mandarin, + Polish, Portuguese (BR), Russian, Spanish; [Help requested](https://github.com/sbs20/scanservjs/issues/154) * Light and dark mode * Responsive design @@ -57,7 +62,7 @@ It supports any * Linux host (or VM with necessary pass-through e.g. USB) * Software sane-utils, ImageMagick, Tesseract (optional) and nodejs -## Installation +## Documentation * [Manual installation](docs/install.md) * [Docker installation](docs/docker.md) @@ -65,12 +70,8 @@ It supports any * [Proxy setup](docs/proxy.md) * [Troubleshooting](docs/troubleshooting.md) * [Development notes](docs/development.md) - -## Configuration and device override - -If you want to override some specific configuration settings then you can do so -within `./config/config.local.js`. See [Configuration](docs/config.md) for more -detail. +* [Configuration and device override](docs/config.md) +* [Integration](docs/integration.md) ## Why? diff --git a/docs/install.md b/docs/install.md index 6da197cd..f78e90d0 100644 --- a/docs/install.md +++ b/docs/install.md @@ -2,7 +2,7 @@ ## One line install -* If you don't already have your scanner working, then you need to get +* If you don't already have your scanner working, then you must get [SANE installed and working](./sane.md) and check permissions etc. Your scanner can be attached to a different server / device if you're using saned. * If you're using a debian based distro then you can just use the installer @@ -11,6 +11,7 @@ ```sh curl -s https://raw.githubusercontent.com/sbs20/scanservjs/master/packages/server/installer.sh | sudo bash -s -- -a ``` + Note: the installer script will always install from the master branch. * If you're using Arch, then [@dadosch](https://github.com/dadosch) created a PKGBUILD script in Arch's AUR which allows Arch-distro-based users to quickly install and update scanservjs with any AUR helper, for example: diff --git a/docs/integration.md b/docs/integration.md new file mode 100644 index 00000000..8274f1e9 --- /dev/null +++ b/docs/integration.md @@ -0,0 +1,63 @@ +# Integration + +It's not uncommon to want to integrate scanservjs with other software - you may +wish to upload scans to Dropbox, paperless-ng or some other location. The +possibilities are endless and deep integration into the UI would add cruft for +the vast majority of users. + +Thankfully, the files just end up in a location on your filesystem so you are +free to integrate however you want. + +The recommended way is to create a script or program which scans the output +directory for files and then does something with them. + +## paperless-ng + +[This discussion](https://github.com/sbs20/scanservjs/issues/351#issuecomment-913858423) +about paperless-ng resulted in +[scantopl](https://github.com/Celedhrim/scantopl) + +## Dropbox + +You could integrate with Dropbox using +[Dropbox-Uploader](https://github.com/andreafabrizi/Dropbox-Uploader) + +## Scan2Mail + +1. Setup and configure [msmtp](https://wiki.debian.org/msmtp) and msmtp-mta as + described + [here](https://decatec.de/linux/linux-einfach-e-mails-versenden-mit-msmtp/) +2. Install the MIME packer [mpack](https://linux.die.net/man/1/mpack) with + `sudo apt install mpack` to send the scanned files +3. Setup [OCRmyPDF](https://github.com/jbarlow83/OCRmyPDF) as described + [here](https://ocrmypdf.readthedocs.io/en/latest/installation.html) + +Now create the following pipeline in your `config/config.local.js` + +```javascript +config.pipelines.push({ + extension: 'pdf', + description: 'ocrmypdf (Scan2Mail email@address.tld)', + get commands() { + return [ + 'convert @- -quality 92 tmp-%04d.jpg && ls tmp-*.jpg', + 'convert @- pdf:-', + `file="scan_$(date +"%d_%m_%Y-%H_%M").pdf" && ocrmypdf -l ${config.ocrLanguage} --deskew --rotate-pages --force-ocr - "$file" && mpack -s "Document from Scanner@Office" "$file" email@address.tld`, + 'ls scan_*.*' + ]; + } +}); +``` + +The important `Scan2Mail` line is: + +``` +file="scan_$(date +"%d_%m_%Y-%H_%M").pdf" && ocrmypdf -l ${config.ocrLanguage} --deskew --rotate-pages --force-ocr - "$file" && mpack -s "Document from Scanner@Office" "$file" email@address.tld +``` + +This sets a time-based filename, then OCRs and finally sends to +email@address.tld + +## Other recipes? + +If you have other recipes then please share them. diff --git a/docs/proxy.md b/docs/proxy.md index 91e5dc22..bf85e7c5 100644 --- a/docs/proxy.md +++ b/docs/proxy.md @@ -1,9 +1,9 @@ -## Reverse proxy +# Reverse proxy scanservjs supports reverse proxying and uses relative paths throughout so no URL rewriting should be required. -### Apache +## Apache Example setup using a debian based distro. diff --git a/package-lock.json b/package-lock.json index be7ed200..2abaccb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scanservjs", - "version": "2.17.1", + "version": "2.18.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "scanservjs", - "version": "2.17.1", + "version": "2.18.0", "hasInstallScript": true, "license": "GPL-2.0" } diff --git a/package.json b/package.json index 8bc045e0..c02e25d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scanservjs", - "version": "2.17.1", + "version": "2.18.0", "description": "scanservjs is a simple web-based UI for SANE which allows you to share a scanner on a network without the need for drivers or complicated installation.", "scripts": { "clean": "rm -rf ./dist", diff --git a/packages/client/package-lock.json b/packages/client/package-lock.json index d889a6db..6a5a4b05 100644 --- a/packages/client/package-lock.json +++ b/packages/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "scanservjs", - "version": "2.17.1", + "version": "2.18.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "scanservjs", - "version": "2.17.1", + "version": "2.18.0", "license": "GPL-2.0", "dependencies": { "@mdi/font": "^5.9.55", diff --git a/packages/client/package.json b/packages/client/package.json index 6ec9a97d..db896874 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "scanservjs", - "version": "2.17.1", + "version": "2.18.0", "description": "scanservjs is a simple web-based UI for SANE which allows you to share a scanner on a network without the need for drivers or complicated installation.", "author": "Sam Strachan", "scripts": { diff --git a/packages/client/src/classes/constants.js b/packages/client/src/classes/constants.js index 02f615cf..0dff5f3c 100644 --- a/packages/client/src/classes/constants.js +++ b/packages/client/src/classes/constants.js @@ -25,6 +25,7 @@ const Constants = { 'es', 'fr', 'it', + 'nl', 'pl', 'pt-BR', 'ru', diff --git a/packages/client/src/classes/request.js b/packages/client/src/classes/request.js index ea8a83fa..5eeb7e1c 100644 --- a/packages/client/src/classes/request.js +++ b/packages/client/src/classes/request.js @@ -16,10 +16,6 @@ export default class Request { version: Constants.Version, params: { deviceId: device.id, - top: request.params.top || device.features['-t'].default, - left: request.params.left || device.features['-l'].default, - width: request.params.width || device.features['-x'].default, - height: request.params.height || device.features['-y'].default, resolution: request.params.resolution || device.features['--resolution'].default }, filters: request.filters || [], @@ -28,6 +24,12 @@ export default class Request { index: 1 }; + if (['-x', '-y', '-l', '-t'].every(s => s in device.features)) { + obj.params.top = request.params.top || device.features['-t'].default; + obj.params.left = request.params.left || device.features['-l'].default; + obj.params.width = request.params.width || device.features['-x'].default; + obj.params.height = request.params.height || device.features['-y'].default; + } if ('--mode' in device.features) { obj.params.mode = request.params.mode || device.features['--mode'].default; } diff --git a/packages/client/src/components/Scan.vue b/packages/client/src/components/Scan.vue index 987878b2..f807222b 100644 --- a/packages/client/src/components/Scan.vue +++ b/packages/client/src/components/Scan.vue @@ -52,36 +52,39 @@
{{ $t('scan.btn-scan') }} mdi-camera - {{ $t('scan.btn-preview') }} mdi-magnify + {{ $t('scan.btn-preview') }} mdi-magnify {{ $t('scan.btn-clear') }} mdi-delete
- + - - - - - - - - - - {{ item.name }} - - - +
s in this.device.features); + }, + deviceSize() { return { width: this.device.features['-x'].limits[1], @@ -272,7 +279,9 @@ export default { methods: { _resizePreview() { - const paperRatio = this.deviceSize.width / this.deviceSize.height; + const paperRatio = this.geometry + ? this.deviceSize.width / this.deviceSize.height + : 210 / 297; // This only makes a difference when the col-width="auto" - so md+ const mdBreakpoint = 960; diff --git a/packages/client/src/locales/de.json b/packages/client/src/locales/de.json index f60752ce..a1cacade 100644 --- a/packages/client/src/locales/de.json +++ b/packages/client/src/locales/de.json @@ -10,26 +10,26 @@ }, "colors": { - "accent-4": "Default", - "red": "Red", + "accent-4": "Standard", + "red": "Rot", "pink": "Pink", - "purple": "Purple", - "deep-purple": "Deep purple", + "purple": "Lila", + "deep-purple": "Dunkellila", "indigo": "Indigo", - "blue": "Blue", - "light-blue": "Light blue", + "blue": "Blau", + "light-blue": "Hellblau", "cyan": "Cyan", - "teal": "Teal", - "green": "Green", - "light-green": "Light green", + "teal": "Blaugrün", + "green": "Grün", + "light-green": "Hellgrün", "lime": "Lime", - "yellow": "Yellow", - "amber": "Amber", + "yellow": "Gelb", + "amber": "Bernstein", "orange": "Orange", - "deep-orange": "Deep orange", - "brown": "Brown", - "blue-grey": "Blue grey", - "grey": "Grey" + "deep-orange": "Dunkelorange", + "brown": "Braun", + "blue-grey": "Blau-Grau", + "grey": "Grau" }, "batch-dialog": { @@ -43,8 +43,8 @@ "filename": "Dateiname", "date": "Datum", "size": "Größe", - "items-per-page": "Files per page", - "items-per-page-all": "All", + "items-per-page": "Anzahl der Dateien pro Seite", + "items-per-page-all": "Alle", "message:deleted": "Gelöscht {0}", "message:renamed": "Datei umbenannt", "button:delete-selected": "Auswahl löschen", @@ -77,9 +77,9 @@ }, "mode": { - "color": "Colour", - "halftone": "Halftone", - "gray": "Grey", + "color": "Farbe", + "halftone": "Rasterbild", + "gray": "Graustufen", "lineart": "Lineart", "24bitcolor":"@:mode.color", @@ -90,11 +90,11 @@ }, "source": { - "flatbed": "Flatbed", - "adf": "Automatic Document Feeder", - "auto": "Auto", - "left-aligned": "Left Aligned", - "centrally-aligned": "Centrally Aligned", + "flatbed": "Flachbrett", + "adf": "Dokumenteneinzug", + "auto": "Automatisch", + "left-aligned": "Linksbündig", + "centrally-aligned": "Zentriert", "duplex": "Duplex", "transparency unit": "Transparency Unit", @@ -144,7 +144,7 @@ "left": "Links", "width": "Breite", "height": "Höhe", - "paperSize": "Paper size", + "paperSize": "Papierformat", "brightness": "Helligkeit", "contrast": "Kontrast", "message:loading-devices": "Suche nach Geräten...", @@ -164,12 +164,12 @@ "theme:system": "Systemdesign", "theme:light": "Helles Design", "theme:dark": "Dunkles Design", - "color": "Colour", - "color:description": "Colour. This will change the colour of the top app bar.", - "devices": "Devices and storage", - "reset:description": "Clears stored scanner devices and forces a reload", + "color": "Hintergrund Design", + "color:description": "Hintergrund Design. Hier kann die Farbe der Menüleiste angepasst werden.", + "devices": "Geräte und Speicherorte", + "reset:description": "Alle gespeicherten Scanner löschen und die App neu laden", "reset": "Zurücksetzen", - "clear-storage:description": "Clears local storage of any cached parameters", - "clear-storage": "Clear" + "clear-storage:description": "All gespeicherten Parameter löschen", + "clear-storage": "Löschen" } } diff --git a/packages/client/src/locales/en.json b/packages/client/src/locales/en.json index ecb2f2d2..e13ed92c 100644 --- a/packages/client/src/locales/en.json +++ b/packages/client/src/locales/en.json @@ -180,6 +180,7 @@ "es": "Español", "fr": "Français", "it": "Italiano", + "nl": "Nederlands", "pl": "Polski", "pt-BR": "Portuguese (Brazilian)", "ru": "Russian", diff --git a/packages/client/src/locales/nl.json b/packages/client/src/locales/nl.json new file mode 100644 index 00000000..2ad3bcb2 --- /dev/null +++ b/packages/client/src/locales/nl.json @@ -0,0 +1,175 @@ +{ + "global": { + "application-name": "scanservjs" + }, + + "about": { + "main": "scanservjs is een simpel web-based UI voor je scanner. Hiermee kun je een of meer scanners (met behulp van SANE) op een netwerk delen zonder dat er stuurprogramma's of een ingewikkelde installatie nodig is. Het kan in TIF, JPG, PNG, PDF en TXT (met Tesseract OCR) opslaan met verschillende compressie-instellingen, wat allemaal kan worden geconfigureerd. Het ondersteunt scannen van meerdere pagina's en alle SANE-compatibele apparaten.", + "issue": "Probleem indienen of bekijk de broncode:", + "system-info": "Systeem informatie" + }, + + "colors": { + "accent-4": "Standaard", + "red": "Rood", + "pink": "Roze", + "purple": "Paars", + "deep-purple": "Donkerpaars", + "indigo": "Indigo", + "blue": "Blauw", + "light-blue": "Lichtblauw", + "cyan": "Cyaan", + "teal": "Groenblauw", + "green": "Groen", + "light-green": "Lichtgroen", + "lime": "Limoen", + "yellow": "Geel", + "amber": "Amber", + "orange": "Oranje", + "deep-orange": "Donkeroranje", + "brown": "Bruin", + "blue-grey": "Blauwgrijs", + "grey": "Grijs" + }, + + "batch-dialog": { + "btn-cancel": "Annuleren", + "btn-finish": "Afronden", + "btn-rescan": "Pagina opnieuw scannen", + "btn-next": "Volgende" + }, + + "files": { + "filename": "Bestandsnaam", + "date": "Datum", + "size": "Grootte", + "items-per-page": "Bestanden per pagina", + "items-per-page-all": "Alles", + "message:deleted": "Verwijderd {0}", + "message:renamed": "Bestand hernoemd", + "button:delete-selected": "Verwijderen geselecteerde bestanden", + "dialog:rename": "Pas bestandsnaam aan", + "dialog:rename-cancel": "Annuleren", + "dialog:rename-save": "Opslaan", + "actions": "Acties" + }, + + "navigation": { + "scan": "Scan", + "files": "Bestanden", + "settings": "Instellingen", + "about": "Over", + "version": "Versie" + }, + + "batch-mode": { + "none": "Geen", + "manual": "Handmatig (met bevestiging)", + "auto": "Auto (Documentinvoer)", + "auto-collate-standard": "Auto (Sorteren 1, 3... 4, 2)", + "auto-collate-reverse": "Auto (Omgedraaid 1, 3... 2, 4)" + }, + + "filter": { + "auto-level": "Auto nivelleren", + "threshold": "Threshold", + "blur": "Vervagen" + }, + + "mode": { + "color": "Kleur", + "halftone": "Halftoon", + "gray": "Grijswaarde", + "lineart": "Lijntekening", + + "24bitcolor":"@:mode.color", + "black & white": "@:mode.lineart", + "gray(error diffusion)": "@:mode.halftone", + "true gray": "@:mode.gray", + "24bit color(fast)": "@:mode.color" + }, + + "source": { + "flatbed": "Flatbed", + "adf": "ADF", + "auto": "Auto", + "left-aligned": "Links uitgelijnd", + "centrally-aligned": "Centraal uitgelijnd", + "duplex": "Duplex", + "transparency unit": "Transparantie-eenheid", + + "automatic document feeder": "@:source.adf", + "automatic document feeder(left aligned)": "@:source.adf (@:source.left-aligned)", + "automatic document feeder(left aligned,duplex)": "@:source.adf (@:source.left-aligned, @:source.duplex)", + "automatic document feeder(centrally aligned)": "@:source.adf (@:source.centrally-aligned)", + "automatic document feeder(centrally aligned,duplex)": "@:source.adf (@:source.centrally-aligned, @:source.duplex)" + }, + + "pipeline": { + "high-quality": "Hoge kwaliteit", + "medium-quality": "Gemiddelde kwaliteit", + "low-quality": "Lage kwaliteit", + "uncompressed": "Ongecomprimeerd", + "lzw-compressed": "LZW gecomprimeerd", + "ocr": "OCR", + "text-file": "Text bestand" + }, + + "paper-size": { + "letter": "Letter", + "legal": "Legal", + "tabloid": "Tabloid", + "ledger": "Ledger", + "junior-legal": "Junior legal", + "half-letter": "Half letter", + "portrait": "Portret", + "landscape": "Landschap" + }, + + "scan": { + "device": "Apparaat", + "source": "Bron", + "resolution": "Resolutie", + "mode": "Modus", + "dynamic-lineart": "Dynamische Lijntekening", + "dynamic-lineart:enabled": "Ingeschakeld", + "dynamic-lineart:disabled": "Uitgeschakeld", + "batch": "Batch", + "filters": "Filters", + "format": "Formaat", + "btn-preview": "Voorbeeld", + "btn-clear": "Reset", + "btn-scan": "Scan", + "top": "Boven", + "left": "Links", + "width": "Breedte", + "height": "Hoogte", + "paperSize": "Papier formaat", + "brightness": "Helderheid", + "contrast": "Contrast", + "message:loading-devices": "Apparaten laden...", + "message:no-devices": "Geen apparaten gevonden", + "message:deleted-preview": "Verwijderd voorbeeld", + "message:turn-documents": "Draai document om", + "message:preview-of-page": "Voorbeeld van pagina" + }, + + "settings": { + "title": "@:navigation.settings", + "behaviour-ui": "Werking en UI", + "locale": "Taal", + "locale:description": "Kies je taal", + "theme": "Thema", + "theme:description": "Thema. Als je het systeem thema gebruikt and dit aanpast, dien je de app te herladen.", + "theme:system": "Systeem", + "theme:light": "Licht", + "theme:dark": "Donker", + "color": "Kleur", + "color:description": "Kleur. Dit past de kleur aan van de balk bovenin.", + "devices": "Apparaten en opslag", + "reset:description": "Wist opgeslagen scanner apparaten en forceert tot heladen", + "reset": "Reset", + "clear-storage:description": "Wist lokale opslag van alle parameters in de cache", + "clear-storage": "Wissen" + } +} diff --git a/packages/client/src/locales/zh.json b/packages/client/src/locales/zh.json index f73f63f2..b7c88eff 100644 --- a/packages/client/src/locales/zh.json +++ b/packages/client/src/locales/zh.json @@ -43,15 +43,15 @@ "filename": "文件名", "date": "日期", "size": "大小", - "items-per-page": "Files per page", - "items-per-page-all": "All", + "items-per-page": "每页文件数", + "items-per-page-all": "展示全部", "message:deleted": "已删除 {0}", - "message:renamed": "File renamed", - "button:delete-selected": "Delete Selected", - "dialog:rename": "Change file name", - "dialog:rename-cancel": "Cancel", - "dialog:rename-save": "Save", - "actions": "Actions" + "message:renamed": "文件已更名", + "button:delete-selected": "删除所选", + "dialog:rename": "更改文件名称", + "dialog:rename-cancel": "取消", + "dialog:rename-save": "保存", + "actions": "操作" }, "navigation": { @@ -121,8 +121,8 @@ "ledger": "Ledger", "junior-legal": "Junior legal", "half-letter": "Half letter", - "portrait": "Portrait", - "landscape": "Landscape" + "portrait": "纵向", + "landscape": "横向" }, "scan": { diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 99152825..eeaac868 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "scanservjs-server", - "version": "2.17.1", + "version": "2.18.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "scanservjs-server", - "version": "2.17.1", + "version": "2.18.0", "license": "GPL-2.0", "dependencies": { "adm-zip": "^0.5.5", diff --git a/packages/server/package.json b/packages/server/package.json index d1a268ac..9a7fb3ab 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "scanservjs-server", - "version": "2.17.1", + "version": "2.18.0", "description": "scanservjs is a simple web-based UI for SANE which allows you to share a scanner on a network without the need for drivers or complicated installation.", "scripts": { "lint": "gulp lint", diff --git a/packages/server/src/device.js b/packages/server/src/device.js index db4133f6..6cef24dd 100644 --- a/packages/server/src/device.js +++ b/packages/server/src/device.js @@ -1,162 +1,55 @@ -const extend = require('./util').extend; +const Feature = require('./feature'); +const Util = require('./util'); -/** - * @param {number} n - * @returns {number} - */ -function round(n) { - return Math.floor(n * 10) / 10; -} - -class Feature { - /** - * @param {string} string - * @param {string} delimiter - * @returns {number[]} - */ - static splitNumbers(string, delimiter) { - return string.replace(/[a-z%]/ig, '') - .split(delimiter) - .filter(s => s.length > 0) - .map(s => Number(s)); - } - - /** - * @param {ScanDeviceFeature} feature - */ - static resolution(feature) { - feature.options = [50, 75, 100, 150, 200, 300, 600, 1200]; - if (feature.parameters.indexOf('|') > -1) { - feature.options = Feature.splitNumbers(feature.parameters, '|'); - } else if (feature.parameters.indexOf('..') > -1) { - const limits = Feature.splitNumbers(feature.parameters, '..'); - feature.options = []; - for (let value = limits[1]; value > limits[0]; value /= 2) { - feature.options.push(value); - } - feature.options.push(limits[0]); - feature.options.sort((a, b) => a - b); - } - feature.default = Number(feature.default); - } - - /** - * @param {ScanDeviceFeature} feature - */ - static range(feature) { - feature.default = round(Number(feature.default)); - const range = /(.*?)(?:\s|$)/g.exec(feature.parameters); - feature.limits = Feature.splitNumbers(range[1], '..'); - const steps = /\(in steps of ([0-9]{1,2})\)/g.exec(feature.parameters); - feature.interval = steps ? Number(steps[1]) : 1; - } - - /** - * @param {ScanDeviceFeature} feature - */ - static geometry(feature) { - Feature.range(feature); - feature.limits[0] = round(feature.limits[0]); - feature.limits[1] = round(feature.limits[1]); +/** @type {ScanDevice} */ +class Device { + constructor(string) { + this.id = ''; + this.name = ''; + this.features = {}; + this.string = string; + this.parse(); } /** - * @param {ScanDeviceFeature} feature + * @returns {boolean} */ - static lighting(feature) { - Feature.range(feature); + get geometry() { + return ['-x', '-y', '-l', '-t'].every(s => s in this.features); } -} -class Adapter { - /** - * @param {ScanDevice} device - * @returns {ScanDevice} - */ - static decorate(device) { - device.name = device.id; - for (const key in device.features) { - const feature = device.features[key]; - feature.parameters = feature.parameters.replace(/^auto\|/, ''); - switch (key) { - case '--mode': - case '--source': - feature.options = feature.parameters.split('|'); - break; - - case '--resolution': - Feature.resolution(feature); - break; - - case '-l': - case '-t': - case '-x': - case '-y': - Feature.geometry(feature); - break; - - case '--brightness': - case '--contrast': - Feature.lighting(feature); - break; - } - } - - return device; - } - /** - * Parses the response of scanimage -A into a dictionary - * @param {string} response - * @returns {ScanDevice} + * Parses the response of scanimage -A into a ScanDevice */ - static parse(response) { - if (response === null || response === '') { + parse() { + if (this.string === null || this.string === '') { throw new Error('No device found'); } - - /** @type {ScanDevice} */ - let device = { - 'id': '', - 'features': {} - }; - + // find // any number of spaces // match 1 or two hyphens with letters, numbers or hypen // match anything (until square brackets) // match anything inside square brackets - let pattern = /\s+([-]{1,2}[-a-zA-Z0-9]+) ?(.*) \[(.*)\]\n/g; - let match; - while ((match = pattern.exec(response)) !== null) { - if (!['inactive', 'read-only'].includes(match[3])) { - device.features[match[1]] = { - 'default': match[3], - 'parameters': match[2] - }; - } - } + Util.matchAll(/\s+([-]{1,2}[-a-zA-Z0-9]+ ?.* \[.*\])\n/g, this.string) + .map(m => m[1]) + .map(Feature.parse) + .filter(f => f.enabled) + .forEach(f => this.features[f.name] = f); - pattern = /All options specific to device `(.*)'/; - match = pattern.exec(response); + const match = /All options specific to device `(.*)'/.exec(this.string); if (match) { - device.id = match[1]; - } - - if (match === null) { + this.id = match[1]; + this.name = this.id; + } else { throw new Error('Scanimage output contains no matching expressions'); } - - return device; - } -} -class Device { - constructor() { + this.validate(); } validate() { - const mandatory = ['--resolution', '-l', '-t', '-x', '-y']; + const mandatory = ['--resolution']; for (const feature of mandatory) { if (this.features[feature] === undefined) { throw `${feature} is missing from device`; @@ -165,19 +58,12 @@ class Device { } /** - * @param {any|string} o + * @param {string} s * @returns {ScanDevice} */ - static from(o) { - const device = new Device(); - if (typeof o === 'object') { - const decorated = Adapter.decorate(o); - extend(device, decorated); - device.validate(); - return device; - } else if (typeof o === 'string') { - const data = Adapter.parse(o); - return Device.from(data); + static from(s) { + if (typeof s === 'string') { + return new Device(s); } else { throw new Error('Unexpected data for Device'); } diff --git a/packages/server/src/devices.js b/packages/server/src/devices.js index bcb32965..ada335ab 100644 --- a/packages/server/src/devices.js +++ b/packages/server/src/devices.js @@ -5,16 +5,11 @@ const FileInfo = require('./file-info'); const userOptions = require('./user-options'); const Process = require('./process'); const Scanimage = require('./scanimage'); +const Util = require('./util'); class Devices { static _parseDevices(s) { - const deviceIds = []; - const pattern = /device `?(.*)'.*/g; - let match; - while ((match = pattern.exec(s)) !== null) { - deviceIds.push(match[1]); - } - return deviceIds; + return Util.matchAll(/device `?(.*)'.*/g, s).map(m => m[1]); } /** @@ -50,7 +45,7 @@ class Devices { let devices = null; if (file.exists()) { - devices = Devices.from(file.toJson()); + devices = Devices.from(file.parseJson()); if (devices.length === 0) { log.debug('devices.json contains no devices. Reloading'); devices = null; @@ -69,6 +64,7 @@ class Devices { deviceIds = deviceIds.concat(localDevices); } + /** @type {ScanDevice[]} */ devices = []; for (let deviceId of deviceIds) { try { @@ -79,7 +75,7 @@ class Devices { log.error(`Ignoring ${deviceId}. Error: ${error}`); } } - file.save(JSON.stringify(devices, null, 2)); + file.save(JSON.stringify(devices.map(d => d.string), null, 2)); } userOptions.applyToDevices(devices); diff --git a/packages/server/src/feature.js b/packages/server/src/feature.js new file mode 100644 index 00000000..c68d1446 --- /dev/null +++ b/packages/server/src/feature.js @@ -0,0 +1,107 @@ +/** + * @param {number} n + * @returns {number} + */ +function round(n) { + return Math.floor(n * 10) / 10; +} + +class Feature { + /** + * Constructor + */ + constructor() { + } + + get enabled() { + return !['inactive', 'read-only'].includes(this.default); + } + + /** + * @param {string} string + * @param {string} delimiter + * @returns {number[]} + */ + static splitNumbers(string, delimiter) { + return string.replace(/[a-z%]/ig, '') + .split(delimiter) + .filter(s => s.length > 0) + .map(s => Number(s)); + } + + range() { + this.default = round(Number(this.default)); + const range = /(.*?)(?:\s|$)/g.exec(this.parameters); + this.limits = Feature.splitNumbers(range[1], '..'); + const steps = /\(in steps of (\d+\.?\d*)\)/g.exec(this.parameters); + this.interval = steps ? Number(steps[1]) : 1; + } + + resolution() { + if (this.parameters.indexOf('|') > -1) { + this.options = Feature.splitNumbers(this.parameters, '|'); + this.default = Number(this.default); + + } else if (this.parameters.indexOf('..') > -1) { + this.range(this); + const limits = this.limits; + this.options = []; + for (let value = limits[1]; value > limits[0]; value /= 2) { + this.options.push(value); + } + this.options.push(limits[0]); + this.options.sort((a, b) => a - b); + } + } + + geometry() { + this.range(); + this.limits[0] = round(this.limits[0]); + this.limits[1] = round(this.limits[1]); + } + + lighting() { + this.range(); + } + + load() { + this.parameters = this.parameters.replace(/^auto\|/, ''); + if (this.enabled) { + switch (this.name) { + case '--mode': + case '--source': + this.options = this.parameters.split('|'); + break; + + case '--resolution': + this.resolution(); + break; + + case '-l': + case '-t': + case '-x': + case '-y': + this.geometry(); + break; + + case '--brightness': + case '--contrast': + this.lighting(); + break; + } + } + } + + static parse(s) { + const match = /^\s*([-]{1,2}[-a-zA-Z0-9]+) ?(.*) \[(.*)\]$/g.exec(s); + const feature = new Feature(); + feature.text = s; + feature.name = match[1]; + feature.default = match[3]; + feature.parameters = match[2]; + feature.load(); + return feature; + } +} + +module.exports = Feature; diff --git a/packages/server/src/file-info.js b/packages/server/src/file-info.js index 3d74379b..8ad933e7 100644 --- a/packages/server/src/file-info.js +++ b/packages/server/src/file-info.js @@ -176,7 +176,7 @@ class FileInfo { /** * @returns {any} */ - toJson() { + parseJson() { return JSON.parse(this.toText()); } diff --git a/packages/server/src/request.js b/packages/server/src/request.js index 52c103bc..c9dedb6e 100644 --- a/packages/server/src/request.js +++ b/packages/server/src/request.js @@ -26,10 +26,6 @@ class Request { extend(this, { params: { deviceId: device.id, - top: bound(data.params.top, features['-t'].limits[0], features['-t'].limits[1], 0), - left: bound(data.params.left, features['-l'].limits[0], features['-l'].limits[1], 0), - width: bound(data.params.width, features['-x'].limits[0], features['-x'].limits[1], features['-x'].limits[1]), - height: bound(data.params.height, features['-y'].limits[0], features['-y'].limits[1], features['-y'].limits[1]), resolution: data.params.resolution || features['--resolution'].default, format: 'tiff' }, @@ -39,6 +35,12 @@ class Request { index: data.index || 1 }); + if (device.geometry) { + this.params.top = bound(data.params.top, features['-t'].limits[0], features['-t'].limits[1], 0); + this.params.left = bound(data.params.left, features['-l'].limits[0], features['-l'].limits[1], 0); + this.params.width = bound(data.params.width, features['-x'].limits[0], features['-x'].limits[1], features['-x'].limits[1]); + this.params.height = bound(data.params.height, features['-y'].limits[0], features['-y'].limits[1], features['-y'].limits[1]); + } if ('--mode' in features) { this.params.mode = data.params.mode || features['--mode'].default; } diff --git a/packages/server/src/scan-controller.js b/packages/server/src/scan-controller.js index 461659fd..bf1394af 100644 --- a/packages/server/src/scan-controller.js +++ b/packages/server/src/scan-controller.js @@ -131,22 +131,22 @@ class ScanController { async updatePreview(filename) { const dpmm = this.request.params.resolution / 25.4; const device = this.context.getDevice(this.request.params.deviceId); - const geometry = { - width: device.features['-x'].limits[1] * dpmm, - height: device.features['-y'].limits[1] * dpmm, - left: this.request.params.left * dpmm, - top: this.request.params.top * dpmm - }; - - const cmd = new CmdBuilder(Config.convert) - .arg(`'${Config.tempDirectory}/${filename}'`) - .arg('-background', '#808080') - .arg('-extent', `${geometry.width}x${geometry.height}-${geometry.left}-${geometry.top}`) - .arg('-resize', 868) - .arg(`'${Config.previewDirectory}/preview.tif'`) - .build(); - - await Process.spawn(cmd); + const cmdBuilder = new CmdBuilder(Config.convert).arg(`'${Config.tempDirectory}/${filename}'`); + if (device.geometry) { + const geometry = { + width: device.features['-x'].limits[1] * dpmm, + height: device.features['-y'].limits[1] * dpmm, + left: this.request.params.left * dpmm, + top: this.request.params.top * dpmm + }; + cmdBuilder.arg('-background', '#808080') + .arg('-extent', `${geometry.width}x${geometry.height}-${geometry.left}-${geometry.top}`); + } + + cmdBuilder.arg('-resize', 868) + .arg(`'${Config.previewDirectory}/preview.tif'`); + + await Process.spawn(cmdBuilder.build()); } /** diff --git a/packages/server/src/scanimage.js b/packages/server/src/scanimage.js index 994b1659..7da5a6d8 100644 --- a/packages/server/src/scanimage.js +++ b/packages/server/src/scanimage.js @@ -53,12 +53,16 @@ class Scanimage { cmdBuilder.arg('--source', params.source); } - cmdBuilder.arg('--resolution', params.resolution) - .arg('-l', params.left) - .arg('-t', params.top) - .arg('-x', params.width) - .arg('-y', params.height) - .arg('--format', params.format); + cmdBuilder.arg('--resolution', params.resolution); + + if (['left', 'top', 'width', 'height'].every(s => s in params)) { + cmdBuilder.arg('-l', params.left) + .arg('-t', params.top) + .arg('-x', params.width) + .arg('-y', params.height); + } + + cmdBuilder.arg('--format', params.format); if ('depth' in params) { cmdBuilder.arg('--depth', params.depth); diff --git a/packages/server/src/types.js b/packages/server/src/types.js index 5bd1ca56..6db5b8eb 100644 --- a/packages/server/src/types.js +++ b/packages/server/src/types.js @@ -24,7 +24,8 @@ * @typedef {Object} ScanDevice * @property {string} id * @property {string} name - * @property {string} version + * @property {string} string + * @property {boolean} geometry * @property {Object.} features */ diff --git a/packages/server/src/util.js b/packages/server/src/util.js index 554da335..a9704d51 100644 --- a/packages/server/src/util.js +++ b/packages/server/src/util.js @@ -1,6 +1,23 @@ const AdmZip = require('adm-zip'); const Util = { + /** + * Rough polyfill for str.matchAll() + * @param {RegExp} regex + * @param {string} string + * @returns {RegExpExecArray[]} + */ + matchAll(regex, string) { + /** @type {RegExpExecArray[]} */ + const result = []; + /** @type {RegExpExecArray} */ + let match; + while ((match = regex.exec(string)) !== null) { + result.push(match); + } + return result; + }, + extend() { const t = arguments[0]; for (let i = 1; i < arguments.length; i++) { diff --git a/packages/server/test/device.test.js b/packages/server/test/device.test.js index eb6517f0..40700e14 100644 --- a/packages/server/test/device.test.js +++ b/packages/server/test/device.test.js @@ -203,4 +203,23 @@ describe('Device', () => { assert.strictEqual(device.features['-y'].limits[1], 297.1); assert.strictEqual(device.features['-y'].default, 297.1); }); + + it('scanimage-a10.txt', () => { + const file = FileInfo.create('test/resource/scanimage-a10.txt'); + const device = Device.from(file.toText()); + + assert.strictEqual(device.id, 'epjitsu:libusb:001:003'); + assert.deepStrictEqual(device.features['--mode'].options, ['Lineart', 'Gray', 'Color']); + assert.strictEqual(device.features['--mode'].default, 'Lineart'); + assert.deepStrictEqual(device.features['--source'].options, ['ADF Front', 'ADF Back', 'ADF Duplex']); + assert.strictEqual(device.features['--source'].default, 'ADF Front'); + assert.deepStrictEqual(device.features['--resolution'].options, [50, 75, 150, 300, 600]); + assert.strictEqual(device.features['--resolution'].default, 300); + assert.strictEqual(device.features['-l'], undefined); + assert.strictEqual(device.features['-x'], undefined); + assert.deepStrictEqual(device.features['-t'].limits, [0, 289.3]); + assert.strictEqual(device.features['-t'].default, 0); + assert.strictEqual(device.features['-t'].interval, 0.0211639); + assert.strictEqual(device.features['-y'], undefined); + }); }); \ No newline at end of file diff --git a/packages/server/test/feature.test.js b/packages/server/test/feature.test.js new file mode 100644 index 00000000..e3e607c2 --- /dev/null +++ b/packages/server/test/feature.test.js @@ -0,0 +1,69 @@ +/* eslint-env mocha */ +const assert = require('assert'); +const Feature = require('../src/feature'); + +describe('Feature', () => { + it('range-brightness', () => { + const f = Feature.parse('--brightness -127..127 (in steps of 1) [0]'); + assert.strictEqual(f.name, '--brightness'); + assert.strictEqual(f.interval, 1); + assert.strictEqual(f.default, 0); + assert.strictEqual(f.enabled, true); + assert.deepStrictEqual(f.limits, [-127, 127]); + }); + + it('range-contrast', () => { + const f = Feature.parse(' --contrast 0..255 (in steps of 1) [120]'); + assert.strictEqual(f.name, '--contrast'); + assert.strictEqual(f.interval, 1); + assert.strictEqual(f.default, 120); + assert.strictEqual(f.enabled, true); + assert.deepStrictEqual(f.limits, [0, 255]); + }); + + it('range-geometry1', () => { + const f = Feature.parse(' -t 0..289.353mm (in steps of 0.0211639) [0]'); + assert.strictEqual(f.name, '-t'); + assert.strictEqual(f.interval, 0.0211639); + assert.strictEqual(f.default, 0); + assert.strictEqual(f.enabled, true); + assert.deepStrictEqual(f.limits, [0, 289.3]); + }); + + it('range-geometry2', () => { + const f = Feature.parse(' -l 0..215mm [0]'); + assert.strictEqual(f.name, '-l'); + assert.strictEqual(f.interval, 1); + assert.strictEqual(f.default, 0); + assert.strictEqual(f.enabled, true); + assert.deepStrictEqual(f.limits, [0, 215]); + }); + + it('range-source-inactive', () => { + const f = Feature.parse(' --source Normal|Transparency|Negative [inactive]'); + assert.strictEqual(f.name, '--source'); + assert.strictEqual(f.interval, undefined); + assert.strictEqual(f.default, 'inactive'); + assert.strictEqual(f.enabled, false); + assert.strictEqual(f.options, undefined); + }); + + it('range-resolution-range', () => { + const f = Feature.parse('--resolution 50..1200dpi [50]'); + assert.strictEqual(f.name, '--resolution'); + assert.strictEqual(f.interval, 1); + assert.strictEqual(f.default, 50); + assert.strictEqual(f.enabled, true); + assert.deepStrictEqual(f.options, [50, 75, 150, 300, 600, 1200]); + }); + + it('range-resolution-options', () => { + const f = Feature.parse('--resolution 75|300|600|1200dpi [75]'); + assert.strictEqual(f.name, '--resolution'); + assert.strictEqual(f.interval, undefined); + assert.strictEqual(f.default, 75); + assert.strictEqual(f.enabled, true); + assert.deepStrictEqual(f.options, [75, 300, 600, 1200]); + }); + +}); \ No newline at end of file diff --git a/packages/server/test/resource/scanimage-a10.txt b/packages/server/test/resource/scanimage-a10.txt new file mode 100644 index 00000000..a40cbe2c --- /dev/null +++ b/packages/server/test/resource/scanimage-a10.txt @@ -0,0 +1,37 @@ +All options specific to device `epjitsu:libusb:001:003': + Scan Mode: + --source ADF Front|ADF Back|ADF Duplex [ADF Front] + Selects the scan source (such as a document-feeder). + --mode Lineart|Gray|Color [Lineart] + Selects the scan mode (e.g., lineart, monochrome, or color). + --resolution 50..600dpi (in steps of 1) [300] + Sets the resolution of the scanned image. + Geometry: + -t 0..289.353mm (in steps of 0.0211639) [0] + Top-left y position of scan area. + --page-width 2.70898..219.428mm (in steps of 0.0211639) [215.872] + Specifies the width of the media. Required for automatic centering of + sheet-fed scans. + --page-height 0..450.707mm (in steps of 0.0211639) [292.062] + Specifies the height of the media, 0 will auto-detect. + Enhancement: + --brightness -127..127 (in steps of 1) [0] + Controls the brightness of the acquired image. + --contrast -127..127 (in steps of 1) [0] + Controls the contrast of the acquired image. + --threshold 0..255 (in steps of 1) [120] + Select minimum-brightness to get a white point + --threshold-curve 0..127 (in steps of 1) [55] + Dynamic threshold curve, from light to dark, normally 50-65 + Sensors: + --scan[=(yes|no)] [no] [hardware] + Scan button + --page-loaded[=(yes|no)] [no] [hardware] + Page loaded + --top-edge[=(yes|no)] [no] [hardware] + Paper is pulled partly into adf + --cover-open[=(yes|no)] [no] [hardware] + Cover open + --power-save[=(yes|no)] [no] [hardware] + Scanner in power saving mode +