Add application

This commit is contained in:
ibu ☉ radempa 2024-11-21 13:23:24 +01:00
parent dba06df091
commit 62bb338898
16 changed files with 1977 additions and 0 deletions

14
Pipfile Normal file
View file

@ -0,0 +1,14 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
pillow = "*"
aiohttp = "*"
aiohttp-jinja2 = "*"
[dev-packages]
[requires]
python_version = "3.11"

588
Pipfile.lock generated Normal file
View file

@ -0,0 +1,588 @@
{
"_meta": {
"hash": {
"sha256": "0c8d4376bace43c538f4e1c619ac0a13ecf25da4135c47c3ac11a2c46c3102c9"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.11"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"aiohttp": {
"hashes": [
"sha256:00ad4b6f185ec67f3e6562e8a1d2b69660be43070bd0ef6fcec5211154c7df67",
"sha256:0175d745d9e85c40dcc51c8f88c74bfbaef9e7afeeeb9d03c37977270303064c",
"sha256:01d4c0c874aa4ddfb8098e85d10b5e875a70adc63db91f1ae65a4b04d3344cda",
"sha256:043d2299f6dfdc92f0ac5e995dfc56668e1587cea7f9aa9d8a78a1b6554e5755",
"sha256:0c413c633d0512df4dc7fd2373ec06cc6a815b7b6d6c2f208ada7e9e93a5061d",
"sha256:0d21c684808288a98914e5aaf2a7c6a3179d4df11d249799c32d1808e79503b5",
"sha256:0e584a10f204a617d71d359fe383406305a4b595b333721fa50b867b4a0a1548",
"sha256:1274477e4c71ce8cfe6c1ec2f806d57c015ebf84d83373676036e256bc55d690",
"sha256:13bf85afc99ce6f9ee3567b04501f18f9f8dbbb2ea11ed1a2e079670403a7c84",
"sha256:153c2549f6c004d2754cc60603d4668899c9895b8a89397444a9c4efa282aaf4",
"sha256:1f7372f7341fcc16f57b2caded43e81ddd18df53320b6f9f042acad41f8e049a",
"sha256:23fb25a9f0a1ca1f24c0a371523546366bb642397c94ab45ad3aedf2941cec6a",
"sha256:28c543e54710d6158fc6f439296c7865b29e0b616629767e685a7185fab4a6b9",
"sha256:2a482e6da906d5e6e653be079b29bc173a48e381600161c9932d89dfae5942ef",
"sha256:2ad5c3c4590bb3cc28b4382f031f3783f25ec223557124c68754a2231d989e2b",
"sha256:2ce2ac5708501afc4847221a521f7e4b245abf5178cf5ddae9d5b3856ddb2f3a",
"sha256:2cf57fb50be5f52bda004b8893e63b48530ed9f0d6c96c84620dc92fe3cd9b9d",
"sha256:2e1b1e51b0774408f091d268648e3d57f7260c1682e7d3a63cb00d22d71bb945",
"sha256:2e2e9839e14dd5308ee773c97115f1e0a1cb1d75cbeeee9f33824fa5144c7634",
"sha256:2e460be6978fc24e3df83193dc0cc4de46c9909ed92dd47d349a452ef49325b7",
"sha256:312fcfbacc7880a8da0ae8b6abc6cc7d752e9caa0051a53d217a650b25e9a691",
"sha256:33279701c04351a2914e1100b62b2a7fdb9a25995c4a104259f9a5ead7ed4802",
"sha256:33776e945d89b29251b33a7e7d006ce86447b2cfd66db5e5ded4e5cd0340585c",
"sha256:34dd0c107799dcbbf7d48b53be761a013c0adf5571bf50c4ecad5643fe9cfcd0",
"sha256:3562b06567c06439d8b447037bb655ef69786c590b1de86c7ab81efe1c9c15d8",
"sha256:368a42363c4d70ab52c2c6420a57f190ed3dfaca6a1b19afda8165ee16416a82",
"sha256:4149d34c32f9638f38f544b3977a4c24052042affa895352d3636fa8bffd030a",
"sha256:461908b2578955045efde733719d62f2b649c404189a09a632d245b445c9c975",
"sha256:4a01951fabc4ce26ab791da5f3f24dca6d9a6f24121746eb19756416ff2d881b",
"sha256:4e874cbf8caf8959d2adf572a78bba17cb0e9d7e51bb83d86a3697b686a0ab4d",
"sha256:4f21e83f355643c345177a5d1d8079f9f28b5133bcd154193b799d380331d5d3",
"sha256:5443910d662db951b2e58eb70b0fbe6b6e2ae613477129a5805d0b66c54b6cb7",
"sha256:5798a9aad1879f626589f3df0f8b79b3608a92e9beab10e5fda02c8a2c60db2e",
"sha256:5d20003b635fc6ae3f96d7260281dfaf1894fc3aa24d1888a9b2628e97c241e5",
"sha256:5db3a5b833764280ed7618393832e0853e40f3d3e9aa128ac0ba0f8278d08649",
"sha256:5ed1c46fb119f1b59304b5ec89f834f07124cd23ae5b74288e364477641060ff",
"sha256:62360cb771707cb70a6fd114b9871d20d7dd2163a0feafe43fd115cfe4fe845e",
"sha256:6809a00deaf3810e38c628e9a33271892f815b853605a936e2e9e5129762356c",
"sha256:68c5a82c8779bdfc6367c967a4a1b2aa52cd3595388bf5961a62158ee8a59e22",
"sha256:6e4a280e4b975a2e7745573e3fc9c9ba0d1194a3738ce1cbaa80626cc9b4f4df",
"sha256:6e6783bcc45f397fdebc118d772103d751b54cddf5b60fbcc958382d7dd64f3e",
"sha256:72a860c215e26192379f57cae5ab12b168b75db8271f111019509a1196dfc780",
"sha256:7607ec3ce4993464368505888af5beb446845a014bc676d349efec0e05085905",
"sha256:773dd01706d4db536335fcfae6ea2440a70ceb03dd3e7378f3e815b03c97ab51",
"sha256:78d847e4cde6ecc19125ccbc9bfac4a7ab37c234dd88fbb3c5c524e8e14da543",
"sha256:7dde0009408969a43b04c16cbbe252c4f5ef4574ac226bc8815cd7342d2028b6",
"sha256:80bd372b8d0715c66c974cf57fe363621a02f359f1ec81cba97366948c7fc873",
"sha256:841cd8233cbd2111a0ef0a522ce016357c5e3aff8a8ce92bcfa14cef890d698f",
"sha256:84de26ddf621d7ac4c975dbea4c945860e08cccde492269db4e1538a6a6f3c35",
"sha256:84f8ae3e09a34f35c18fa57f015cc394bd1389bce02503fb30c394d04ee6b938",
"sha256:8af740fc2711ad85f1a5c034a435782fbd5b5f8314c9a3ef071424a8158d7f6b",
"sha256:8b929b9bd7cd7c3939f8bcfffa92fae7480bd1aa425279d51a89327d600c704d",
"sha256:910bec0c49637d213f5d9877105d26e0c4a4de2f8b1b29405ff37e9fc0ad52b8",
"sha256:96943e5dcc37a6529d18766597c491798b7eb7a61d48878611298afc1fca946c",
"sha256:a0215ce6041d501f3155dc219712bc41252d0ab76474615b9700d63d4d9292af",
"sha256:a3cf433f127efa43fee6b90ea4c6edf6c4a17109d1d037d1a52abec84d8f2e42",
"sha256:a6ce61195c6a19c785df04e71a4537e29eaa2c50fe745b732aa937c0c77169f3",
"sha256:a7a75ef35f2df54ad55dbf4b73fe1da96f370e51b10c91f08b19603c64004acc",
"sha256:a94159871304770da4dd371f4291b20cac04e8c94f11bdea1c3478e557fbe0d8",
"sha256:aa1990247f02a54185dc0dff92a6904521172a22664c863a03ff64c42f9b5410",
"sha256:ab88bafedc57dd0aab55fa728ea10c1911f7e4d8b43e1d838a1739f33712921c",
"sha256:ad093e823df03bb3fd37e7dec9d4670c34f9e24aeace76808fc20a507cace825",
"sha256:ae871a964e1987a943d83d6709d20ec6103ca1eaf52f7e0d36ee1b5bebb8b9b9",
"sha256:b0ba0d15164eae3d878260d4c4df859bbdc6466e9e6689c344a13334f988bb53",
"sha256:b5411d82cddd212644cf9360879eb5080f0d5f7d809d03262c50dad02f01421a",
"sha256:b9552ec52cc147dbf1944ac7ac98af7602e51ea2dcd076ed194ca3c0d1c7d0bc",
"sha256:bfb9162dcf01f615462b995a516ba03e769de0789de1cadc0f916265c257e5d8",
"sha256:c0a9034379a37ae42dea7ac1e048352d96286626251862e448933c0f59cbd79c",
"sha256:c1161b345c0a444ebcf46bf0a740ba5dcf50612fd3d0528883fdc0eff578006a",
"sha256:c11f5b099adafb18e65c2c997d57108b5bbeaa9eeee64a84302c0978b1ec948b",
"sha256:c44e65da1de4403d0576473e2344828ef9c4c6244d65cf4b75549bb46d40b8dd",
"sha256:c48c5c0271149cfe467c0ff8eb941279fd6e3f65c9a388c984e0e6cf57538e14",
"sha256:c7a815258e5895d8900aec4454f38dca9aed71085f227537208057853f9d13f2",
"sha256:cae533195e8122584ec87531d6df000ad07737eaa3c81209e85c928854d2195c",
"sha256:cc14be025665dba6202b6a71cfcdb53210cc498e50068bc088076624471f8bb9",
"sha256:cd56db019015b6acfaaf92e1ac40eb8434847d9bf88b4be4efe5bfd260aee692",
"sha256:d827176898a2b0b09694fbd1088c7a31836d1a505c243811c87ae53a3f6273c1",
"sha256:df72ac063b97837a80d80dec8d54c241af059cc9bb42c4de68bd5b61ceb37caa",
"sha256:e5980a746d547a6ba173fd5ee85ce9077e72d118758db05d229044b469d9029a",
"sha256:e5d47ae48db0b2dcf70bc8a3bc72b3de86e2a590fc299fdbbb15af320d2659de",
"sha256:e91d635961bec2d8f19dfeb41a539eb94bd073f075ca6dae6c8dc0ee89ad6f91",
"sha256:ea353162f249c8097ea63c2169dd1aa55de1e8fecbe63412a9bc50816e87b761",
"sha256:eaeed7abfb5d64c539e2db173f63631455f1196c37d9d8d873fc316470dfbacd",
"sha256:eca4bf3734c541dc4f374ad6010a68ff6c6748f00451707f39857f429ca36ced",
"sha256:f83a552443a526ea38d064588613aca983d0ee0038801bc93c0c916428310c28",
"sha256:fb1558def481d84f03b45888473fc5a1f35747b5f334ef4e7a571bc0dfcb11f8",
"sha256:fd1ed388ea7fbed22c4968dd64bab0198de60750a25fe8c0c9d4bef5abe13824"
],
"index": "pypi",
"version": "==3.8.5"
},
"aiohttp-jinja2": {
"hashes": [
"sha256:45cf00b80ab4dcc19515df13a929826eeb9698e76a3bcfd99112418751f5a061",
"sha256:8d149b2a57d91f794b33a394ea5bc66b567f38c74a5a6a9477afc2450f105c01"
],
"index": "pypi",
"version": "==1.5.1"
},
"aiosignal": {
"hashes": [
"sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc",
"sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"
],
"markers": "python_version >= '3.7'",
"version": "==1.3.1"
},
"async-timeout": {
"hashes": [
"sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f",
"sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"
],
"markers": "python_version >= '3.7'",
"version": "==4.0.3"
},
"attrs": {
"hashes": [
"sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04",
"sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"
],
"markers": "python_version >= '3.7'",
"version": "==23.1.0"
},
"charset-normalizer": {
"hashes": [
"sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96",
"sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c",
"sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710",
"sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706",
"sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020",
"sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252",
"sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad",
"sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329",
"sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a",
"sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f",
"sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6",
"sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4",
"sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a",
"sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46",
"sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2",
"sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23",
"sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace",
"sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd",
"sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982",
"sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10",
"sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2",
"sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea",
"sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09",
"sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5",
"sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149",
"sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489",
"sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9",
"sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80",
"sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592",
"sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3",
"sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6",
"sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed",
"sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c",
"sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200",
"sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a",
"sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e",
"sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d",
"sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6",
"sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623",
"sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669",
"sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3",
"sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa",
"sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9",
"sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2",
"sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f",
"sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1",
"sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4",
"sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a",
"sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8",
"sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3",
"sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029",
"sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f",
"sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959",
"sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22",
"sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7",
"sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952",
"sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346",
"sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e",
"sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d",
"sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299",
"sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd",
"sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a",
"sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3",
"sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037",
"sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94",
"sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c",
"sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858",
"sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a",
"sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449",
"sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c",
"sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918",
"sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1",
"sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c",
"sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac",
"sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa"
],
"markers": "python_full_version >= '3.7.0'",
"version": "==3.2.0"
},
"frozenlist": {
"hashes": [
"sha256:007df07a6e3eb3e33e9a1fe6a9db7af152bbd8a185f9aaa6ece10a3529e3e1c6",
"sha256:008eb8b31b3ea6896da16c38c1b136cb9fec9e249e77f6211d479db79a4eaf01",
"sha256:09163bdf0b2907454042edb19f887c6d33806adc71fbd54afc14908bfdc22251",
"sha256:0c7c1b47859ee2cac3846fde1c1dc0f15da6cec5a0e5c72d101e0f83dcb67ff9",
"sha256:0e5c8764c7829343d919cc2dfc587a8db01c4f70a4ebbc49abde5d4b158b007b",
"sha256:10ff5faaa22786315ef57097a279b833ecab1a0bfb07d604c9cbb1c4cdc2ed87",
"sha256:17ae5cd0f333f94f2e03aaf140bb762c64783935cc764ff9c82dff626089bebf",
"sha256:19488c57c12d4e8095a922f328df3f179c820c212940a498623ed39160bc3c2f",
"sha256:1a0848b52815006ea6596c395f87449f693dc419061cc21e970f139d466dc0a0",
"sha256:1e78fb68cf9c1a6aa4a9a12e960a5c9dfbdb89b3695197aa7064705662515de2",
"sha256:261b9f5d17cac914531331ff1b1d452125bf5daa05faf73b71d935485b0c510b",
"sha256:2b8bcf994563466db019fab287ff390fffbfdb4f905fc77bc1c1d604b1c689cc",
"sha256:38461d02d66de17455072c9ba981d35f1d2a73024bee7790ac2f9e361ef1cd0c",
"sha256:490132667476f6781b4c9458298b0c1cddf237488abd228b0b3650e5ecba7467",
"sha256:491e014f5c43656da08958808588cc6c016847b4360e327a62cb308c791bd2d9",
"sha256:515e1abc578dd3b275d6a5114030b1330ba044ffba03f94091842852f806f1c1",
"sha256:556de4430ce324c836789fa4560ca62d1591d2538b8ceb0b4f68fb7b2384a27a",
"sha256:5833593c25ac59ede40ed4de6d67eb42928cca97f26feea219f21d0ed0959b79",
"sha256:6221d84d463fb110bdd7619b69cb43878a11d51cbb9394ae3105d082d5199167",
"sha256:6918d49b1f90821e93069682c06ffde41829c346c66b721e65a5c62b4bab0300",
"sha256:6c38721585f285203e4b4132a352eb3daa19121a035f3182e08e437cface44bf",
"sha256:71932b597f9895f011f47f17d6428252fc728ba2ae6024e13c3398a087c2cdea",
"sha256:7211ef110a9194b6042449431e08c4d80c0481e5891e58d429df5899690511c2",
"sha256:764226ceef3125e53ea2cb275000e309c0aa5464d43bd72abd661e27fffc26ab",
"sha256:7645a8e814a3ee34a89c4a372011dcd817964ce8cb273c8ed6119d706e9613e3",
"sha256:76d4711f6f6d08551a7e9ef28c722f4a50dd0fc204c56b4bcd95c6cc05ce6fbb",
"sha256:7f4f399d28478d1f604c2ff9119907af9726aed73680e5ed1ca634d377abb087",
"sha256:88f7bc0fcca81f985f78dd0fa68d2c75abf8272b1f5c323ea4a01a4d7a614efc",
"sha256:8d0edd6b1c7fb94922bf569c9b092ee187a83f03fb1a63076e7774b60f9481a8",
"sha256:901289d524fdd571be1c7be054f48b1f88ce8dddcbdf1ec698b27d4b8b9e5d62",
"sha256:93ea75c050c5bb3d98016b4ba2497851eadf0ac154d88a67d7a6816206f6fa7f",
"sha256:981b9ab5a0a3178ff413bca62526bb784249421c24ad7381e39d67981be2c326",
"sha256:9ac08e601308e41eb533f232dbf6b7e4cea762f9f84f6357136eed926c15d12c",
"sha256:a02eb8ab2b8f200179b5f62b59757685ae9987996ae549ccf30f983f40602431",
"sha256:a0c6da9aee33ff0b1a451e867da0c1f47408112b3391dd43133838339e410963",
"sha256:a6c8097e01886188e5be3e6b14e94ab365f384736aa1fca6a0b9e35bd4a30bc7",
"sha256:aa384489fefeb62321b238e64c07ef48398fe80f9e1e6afeff22e140e0850eef",
"sha256:ad2a9eb6d9839ae241701d0918f54c51365a51407fd80f6b8289e2dfca977cc3",
"sha256:b206646d176a007466358aa21d85cd8600a415c67c9bd15403336c331a10d956",
"sha256:b826d97e4276750beca7c8f0f1a4938892697a6bcd8ec8217b3312dad6982781",
"sha256:b89ac9768b82205936771f8d2eb3ce88503b1556324c9f903e7156669f521472",
"sha256:bd7bd3b3830247580de99c99ea2a01416dfc3c34471ca1298bccabf86d0ff4dc",
"sha256:bdf1847068c362f16b353163391210269e4f0569a3c166bc6a9f74ccbfc7e839",
"sha256:c11b0746f5d946fecf750428a95f3e9ebe792c1ee3b1e96eeba145dc631a9672",
"sha256:c5374b80521d3d3f2ec5572e05adc94601985cc526fb276d0c8574a6d749f1b3",
"sha256:ca265542ca427bf97aed183c1676e2a9c66942e822b14dc6e5f42e038f92a503",
"sha256:ce31ae3e19f3c902de379cf1323d90c649425b86de7bbdf82871b8a2a0615f3d",
"sha256:ceb6ec0a10c65540421e20ebd29083c50e6d1143278746a4ef6bcf6153171eb8",
"sha256:d081f13b095d74b67d550de04df1c756831f3b83dc9881c38985834387487f1b",
"sha256:d5655a942f5f5d2c9ed93d72148226d75369b4f6952680211972a33e59b1dfdc",
"sha256:d5a32087d720c608f42caed0ef36d2b3ea61a9d09ee59a5142d6070da9041b8f",
"sha256:d6484756b12f40003c6128bfcc3fa9f0d49a687e171186c2d85ec82e3758c559",
"sha256:dd65632acaf0d47608190a71bfe46b209719bf2beb59507db08ccdbe712f969b",
"sha256:de343e75f40e972bae1ef6090267f8260c1446a1695e77096db6cfa25e759a95",
"sha256:e29cda763f752553fa14c68fb2195150bfab22b352572cb36c43c47bedba70eb",
"sha256:e41f3de4df3e80de75845d3e743b3f1c4c8613c3997a912dbf0229fc61a8b963",
"sha256:e66d2a64d44d50d2543405fb183a21f76b3b5fd16f130f5c99187c3fb4e64919",
"sha256:e74b0506fa5aa5598ac6a975a12aa8928cbb58e1f5ac8360792ef15de1aa848f",
"sha256:f0ed05f5079c708fe74bf9027e95125334b6978bf07fd5ab923e9e55e5fbb9d3",
"sha256:f61e2dc5ad442c52b4887f1fdc112f97caeff4d9e6ebe78879364ac59f1663e1",
"sha256:fec520865f42e5c7f050c2a79038897b1c7d1595e907a9e08e3353293ffc948e"
],
"markers": "python_version >= '3.8'",
"version": "==1.4.0"
},
"idna": {
"hashes": [
"sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4",
"sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"
],
"markers": "python_version >= '3.5'",
"version": "==3.4"
},
"jinja2": {
"hashes": [
"sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852",
"sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"
],
"markers": "python_version >= '3.7'",
"version": "==3.1.2"
},
"markupsafe": {
"hashes": [
"sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e",
"sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e",
"sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431",
"sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686",
"sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559",
"sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc",
"sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c",
"sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0",
"sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4",
"sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9",
"sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575",
"sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba",
"sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d",
"sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3",
"sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00",
"sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155",
"sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac",
"sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52",
"sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f",
"sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8",
"sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b",
"sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24",
"sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea",
"sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198",
"sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0",
"sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee",
"sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be",
"sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2",
"sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707",
"sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6",
"sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58",
"sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779",
"sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636",
"sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c",
"sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad",
"sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee",
"sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc",
"sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2",
"sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48",
"sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7",
"sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e",
"sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b",
"sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa",
"sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5",
"sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e",
"sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb",
"sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9",
"sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57",
"sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc",
"sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"
],
"markers": "python_version >= '3.7'",
"version": "==2.1.3"
},
"multidict": {
"hashes": [
"sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9",
"sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8",
"sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03",
"sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710",
"sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161",
"sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664",
"sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569",
"sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067",
"sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313",
"sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706",
"sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2",
"sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636",
"sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49",
"sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93",
"sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603",
"sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0",
"sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60",
"sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4",
"sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e",
"sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1",
"sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60",
"sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951",
"sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc",
"sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe",
"sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95",
"sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d",
"sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8",
"sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed",
"sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2",
"sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775",
"sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87",
"sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c",
"sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2",
"sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98",
"sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3",
"sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe",
"sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78",
"sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660",
"sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176",
"sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e",
"sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988",
"sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c",
"sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c",
"sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0",
"sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449",
"sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f",
"sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde",
"sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5",
"sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d",
"sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac",
"sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a",
"sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9",
"sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca",
"sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11",
"sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35",
"sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063",
"sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b",
"sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982",
"sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258",
"sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1",
"sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52",
"sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480",
"sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7",
"sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461",
"sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d",
"sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc",
"sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779",
"sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a",
"sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547",
"sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0",
"sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171",
"sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf",
"sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d",
"sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba"
],
"markers": "python_version >= '3.7'",
"version": "==6.0.4"
},
"pillow": {
"hashes": [
"sha256:00e65f5e822decd501e374b0650146063fbb30a7264b4d2744bdd7b913e0cab5",
"sha256:040586f7d37b34547153fa383f7f9aed68b738992380ac911447bb78f2abe530",
"sha256:0b6eb5502f45a60a3f411c63187db83a3d3107887ad0d036c13ce836f8a36f1d",
"sha256:1ce91b6ec08d866b14413d3f0bbdea7e24dfdc8e59f562bb77bc3fe60b6144ca",
"sha256:1f62406a884ae75fb2f818694469519fb685cc7eaff05d3451a9ebe55c646891",
"sha256:22c10cc517668d44b211717fd9775799ccec4124b9a7f7b3635fc5386e584992",
"sha256:3400aae60685b06bb96f99a21e1ada7bc7a413d5f49bce739828ecd9391bb8f7",
"sha256:349930d6e9c685c089284b013478d6f76e3a534e36ddfa912cde493f235372f3",
"sha256:368ab3dfb5f49e312231b6f27b8820c823652b7cd29cfbd34090565a015e99ba",
"sha256:38250a349b6b390ee6047a62c086d3817ac69022c127f8a5dc058c31ccef17f3",
"sha256:3a684105f7c32488f7153905a4e3015a3b6c7182e106fe3c37fbb5ef3e6994c3",
"sha256:3a82c40d706d9aa9734289740ce26460a11aeec2d9c79b7af87bb35f0073c12f",
"sha256:3b08d4cc24f471b2c8ca24ec060abf4bebc6b144cb89cba638c720546b1cf538",
"sha256:3ed64f9ca2f0a95411e88a4efbd7a29e5ce2cea36072c53dd9d26d9c76f753b3",
"sha256:3f07ea8d2f827d7d2a49ecf1639ec02d75ffd1b88dcc5b3a61bbb37a8759ad8d",
"sha256:520f2a520dc040512699f20fa1c363eed506e94248d71f85412b625026f6142c",
"sha256:5c6e3df6bdd396749bafd45314871b3d0af81ff935b2d188385e970052091017",
"sha256:608bfdee0d57cf297d32bcbb3c728dc1da0907519d1784962c5f0c68bb93e5a3",
"sha256:685ac03cc4ed5ebc15ad5c23bc555d68a87777586d970c2c3e216619a5476223",
"sha256:76de421f9c326da8f43d690110f0e79fe3ad1e54be811545d7d91898b4c8493e",
"sha256:76edb0a1fa2b4745fb0c99fb9fb98f8b180a1bbceb8be49b087e0b21867e77d3",
"sha256:7be600823e4c8631b74e4a0d38384c73f680e6105a7d3c6824fcf226c178c7e6",
"sha256:81ff539a12457809666fef6624684c008e00ff6bf455b4b89fd00a140eecd640",
"sha256:88af2003543cc40c80f6fca01411892ec52b11021b3dc22ec3bc9d5afd1c5334",
"sha256:8c11160913e3dd06c8ffdb5f233a4f254cb449f4dfc0f8f4549eda9e542c93d1",
"sha256:8f8182b523b2289f7c415f589118228d30ac8c355baa2f3194ced084dac2dbba",
"sha256:9211e7ad69d7c9401cfc0e23d49b69ca65ddd898976d660a2fa5904e3d7a9baa",
"sha256:92be919bbc9f7d09f7ae343c38f5bb21c973d2576c1d45600fce4b74bafa7ac0",
"sha256:9c82b5b3e043c7af0d95792d0d20ccf68f61a1fec6b3530e718b688422727396",
"sha256:9f7c16705f44e0504a3a2a14197c1f0b32a95731d251777dcb060aa83022cb2d",
"sha256:9fb218c8a12e51d7ead2a7c9e101a04982237d4855716af2e9499306728fb485",
"sha256:a74ba0c356aaa3bb8e3eb79606a87669e7ec6444be352870623025d75a14a2bf",
"sha256:b4f69b3700201b80bb82c3a97d5e9254084f6dd5fb5b16fc1a7b974260f89f43",
"sha256:bc2ec7c7b5d66b8ec9ce9f720dbb5fa4bace0f545acd34870eff4a369b44bf37",
"sha256:c189af0545965fa8d3b9613cfdb0cd37f9d71349e0f7750e1fd704648d475ed2",
"sha256:c1fbe7621c167ecaa38ad29643d77a9ce7311583761abf7836e1510c580bf3dd",
"sha256:c7cf14a27b0d6adfaebb3ae4153f1e516df54e47e42dcc073d7b3d76111a8d86",
"sha256:c9f72a021fbb792ce98306ffb0c348b3c9cb967dce0f12a49aa4c3d3fdefa967",
"sha256:cd25d2a9d2b36fcb318882481367956d2cf91329f6892fe5d385c346c0649629",
"sha256:ce543ed15570eedbb85df19b0a1a7314a9c8141a36ce089c0a894adbfccb4568",
"sha256:ce7b031a6fc11365970e6a5686d7ba8c63e4c1cf1ea143811acbb524295eabed",
"sha256:d35e3c8d9b1268cbf5d3670285feb3528f6680420eafe35cccc686b73c1e330f",
"sha256:d50b6aec14bc737742ca96e85d6d0a5f9bfbded018264b3b70ff9d8c33485551",
"sha256:d5d0dae4cfd56969d23d94dc8e89fb6a217be461c69090768227beb8ed28c0a3",
"sha256:d5db32e2a6ccbb3d34d87c87b432959e0db29755727afb37290e10f6e8e62614",
"sha256:d72e2ecc68a942e8cf9739619b7f408cc7b272b279b56b2c83c6123fcfa5cdff",
"sha256:d737a602fbd82afd892ca746392401b634e278cb65d55c4b7a8f48e9ef8d008d",
"sha256:d80cf684b541685fccdd84c485b31ce73fc5c9b5d7523bf1394ce134a60c6883",
"sha256:db24668940f82321e746773a4bc617bfac06ec831e5c88b643f91f122a785684",
"sha256:dbc02381779d412145331789b40cc7b11fdf449e5d94f6bc0b080db0a56ea3f0",
"sha256:dffe31a7f47b603318c609f378ebcd57f1554a3a6a8effbc59c3c69f804296de",
"sha256:edf4392b77bdc81f36e92d3a07a5cd072f90253197f4a52a55a8cec48a12483b",
"sha256:efe8c0681042536e0d06c11f48cebe759707c9e9abf880ee213541c5b46c5bf3",
"sha256:f31f9fdbfecb042d046f9d91270a0ba28368a723302786c0009ee9b9f1f60199",
"sha256:f88a0b92277de8e3ca715a0d79d68dc82807457dae3ab8699c758f07c20b3c51",
"sha256:faaf07ea35355b01a35cb442dd950d8f1bb5b040a7787791a535de13db15ed90"
],
"index": "pypi",
"version": "==10.0.0"
},
"yarl": {
"hashes": [
"sha256:04ab9d4b9f587c06d801c2abfe9317b77cdf996c65a90d5e84ecc45010823571",
"sha256:066c163aec9d3d073dc9ffe5dd3ad05069bcb03fcaab8d221290ba99f9f69ee3",
"sha256:13414591ff516e04fcdee8dc051c13fd3db13b673c7a4cb1350e6b2ad9639ad3",
"sha256:149ddea5abf329752ea5051b61bd6c1d979e13fbf122d3a1f9f0c8be6cb6f63c",
"sha256:159d81f22d7a43e6eabc36d7194cb53f2f15f498dbbfa8edc8a3239350f59fe7",
"sha256:1b1bba902cba32cdec51fca038fd53f8beee88b77efc373968d1ed021024cc04",
"sha256:22a94666751778629f1ec4280b08eb11815783c63f52092a5953faf73be24191",
"sha256:2a96c19c52ff442a808c105901d0bdfd2e28575b3d5f82e2f5fd67e20dc5f4ea",
"sha256:2b0738fb871812722a0ac2154be1f049c6223b9f6f22eec352996b69775b36d4",
"sha256:2c315df3293cd521033533d242d15eab26583360b58f7ee5d9565f15fee1bef4",
"sha256:32f1d071b3f362c80f1a7d322bfd7b2d11e33d2adf395cc1dd4df36c9c243095",
"sha256:3458a24e4ea3fd8930e934c129b676c27452e4ebda80fbe47b56d8c6c7a63a9e",
"sha256:38a3928ae37558bc1b559f67410df446d1fbfa87318b124bf5032c31e3447b74",
"sha256:3da8a678ca8b96c8606bbb8bfacd99a12ad5dd288bc6f7979baddd62f71c63ef",
"sha256:494053246b119b041960ddcd20fd76224149cfea8ed8777b687358727911dd33",
"sha256:50f33040f3836e912ed16d212f6cc1efb3231a8a60526a407aeb66c1c1956dde",
"sha256:52a25809fcbecfc63ac9ba0c0fb586f90837f5425edfd1ec9f3372b119585e45",
"sha256:53338749febd28935d55b41bf0bcc79d634881195a39f6b2f767870b72514caf",
"sha256:5415d5a4b080dc9612b1b63cba008db84e908b95848369aa1da3686ae27b6d2b",
"sha256:5610f80cf43b6202e2c33ba3ec2ee0a2884f8f423c8f4f62906731d876ef4fac",
"sha256:566185e8ebc0898b11f8026447eacd02e46226716229cea8db37496c8cdd26e0",
"sha256:56ff08ab5df8429901ebdc5d15941b59f6253393cb5da07b4170beefcf1b2528",
"sha256:59723a029760079b7d991a401386390c4be5bfec1e7dd83e25a6a0881859e716",
"sha256:5fcd436ea16fee7d4207c045b1e340020e58a2597301cfbcfdbe5abd2356c2fb",
"sha256:61016e7d582bc46a5378ffdd02cd0314fb8ba52f40f9cf4d9a5e7dbef88dee18",
"sha256:63c48f6cef34e6319a74c727376e95626f84ea091f92c0250a98e53e62c77c72",
"sha256:646d663eb2232d7909e6601f1a9107e66f9791f290a1b3dc7057818fe44fc2b6",
"sha256:662e6016409828ee910f5d9602a2729a8a57d74b163c89a837de3fea050c7582",
"sha256:674ca19cbee4a82c9f54e0d1eee28116e63bc6fd1e96c43031d11cbab8b2afd5",
"sha256:6a5883464143ab3ae9ba68daae8e7c5c95b969462bbe42e2464d60e7e2698368",
"sha256:6e7221580dc1db478464cfeef9b03b95c5852cc22894e418562997df0d074ccc",
"sha256:75df5ef94c3fdc393c6b19d80e6ef1ecc9ae2f4263c09cacb178d871c02a5ba9",
"sha256:783185c75c12a017cc345015ea359cc801c3b29a2966c2655cd12b233bf5a2be",
"sha256:822b30a0f22e588b32d3120f6d41e4ed021806418b4c9f0bc3048b8c8cb3f92a",
"sha256:8288d7cd28f8119b07dd49b7230d6b4562f9b61ee9a4ab02221060d21136be80",
"sha256:82aa6264b36c50acfb2424ad5ca537a2060ab6de158a5bd2a72a032cc75b9eb8",
"sha256:832b7e711027c114d79dffb92576acd1bd2decc467dec60e1cac96912602d0e6",
"sha256:838162460b3a08987546e881a2bfa573960bb559dfa739e7800ceeec92e64417",
"sha256:83fcc480d7549ccebe9415d96d9263e2d4226798c37ebd18c930fce43dfb9574",
"sha256:84e0b1599334b1e1478db01b756e55937d4614f8654311eb26012091be109d59",
"sha256:891c0e3ec5ec881541f6c5113d8df0315ce5440e244a716b95f2525b7b9f3608",
"sha256:8c2ad583743d16ddbdf6bb14b5cd76bf43b0d0006e918809d5d4ddf7bde8dd82",
"sha256:8c56986609b057b4839968ba901944af91b8e92f1725d1a2d77cbac6972b9ed1",
"sha256:8ea48e0a2f931064469bdabca50c2f578b565fc446f302a79ba6cc0ee7f384d3",
"sha256:8ec53a0ea2a80c5cd1ab397925f94bff59222aa3cf9c6da938ce05c9ec20428d",
"sha256:95d2ecefbcf4e744ea952d073c6922e72ee650ffc79028eb1e320e732898d7e8",
"sha256:9b3152f2f5677b997ae6c804b73da05a39daa6a9e85a512e0e6823d81cdad7cc",
"sha256:9bf345c3a4f5ba7f766430f97f9cc1320786f19584acc7086491f45524a551ac",
"sha256:a60347f234c2212a9f0361955007fcf4033a75bf600a33c88a0a8e91af77c0e8",
"sha256:a74dcbfe780e62f4b5a062714576f16c2f3493a0394e555ab141bf0d746bb955",
"sha256:a83503934c6273806aed765035716216cc9ab4e0364f7f066227e1aaea90b8d0",
"sha256:ac9bb4c5ce3975aeac288cfcb5061ce60e0d14d92209e780c93954076c7c4367",
"sha256:aff634b15beff8902d1f918012fc2a42e0dbae6f469fce134c8a0dc51ca423bb",
"sha256:b03917871bf859a81ccb180c9a2e6c1e04d2f6a51d953e6a5cdd70c93d4e5a2a",
"sha256:b124e2a6d223b65ba8768d5706d103280914d61f5cae3afbc50fc3dfcc016623",
"sha256:b25322201585c69abc7b0e89e72790469f7dad90d26754717f3310bfe30331c2",
"sha256:b7232f8dfbd225d57340e441d8caf8652a6acd06b389ea2d3222b8bc89cbfca6",
"sha256:b8cc1863402472f16c600e3e93d542b7e7542a540f95c30afd472e8e549fc3f7",
"sha256:b9a4e67ad7b646cd6f0938c7ebfd60e481b7410f574c560e455e938d2da8e0f4",
"sha256:be6b3fdec5c62f2a67cb3f8c6dbf56bbf3f61c0f046f84645cd1ca73532ea051",
"sha256:bf74d08542c3a9ea97bb8f343d4fcbd4d8f91bba5ec9d5d7f792dbe727f88938",
"sha256:c027a6e96ef77d401d8d5a5c8d6bc478e8042f1e448272e8d9752cb0aff8b5c8",
"sha256:c0c77533b5ed4bcc38e943178ccae29b9bcf48ffd1063f5821192f23a1bd27b9",
"sha256:c1012fa63eb6c032f3ce5d2171c267992ae0c00b9e164efe4d73db818465fac3",
"sha256:c3a53ba34a636a256d767c086ceb111358876e1fb6b50dfc4d3f4951d40133d5",
"sha256:d4e2c6d555e77b37288eaf45b8f60f0737c9efa3452c6c44626a5455aeb250b9",
"sha256:de119f56f3c5f0e2fb4dee508531a32b069a5f2c6e827b272d1e0ff5ac040333",
"sha256:e65610c5792870d45d7b68c677681376fcf9cc1c289f23e8e8b39c1485384185",
"sha256:e9fdc7ac0d42bc3ea78818557fab03af6181e076a2944f43c38684b4b6bed8e3",
"sha256:ee4afac41415d52d53a9833ebae7e32b344be72835bbb589018c9e938045a560",
"sha256:f364d3480bffd3aa566e886587eaca7c8c04d74f6e8933f3f2c996b7f09bee1b",
"sha256:f3b078dbe227f79be488ffcfc7a9edb3409d018e0952cf13f15fd6512847f3f7",
"sha256:f4e2d08f07a3d7d3e12549052eb5ad3eab1c349c53ac51c209a0e5991bbada78",
"sha256:f7a3d8146575e08c29ed1cd287068e6d02f1c7bdff8970db96683b9591b86ee7"
],
"markers": "python_version >= '3.7'",
"version": "==1.9.2"
}
},
"develop": {}
}

104
README.md Normal file
View file

@ -0,0 +1,104 @@
# rsnaps
Capture a raw snap stream (scans or pictures) through a locally running webapp.
*rsnaps* is a simple webapp for local deployment on a machine with access to
one or more scanner devices, or cameras with Canon CHDK.
Users can start scan jobs and the scanned images will be put into
a local directory with a serial number in the name.
Users can see the list of previously scanned images and redo previous scans.
The scanned images are stored in PNG format and few metadata are added to them:
* the name of the operator doing the scan
* the ID of the unit/batch of material that is being scanned
* the date and time of the scan
* the device used for scanning
Limitations:
* no concurrency: only one instance of rsnaps can run on a machine
* A4 (181x256)
## Prerequisites
Debian Linux with these packages installed
* python3-sane
* python3-aiohttp
* python3-aiohttp-jinja2
```
apt install sane python3-sane python3-aiohttp python3-aiohttp-jinja2
```
### PDF creation
Install jbig2 from https://github.com/agl/jbig2enc
(cf. https://ocrmypdf.readthedocs.io/en/latest/jbig2.html).
You'll need these dependencies: TODO
Note: For jbig2 compression architecture amd64 is required.
### OCR
`apt install pngquant ocrmypdf`
and required tesseract language packages, e.g. `tesseract-ocr-eng`, `tesseract-ocr-deu`, ...
### Page rotationa
`apt install imagemagick`
## Setup
### User and software
```
adduser --disabled-login --home /srv/rsnaps --ingroup scanner
su - rsnaps
git clone _______TODO_________ repo
... TODO
```
### systemd integration
Put a systemd service unit in `/etc/systemd/system/rsnaps.service`:
```
[Unit]
Description=rsnaps local sanning service
[Service]
Type=
ExecStart=
[Install]
WantedBy=multi-user.target
```
```
systemctl daemon-reload
systemctl enable rsnaps.service
systemctl start rsnaps.service
```
## Development
Useful resources:
* [python-sane docs](https://python-sane.readthedocs.io/en/latest/)
* [example.py](https://github.com/python-pillow/Sane/blob/main/example.py)
* [python-sane source](https://github.com/python-pillow/Sane/)
* [SANE standard](https://sane-project.gitlab.io/standard/)
## TODO
* handle timeout of subprocess calls
* lazy image loading:
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
https://css-tricks.com/the-complete-guide-to-lazy-loading-images/
https://web.dev/lazy-loading-images/
https://developer.mozilla.org/en-US/docs/Web/Performance/Lazy_loading

823
server.py Executable file
View file

@ -0,0 +1,823 @@
#!/usr/bin/env python3
"""
Simple server for gathering images from locally connected devices.
Supported devices are scanners available through SANE on linux.
This service will no work for multiple users.
TODO: allow for shutdown and call device.close() then
"""
import json
import logging
import subprocess
import sys
from copy import deepcopy
from pathlib import Path
import aiohttp_jinja2
import jinja2
import sane
from aiohttp import web
from PIL import Image
ocr_languages = ['deu', 'eng']
logging.basicConfig()
logging.getLogger().setLevel(logging.DEBUG)
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
config_dir = Path.home() / '.config' / 'rsnaps'
cache_dir = Path.home() / '.cache' / 'rsnaps'
cache_dir_small = cache_dir / 'small'
cache_dir_pdf = cache_dir / 'pdf'
archive_dir = None
thumbnail_width = 181
thumbnail_height = 256
path_jbig2 = Path.home() / 'Desktop/tools/scan/jbig2'
path_pdf_py = Path.home() / 'Desktop/tools/scan/pdf.py'
routes = web.RouteTableDef()
app_basedir = Path(__file__).parent
snap_device = None
settings_cache = None
def init(archive_dir_):
"""
Setup config and cache directories.
"""
global archive_dir
try:
archive_dir = Path(archive_dir_)
except:
print('Invalid archive basedir.')
sys.exit(2)
if not archive_dir.exists():
print('Archive basedir does not exist.')
sys.exit(2)
# TODO: check if archive_dir is writable
config_dir.mkdir(mode=0o700, exist_ok=True)
cache_dir.mkdir(mode=0o700, exist_ok=True)
cache_dir_small.mkdir(mode=0o700, exist_ok=True)
cache_dir_pdf.mkdir(mode=0o700, exist_ok=True)
def update_settings(settings: dict):
"""
Update existing settings, store and return them.
Fully replace keys that are in *settings*.
"""
settings_path = config_dir / 'settings.json'
try:
with open(settings_path, 'r') as file:
settings_ = json.loads(file.read())
except:
settings_ = {}
settings_.update(settings)
with open(settings_path, 'w') as file:
file.write(json.dumps(settings_, indent=4))
global settings_cache
settings_cache = deepcopy(settings_)
return settings_
def get_settings():
"""
Return stored settings.
"""
global settings_cache
if settings_cache:
return deepcopy(settings_cache)
settings_path = config_dir / 'settings.json'
try:
with open(settings_path, 'r') as file:
setting_cache = json.loads(file.read())
return deepcopy(settings_cache or {})
except Exception as err:
logger.exception(err)
return {}
async def store_settings(request):
"""
Extract settings from POST data and store them.
Also change global `snap_device` to the selected one.
"""
try:
data = await request.post()
settings_ = get_settings()
settings = {}
# device
device_id = data.get('device_id')
device_data = None
devices = [list(x) for x in settings_.get('devices')] or []
for device_data_ in devices:
if device_id == device_data_[0]:
device_data = device_data_.copy()
global snap_device
if device_id != settings_.get('device_id') or snap_device is None:
settings['device_id'] = device_id
snap_device = sane.open(device_id)
# device_settings
device_settings = {}
if paper_size := data.get('paper_size'): # for setting the scan area
device_settings['paper_size'] = paper_size
if mode := data.get('mode'):
device_settings['mode'] = mode
if resolution := data.get('resolution'):
device_settings['resolution'] = int(resolution)
device_settings['snap'] = data.get('snap')
# collection
collection_choice = Path(settings_.get('collection_choice', '.'))
if collection_new := str(data.get('collection_new')):
p = archive_dir / collection_choice / collection_new
p.mkdir(mode=0o700, parents=True, exist_ok=True)
settings['collection_choice'] = str(collection_choice / collection_new)
if collection_description := data.get('collection_description'):
settings['collection_description'] = collection_description
# (duplex) page number
if dpage_number := data.get('dpage_number'):
try:
dpage_number = int(dpage_number)
except:
dpage_number = None
if dpage_number in (None, ''):
max_ = 0
for name in archive_dir.glob('*'):
try:
max_ = max(max_, int(name[:4]))
except:
continue
dpage_number = max_ + 1
settings['dpage_number'] = dpage_number
# target
settings['target'] = data.get('target', 'a')
# save settings, if changed
settings_old = deepcopy(settings_)
settings_.update(settings)
if 'device_settings' not in settings_:
settings_['device_settings'] = {}
if device_id and device_id not in settings_['device_settings']:
settings_['device_settings'][device_id] = {}
settings_['device_settings'][device_id].update(device_settings)
if settings_old != settings_:
settings_path = config_dir / 'settings.json'
with open(settings_path, 'w') as file:
file.write(json.dumps(settings_, indent=4))
global settings_cache
settings_cache = deepcopy(settings_)
return settings_
except Exception as err:
logger.exception(err)
return {}
def get_params(settings=None):
"""
Return params required for main template (snaps.html).
Includes settings and image names.
"""
if settings is None:
settings = get_settings()
images = []
image_path = archive_dir / settings.get('collection_choice', '.')
for path in image_path.glob('*'):
if path.is_file():
name = path.with_suffix('').name
images.append(name)
device_id = settings.get('device_id')
device_settings = settings.get('device_settings', {}).get(device_id, {})
collection_choices = [str(d.relative_to(archive_dir))
for d in archive_dir.glob('**') if d.is_dir()]
collection_choices.sort(key=lambda x: x.lower())
return {
'devices': settings.get('devices', []),
'device_id': device_id,
'paper_sizes': ['DIN A4 (left)', 'DIN A5 (left)', 'DIN A5 (centered)', 'Letter', '115x158mm', '145x420mm', '157x240mm', '170x240mm', '210x440mm'],
'paper_size': device_settings.get('paper_size', 'DIN A4 (left)'),
'modes': device_settings.get('modes', []),
'mode': device_settings.get('mode', 'Gray'),
'resolutions': device_settings.get('resolutions', []),
'resolution': device_settings.get('resolution', 300),
'sources': device_settings.get('sources', []),
'collection_choices': collection_choices,
'collection_choice': settings.get('collection_choice', '.'),
'collection_description': settings.get('collection_description', ''),
'dpage_number': settings.get('dpage_number', 0) + 1,
'target': settings.get('target', 'a'),
'images': sorted(images),
}
@aiohttp_jinja2.template('rsnaps.html')
async def detect(request):
"""
Detect available devices and store them in settings.
"""
sane.exit()
sane.init()
devices = sane.get_devices()
sane.exit()
update_settings({'devices': devices})
raise web.HTTPFound('/')
@aiohttp_jinja2.template('rsnaps.html')
async def collection(request):
data = await request.post()
collection_choice = data.get('collection_choice', '.')
update_settings({'collection_choice': collection_choice})
raise web.HTTPFound('/')
@aiohttp_jinja2.template('rsnaps.html')
async def collection_delete(request):
data = await request.post()
collection_choice = data.get('collection_choice', '.')
if collection_choice and collection_choice != '.':
image_dir = archive_dir / collection_choice
remove_dir(image_dir)
thumbnail_dir = cache_dir_small / collection_choice
remove_dir(thumbnail_dir)
else:
print(76576567373, collection_choice) # TODO
update_settings({'collection_choice': '.'})
raise web.HTTPFound('/')
def remove_dir(dir_path):
if not dir_path.is_dir():
return
has_subdirs = False
for p in dir_path.iterdir():
if p.is_file():
p.unlink()
else:
has_subdirs = True
if not has_subdirs:
dir_path.rmdir()
@aiohttp_jinja2.template('rsnaps.html')
async def device(request):
data = await request.post()
device_id = data.get('device_id')
global snap_device
if device_id:
try:
snap_device = sane.open(device_id)
except:
try:
sane.exit()
if ':libusb:' in device_id:
bus_devnum = device_id[-7:]
# get usb_ids
cmd = ['lsusb', '-s', bus_devnum]
process = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
out, _ = process.communicate()
usb_ids = out.decode('utf-8').split(' ')[5]
# do usbreset
cmd = ['usbreset', usb_ids]
subprocess.run(cmd)
sane.init()
try:
snap_device = sane.open(device_id)
except Exception as err:
print(f'Error opening device {device_id}: {err}')
snap_device = None
except:
print(f'Error on sane exit and init')
snap_device = None
else:
snap_device = None
if snap_device:
update_settings({'device_id': device_id})
else:
update_settings({'device_id': None})
# get and store device constraints
if snap_device:
modes = snap_device['mode'].constraint
resolutions = snap_device['resolution'].constraint
sources = snap_device['source'].constraint
device_settings = {}
device_settings['modes'] = modes
device_settings['resolutions'] = resolutions
device_settings['sources'] = sources
settings = get_settings()
if 'device_settings' not in settings:
settings['device_settings'] = {}
if device_id not in settings['device_settings']:
settings['device_settings'][device_id] = {}
settings['device_settings'][device_id].update(device_settings)
update_settings({'device_settings': settings['device_settings']})
params = snap_device.get_parameters()
logger.info(f'Scanner device parameters: {params}')
# logger.info(f'Scanner device options: {snap_device.optlist}')
# logger.info(dir(snap_device))
# for o in snap_device.optlist:
# try:
# print(snap_device[o], dir(snap_device[o]))
# print(o, snap_device[o].constraint)
# except:
# pass
raise web.HTTPFound('/')
@aiohttp_jinja2.template('rsnaps.html')
async def rsnaps(request):
"""
Just display main view.
"""
return get_params()
@aiohttp_jinja2.template('rsnaps.html')
async def snap(request):
"""
Perform a snap.
"""
settings = await store_settings(request)
try:
snap_page(settings)
except:
logger.exception(f'FAIL: snap_page({settings})')
return get_params(settings)
async def image_delete(request):
"""
Delete the named image.
"""
name = request.match_info['name']
settings = get_settings()
image_dir = archive_dir / settings.get('collection_choice', '.')
image_file = image_dir / f'{name}.png'
if image_file.is_file():
image_file.unlink()
image_dir_small = cache_dir_small / settings.get('collection_choice', '.')
image_file_small = image_dir_small / f'{name}.png'
if image_file_small.is_file():
image_file_small.unlink()
raise web.HTTPFound('/')
async def image(request):
"""
Display the named image.
"""
name = request.match_info['name']
settings = get_settings()
image_dir = archive_dir / settings.get('collection_choice', '.')
try:
with open(image_dir / f'{name}.png', 'rb') as file:
img_content = file.read()
return web.Response(body=img_content, content_type='image/png')
except Exception as err:
logger.exception('Image not found')
raise web.HTTPNotFound(text='The image does not exist.')
async def image_small(request):
"""
Display the named thumbnail / small image.
"""
name = request.match_info['name']
settings = get_settings()
image_dir_small = cache_dir_small / settings.get('collection_choice', '.')
image_file_small = image_dir_small / f'{name}.png'
try:
with open(image_file_small, 'rb') as file:
img_content = file.read()
return web.Response(body=img_content, content_type='image/png')
except Exception:
image_dir = archive_dir / settings.get('collection_choice', '.')
image_file = image_dir / f'{name}.png'
if not image_file.is_file():
logger.exception('Image not found')
raise web.HTTPNotFound(text='The image does not exist.')
if not image_dir_small.is_dir():
image_dir_small.mkdir(mode=0o700, parents=True)
with Image.open(image_file) as img:
img_small = img.resize((thumbnail_width, thumbnail_height), Image.BICUBIC)
img_small.save(image_dir_small / f'{name}.png')
async def pages_operation(request):
"""
Perform the requested operation on pages, e.g. PDF generation.
"""
data = await request.post()
if not (pages := parse_pages(data.get('pages'))):
raise web.HTTPFound('/')
operation = data.get('operation')
if operation == 'delete':
await delete_pages(pages)
raise web.HTTPFound('/')
elif operation == 'rotate180':
await rotate_pages(pages, angle=180)
raise web.HTTPFound('/')
else:
ocr = operation == 'pdf_ocr'
lossy = data.get('lossy') == 'lossy'
return await create_pdf(pages, ocr=ocr, lossy=lossy)
def parse_pages(pages_):
"""
Input cleaning: Filter `pages_` for existing ones.
Return a list of pages, retaining the requested sort order.
"""
pages = []
if not pages_:
return pages
for pr in (prs := str(pages_).split(',')):
if '-' in pr:
start_, end_ = pr.split('-', 1)
start_ = start_.strip()
end_ = end_.strip()
if start_.endswith('a'):
start_t = 'a'
start_ = start_[:-1]
elif start_.endswith('b'):
start_t = 'b'
start_ = start_[:-1]
else:
start_t = ''
start = int(start_)
if end_.endswith('a'):
end_t = 'a'
end_ = end_[:-1]
elif end_.endswith('b'):
end_t = 'b'
end_ = end_[:-1]
else:
end_t = ''
end = int(end_)
else:
pr = pr.strip()
if pr.endswith('a'):
pr_t = 'a'
pr = pr[:-1]
elif pr.endswith('b'):
pr_t = 'b'
pr = pr[:-1]
else:
pr_t = ''
start = end = int(pr)
start_t = end_t = pr_t
for ind in range(start, end + 1):
if ind == start:
if not start_t or start_t == 'a':
pages.append(f'{ind:04d}a')
if not (ind == end and end_t == 'a'):
pages.append(f'{ind:04d}b')
elif ind == end:
if not (ind == start and start_t == 'b'):
pages.append(f'{ind:04d}a')
if not end_t or end_t == 'b':
pages.append(f'{ind:04d}b')
else:
pages.append(f'{ind:04d}a')
pages.append(f'{ind:04d}b')
# filter by existing pages
pages_set = set(pages)
image_dir = archive_dir / get_settings().get('collection_choice', '.')
existing = set([p.stem for p in image_dir.iterdir()])
common = pages_set & existing
return [page for page in pages if page in common]
async def delete_pages(pages):
"""
Delete the given `pages`, i.e. images and small images.
"""
image_dir = archive_dir / get_settings().get('collection_choice', '.')
image_dir_small = cache_dir_small / get_settings().get('collection_choice', '.')
for page in pages:
p = image_dir / f'{page}.png'
p.unlink(missing_ok=True)
p = image_dir_small / f'{page}.png'
p.unlink(missing_ok=True)
async def rotate_pages(pages, angle=0):
"""
Rotate the `pages` by `angle`, i.e. images and small images.
"""
image_dir = archive_dir / get_settings().get('collection_choice', '.')
image_dir_small = cache_dir_small / get_settings().get('collection_choice', '.')
for page in pages:
p = image_dir / f'{page}.png'
cmd = [
'mogrify',
'-rotate',
str(angle),
str(p),
]
subprocess.run(cmd, cwd=cache_dir_pdf)
p = image_dir_small / f'{page}.png'
cmd = [
'mogrify',
'-rotate',
str(angle),
str(p),
]
subprocess.run(cmd, cwd=cache_dir_pdf)
async def create_pdf(pages, lossy: bool = False, ocr: bool = False):
"""
Create and return a PDF from the given pages.
If `lossy` is True, use jbig2 for compression.
If `ocr` is True, perform OCR using `ocrmypdf`.
"""
for p in cache_dir_pdf.iterdir():
p.unlink()
img_paths = [str(archive_dir / f'{page}.png') for page in pages]
if lossy:
cmd = [
str(path_jbig2),
'-s',
'-p',
'-a',
'-v',
'-4',
] + img_paths
logger.debug(' '.join([str(x) for x in cmd]))
subprocess.run(cmd, cwd=cache_dir_pdf)
cmd = [
'/usr/bin/python3',
str(path_pdf_py),
'output',
]
logger.debug(' '.join(cmd))
subprocess.run(cmd, cwd=cache_dir_pdf, capture_output=True)
#with open(cache_dir_pdf / 'x.pdf', 'wb') as file:
# file.write(result.stdout)
else:
# create a PDF file using img2pdf
cmd = [
'img2pdf',
'--pagesize',
'A4',
'-o',
str(cache_dir_pdf / 'o1.pdf'),
] + img_paths
logger.debug(' '.join([str(x) for x in cmd]))
subprocess.run(cmd, cwd=cache_dir_pdf)
# optimize images and linearize the pdf file using qpdf
cmd = [
'qpdf',
'--optimize-images',
'--linearize',
'--compress-streams=y',
'--object-streams=generate',
'--recompress-flate',
str(cache_dir_pdf / 'o1.pdf'),
str(cache_dir_pdf / 'o.pdf'),
]
logger.debug(' '.join([str(x) for x in cmd]))
subprocess.run(cmd, cwd=cache_dir_pdf)
if ocr:
cmd = [
'ocrmypdf',
'-d',
'-O',
'3',
'-l',
'+'.join(ocr_languages),
'--output-type',
'pdf',
cache_dir_pdf / 'o.pdf',
cache_dir_pdf / 'ocr.pdf',
]
logger.debug(' '.join([str(x) for x in cmd]))
subprocess.run(cmd, cwd=cache_dir_pdf)
result_file = cache_dir_pdf / 'ocr.pdf'
else:
result_file = cache_dir_pdf / 'o.pdf'
# return result
with open(result_file, 'rb') as file:
result_content = file.read()
return web.Response(body=result_content, content_type='application/pdf')
app = web.Application()
app.add_routes([
web.get('/', rsnaps),
web.post('/detect', detect),
web.post('/collection', collection),
web.post('/collection/delete', collection_delete),
web.post('/device', device),
web.post('/snap', snap),
web.post('/image-delete/{name}', image_delete),
web.get('/image/{name}', image),
web.get('/image-small/{name}', image_small),
web.post('/pages-operation', pages_operation),
])
aiohttp_jinja2.setup(
app,
loader=jinja2.FileSystemLoader(app_basedir / 'templates'),
)
app.router.add_static('/static', app_basedir / 'static')
# snap data
def snap_page(settings):
"""
Set device options.
"""
device_id = settings.get('device_id')
device_settings = settings.get('device_settings', {}).get(device_id, {})
# set source before mode and resolution!
global snap_device
snap_device.source = device_settings.get('snap')
snap_device.mode = device_settings.get('mode')
snap_device.resolution = device_settings.get('resolution')
if device_settings.get('snap') in ('Automatic Document Feeder', 'ADF Front'):
scan_adf(snap_device, settings)
if device_settings.get('snap') == 'ADF Duplex':
scan_adf_duplex(snap_device, settings)
else:
scan_page(snap_device, settings)
def scan_page(snap_device, settings):
"""
Scan a single page from source 'Flatbed'.
"""
set_scan_area(settings)
snap_device.start()
img = snap_device.snap()
store_image(img, settings)
def scan_adf(snap_device, settings):
"""
Scan pages from source 'Automatic Document Feeder'.
"""
set_scan_area(settings)
direction = -1 if settings.get('target') == 'b' else 1
img_i = 0
dpage_number = settings.get('dpage_number', 1)
if direction == -1:
dpage_number -= 1
while True:
try:
snap_device.start()
img = snap_device.snap(True)
if not isinstance(img, Image.Image):
break
settings['dpage_number'] = dpage_number + direction * img_i
store_image(img, settings)
img_i += 1
except Exception as e:
if str(e) == 'Document feeder out of documents':
return
else:
print(e)
# snap_device.close()
# device_id = settings['device_id']
# snap_device = sane.open(device_id)
break
# TODO: maybe `adf_mode` can be set to `simplex`? see `--adf-mode` in http://sane-project.org/man/sane-epsonds.5.html
# for img in snap_device.multi_scan():
# if not isinstance(img, Image.Image):
# break
# settings['dpage_number'] = dpage_number + direction * img_i
# store_image(img, settings)
# img_i += 1
def scan_adf_duplex(snap_device, settings):
"""
Scan pages from source 'ADF Duplex' (a DADF scanner).
"""
set_scan_area(settings)
img_i = 0
dpage_number = settings.get('dpage_number', 1)
side = 'b'
for img in snap_device.multi_scan():
if not isinstance(img, Image.Image):
continue
side = 'a' if side == 'b' else 'b'
settings['target'] = side
settings['dpage_number'] = dpage_number + img_i // 2
store_image(img, settings)
img_i += 1
def set_scan_area(settings):
"""
Set coordinates of the scan area, using device params and paper size.
"""
device_id = settings.get('device_id')
device_settings = settings.get('device_settings', {}).get(device_id, {})
paper_size = device_settings.get('paper_size', '')
if paper_size.startswith('DIN A5'):
paper_width_mm = 148
paper_height_mm = 210
elif paper_size.startswith('Letter'):
paper_width_mm = 216
paper_height_mm = 279
elif paper_size.startswith('115x158mm'):
paper_width_mm = 115
paper_height_mm = 158
elif paper_size.startswith('145x420mm'):
paper_width_mm = 145
paper_height_mm = 420
elif paper_size.startswith('157x240mm'):
paper_width_mm = 157
paper_height_mm = 240
elif paper_size.startswith('170x240mm'):
paper_width_mm = 170
paper_height_mm = 240
elif paper_size.startswith('210x440mm'):
paper_width_mm = 210
paper_height_mm = 440
else:
paper_width_mm = 210
paper_height_mm = 297
scan_width_mm = snap_device['tl_x'].constraint[1]
if scan_width_mm > paper_width_mm:
if 'centered' in paper_size:
offset_hl = offset_hr = (scan_width_mm - paper_width_mm) / 2
else:
offset_hl = 0
offset_hr = scan_width_mm - paper_width_mm
snap_device.tl_x = offset_hl
snap_device.br_x = scan_width_mm - offset_hr
snap_device.tl_y = 0
snap_device.br_y = paper_height_mm
#print(paper_size, snap_device.tl_x,snap_device.tl_x,snap_device.br_x,snap_device.br_y)
def store_image(img, settings):
dpage_number = settings.get('dpage_number', 1)
target = settings.get('target', 'a')
img_name = f'{dpage_number:04d}{target}.png'
img.save(archive_dir / settings.get('collection_choice', '.') / img_name)
#img_small = img.resize((thumbnail_width, thumbnail_height), Image.BICUBIC)
# device_id = settings['device_id']
# device_settings = settings['device_settings'][device_id]
# mode = device_settings.get('mode', '?')
# resolution = device_settings.get('resolution', '?')
#collection_name = device_settings.get('collection_name', '').replace(' ', '_')
#img_small_name = f'{dpage_number:04d}{target}_{mode}_{resolution}_{collection_name}.png'
#img_small.save(cache_dir_small / img_small_name)
update_settings({'dpage_number': dpage_number})
if __name__ == '__main__':
if len(sys.argv) < 2:
print('Please give the archive basedir as argument 1.')
sys.exit(2)
init(sys.argv[1])
web.run_app(app, port=8066)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

7
static/bootstrap/js/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

240
static/css/base.css Normal file
View file

@ -0,0 +1,240 @@
/* X-Small devices (portrait phones, less than 576px) */
@media screen and (max-width:575.98px) {
#logo {
min-width: 180px;
}
}
/* Small devices (landscape phones, less than 768px) */
@media screen and (max-width: 767.98px) and (min-width:576px) {
#logo {
min-width: 210px;
}
}
/* Medium devices (tablets, less than 992px) */
@media screen and (max-width: 991.98px) and (min-width:768px) {
#logo {
min-width: 240px;
}
}
/* Large devices (desktops, less than 1200px) */
@media screen and (max-width: 1199.98px) and (min-width:992px) {
#logo {
min-width: 270px;
}
}
/* X-Large devices (large desktops, less than 1400px) */
@media screen and (max-width: 1399.98px) and (min-width:1200px) {
#logo {
min-width: 300px;
}
}
/* XX-Large devices (large desktops, more than 1400px) */
@media screen and (min-width:1400px) {
#logo {
min-width: 320px;
}
}
@media print {
.noprint {
display: none;
}
}
body {
min-width: 100vw;
min-height: 100vh;
margin-left: auto;
margin-right: auto;
overflow: hidden;
}
#footer {
background-color: #ccc;
}
a, a:link, a:visited, a:hover, a:active {
color: #006699;
text-decoration: none;
}
h1,h2,h3,h4,h5,h6 {
color: #666;
}
ul {
list-style: none;
margin: 0;
margin-top: 10px;
padding: 0;
padding-left: 0;
}
ul.btns > li {
padding-bottom: 10px;
}
ul.arrow {
list-style: none;
padding: 0 0 10px 20px;
margin: 0;
}
ul.arrow > li {
padding: 5px 0 0 20px;
}
ul.arrow > li:before {
content: "➜";
position: absolute;
margin-left: -24px;
}
ul.bool {
list-style: none;
padding: 0;
padding-left: 20px;
margin: 0;
}
ul.bool > li {
padding-top: 5px;
padding-left: 20px;
}
ul.bool > li.success:before {
content: "☑";
position: absolute;
margin-left: -24px;
}
ul.bool > li.failure:before {
content: "☐";
position: absolute;
margin-left: -24px;
}
ul.errorlist {
color: red;
}
#form-login {
max-width: 340px;
}
button.menu {
width: 100%;
}
@font-face {
font-family: "fa";
src: url("../fonts/fa-regular-400.woff2") format('woff2');
}
.fatt {
font-family: fa;
font-weight: 400;
}
details > summary {
color: #006699;
}
meter {
background: lightgrey;
width: 200px;
}
meter.meter-narrow {
background: lightgrey;
width: 80px;
}
th.number, td.number {
text-align: right;
}
th.rotate {
height: 140px;
white-space: nowrap;
}
th.rotate > div {
transform:
translate(25px, -5px)
rotate(315deg);
width: 30px;
}
th.rotate > div > span {
border-bottom: 2px dotted #000;
padding: 5px 10px;
}
div.date {
width: 200px;
}
form > table > tbody > tr > th {
text-align: right;
}
select.fgrow2 {
flex-grow: 2 !important;
}
span.input-group-text {
padding-top: 0;
padding-bottom: 0;
height: 100%;
}
input.input-right {
text-align: right;
}
input#id_password, input#id_username {
max-width: 180px;
}
.cursor-cell {
cursor: cell;
}
.btn-outline-info, .btn-outline-info:active, .btn-outline-info:focus, .btn-outline-info:visited {
border-color: #9a1662 !important;
color: #9a1662 !important;
background-color: white !important;
}
.btn-outline-info:hover {
border-color: #9a1662 !important;
color: white !important;
background-color: #9a1662 !important;
}
.btn-outline-info:focus {
box-shadow: rgba(154, 22, 98, 0.6) 0 0 0 3px;
color: white !important;
background-color: #9a1662 !important;
}
.snap-img {
background: #FFF;
width: 181px;
height: 256px;
display: block;
margin: 10px auto;
border: 1px solid black;
}
.snaps {
height: calc(100vh - 90px);
scroll-snap-align: end;
scroll-snap-type: y mandatory;
scrollbar-color: #dd4444 white;
scrollbar-width: auto;
}

7
static/jquery-ui/jquery-ui.min.css vendored Normal file

File diff suppressed because one or more lines are too long

6
static/jquery-ui/jquery-ui.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
static/jquery/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

6
static/popper/popper.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

169
templates/rsnaps.html Normal file
View file

@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{% block title %}rsnaps{% endblock %}</title>
<meta name="generator" content="aiohttp" />
<meta name="title" content="rsnaps" />
<meta name="description" content="Capture tool for scans and photos." />
<meta name="revisit-after" content="3600" />
<meta name="language" content="EN" />
<meta name="robots" content="noindex" />
<meta http-equiv="x-dns-prefetch-control" content="off" />
<link href="/static/bootstrap/css/bootstrap.min.css" rel="stylesheet" type="text/css" />
<link href="/static/css/base.css" rel="stylesheet" type="text/css" />
{% block stylesheets %}{% endblock %}
{% block head %}{% endblock %}
</head>
<body class="d-flex bg-white body">
{% block page %}
<div id="page" class="d-flex flex-row page">
<div class="d-flex flex-column justify-content-start bg-light border-right border-dark">
<a href="/">
<header class="d-flex flex-row bg-dark p-2">
<span class="text-white "><h1 class="text-info fw-bold">rsnaps</h1></span>
</header>
</a>
<div class="d-flex flex-row">
<form id="settings" method="POST" action="/detect">
<div class="p-2 pe-0">
<button class="btn btn-sm btn-secondary" type="submit"><strong>Detect&nbsp;devices</strong></button>
</div>
</form>
<form id="device" method="POST" action="/device">
<div class="p-2">
<select id="device" name="device_id" class="form-select form-select-sm w-auto" aria-label="Choose device" onchange="submit()">
<option value="">Device</option>
{% for device in devices %}
<option value="{{ device[0] }}"{% if device[0] == device_id %} selected{% endif %}>{{ device[2] }}</option>
{% endfor %}
</select>
</div>
</form>
</div>
<form id="collection_choice" method="POST" action="/collection">
<div class="d-flex flex-row p-2 pt-0">
<select id="collection_choice" name="collection_choice" class="form-select form-select-sm" aria-label="Choose collection" onchange="submit()">
{% for coll in collection_choices %}
<option value="{{ coll }}"{% if collection_choice == coll %} selected{% endif %}>{{ coll }}</option>
{% endfor %}
</select>
</div>
</form>
<form id="collection_delete" method="POST" action="/collection/delete">
<div class="d-flex flex-row p-2 pt-0">
<input type="hidden" name="collection_choice" value="{{ collection_choice }}">
<button class="btn btn-sm btn-danger" type="submit"><strong>DELETE</strong></button>
</div>
</form>
{% if device_id %}
<form id="snap" method="POST" action="/snap">
<input type="hidden" name="device_id" value="{{ device_id }}">
<div class="ms-2 me-2 mb-2">
<span><strong>Settings</strong> for next snaps &#x24d8;</span>
<div class="card">
<div class="card-body p-2 pe-2">
<div class="mt-0">
<span data-bs-toggle="tooltip" data-bs-placement="right" title="Name of collection"><strong>Start new collection</strong> &#x24d8;</span><br>
<input class="form-control w-100" id="collection_new" name="collection_new" type="text" value="{{ collection_new }}" placeholder="Path/to/Coll A/part 1">
</div>
<!-- <div class="mt-2">
<span data-bs-toggle="tooltip" data-bs-placement="right" title="Free annotation text (optional, short)"><strong>Collection description</strong> &#x24d8;</span><br>
<input class="form-control w-auto" id="collection_description" name="collection_description" type="text" value="{{ collection_description }}" placeholder="short description of collection">
</div>
-->
<div class="d-flex flex-row p-2 ps-0 pe-0">
<select id="paper_size" name="paper_size" class="form-select form-select-sm w-100" aria-label="Paper size">
{% for p_s in paper_sizes %}
<option value="{{ p_s }}"{% if p_s == paper_size %} selected{% endif %}>{{ p_s }}</option>
{% endfor %}
</select>
</div>
<div class="d-flex flex-row">
<div>
<span data-bs-toggle="tooltip" data-bs-placement="right" title="For colored originals use Color"><strong>Mode</strong> &#x24d8;</span><br>
{% for mode_ in modes %}
<input class="form-check-input" type="radio" name="mode" id="mode_{{ mode_ }}" value="{{ mode_ }}"{% if mode == mode_ %} checked{% endif %}>
<label class="form-check-label" for="mode_{{ mode_ }}">{{ mode_ }}</label><br>
{% endfor %}
</div>
<div class="ps-4">
<span data-bs-toggle="tooltip" data-bs-placement="right" title="Image resolution in dpi"><strong>Resolution</strong> &#x24d8;</span><br>
{% for resolution_ in resolutions %}
<input class="form-check-input" type="radio" name="resolution" id="resolution_{{ resolution_ }}" value="{{ resolution_ }}"{% if resolution == resolution_ %} checked{% endif %}>
<label class="form-check-label" for="resolution_{{ resolution_ }}">{{ resolution_ }}</label><br>
{% endfor %}
</div>
</div>
<div class="mt-2">
<span data-bs-toggle="tooltip" data-bs-placement="right" title="Use this page number for the next snap (e.g. 32 for page 0032x.png)"><strong>Next (duplex) page number</strong> &#x24d8;</span><br>
<input class="form-control w-auto" id="dpage_number" name="dpage_number" type="text" value="{{ dpage_number }}" placeholder="use next" size="4">
</div>
<div class="mt-2">
<span data-bs-toggle="tooltip" data-bs-placement="right" title="File name target"><strong>Target</strong> (if not duplex) &#x24d8;</span><br>
<input class="form-check-input" type="radio" name="target" id="target_a" value="a"{% if target == 'a' %} checked{% endif %}>
<label class="form-check-label" for="target_a"><strong>a</strong> (front pages)<br>increase page number by 1</label><br>
<input class="form-check-input" type="radio" name="target" id="target_b" value="b"{% if target == 'b' %} checked{% endif %}>
<label class="form-check-label" for="target_b"><strong>b</strong> (back pages)<br>decrease page number by 1</label>
</div>
<div>Existing images will be overwritten!</div>
</div>
</div>
</div>
</div>
</form>
{% endif %}
</div>
<div class="d-flex flex-grow-1 flex-column">
<div class="d-flex flex-column flex-wrap p-2 bg-white">
<div class="snaps overflow-auto">
<div class="d-flex flex-wrap">
{% for name in images %}
<div class"snap-img">
<a href="/image/{{ name }}"><img class="snap-img m-2 mb-0" src="/image-small/{{ name }}"></a>
<div class="d-flex ps-2 pe-2"><small><strong>{{ name }}</strong>
{% if mode == 'Color' %}<span class="bg-danger">&#x2000;</span>&#x2009;<span class="bg-success">&#x2000;</span>&#x2009;<span class="bg-primary">&#x2000;</span>{% else %}<span class="bg-dark">&#x2000;</span>&#x2009;<span class="bg-secondary">&#x2000;</span>&#x2009;<span class="bg-white">&#x2000;</span>{% endif %}
<strong>{{ resolution }}</strong>dpi</small><div class="ms-auto"><form id="image_delete_{{ name }}" method="POST" action="/image-delete/{{ name }}"><button type="submit" class="form-control btn btn-sm p-0 m-0">&#x274c;</button></form></div></div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="d-flex flex-row justify-content-between bg-light h-100 border-top border-dark">
{% if device_id %}
{% for source_ in sources %}
<button form="snap" class="btn btn-success me-1 rounded-0" type="submit" name="snap" value="{{ source_ }}"><span class="fs-2 fw-bold p-0 m-0">{% if source_ == 'Automatic Document Feeder' %}ADF{% else %}{{ source_ }}{% endif %}</span></button>
{% endfor %}
<div id="page-ops" class="d-flex flex-row">
<div class="d-flex flex-column justify-content-center">
<div id="page-ops-input" class="d-flex flex-row justify-content-between">
<input form="pages" class="form-control w-auto h-auto" type="text" name="pages" placeholder="pages, e.g. 5a-10a,12,14-16">
<div class="align-self-center ps-2 pe-2">
<input form="pages" class="form-check-input" type="checkbox" id="lossy" name="lossy" value="lossy">
<label class="form-check-label" for="lossy"> Lossy</label>
</div>
</div>
<div id="page-ops-buttons" class="d-flex flex-row justify-content-between">
<button form="pages" class="btn btn-secondary btn-sm" type="submit" name="operation" value="rotate180"><big><strong>Rotate pages 180°</strong></big></button>
<button form="pages" class="btn btn-danger btn-sm ms-1" type="submit" name="operation" value="delete"><big><strong>Delete pages</strong></big></button>
<button form="pages" class="btn btn-secondary btn-sm ms-1" type="submit" name="operation" value="pdf"><big><strong>Create PDF</strong></big></button>
<button form="pages" class="btn btn-secondary btn-sm ms-1" type="submit" name="operation" value="pdf_ocr"><big><strong>PDF+OCR</strong></big></button>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
<form id="pages" method="POST" action="/pages-operation"></form>
<script src="/static/jquery/jquery.min.js"></script>
<script src="/static/popper/popper.min.js"></script>
<script src="/static/bootstrap/js/bootstrap.min.js"></script>
<script>
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>