libreqda 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CHANGELOG.md +481 -0
- libreqda-1.2.0.dist-info/LICENSE.txt +28 -0
- libreqda-1.2.0.dist-info/METADATA +131 -0
- libreqda-1.2.0.dist-info/RECORD +255 -0
- libreqda-1.2.0.dist-info/WHEEL +4 -0
- libreqda-1.2.0.dist-info/entry_points.txt +4 -0
- scripts/babelrc +8 -0
- scripts/dist/LICENSE.txt +33 -0
- scripts/dist/RELEASE.txt +23 -0
- scripts/dist/macos/libreqda.icns +0 -0
- scripts/dist/macos/macos.spec +43 -0
- scripts/dist/macos/plist.patch +10 -0
- scripts/dist/macos/taguette.icns +0 -0
- scripts/dist/macos/taguette_console_wrapper +4 -0
- scripts/dist/macos.sh +32 -0
- scripts/dist/pyinstaller_entrypoint.py +19 -0
- scripts/dist/windows/windows-installer.iss +51 -0
- scripts/dist/windows/windows.spec +39 -0
- scripts/edugain/worker_update_edugain_idps.py +89 -0
- scripts/edugain/worker_update_edugain_idps.sh +9 -0
- scripts/make_password_reset_link.py +56 -0
- scripts/make_theme.py +99 -0
- scripts/new_db_revision.sh +8 -0
- scripts/register_user.py +76 -0
- scripts/update_pot.sh +33 -0
- scripts/update_translations.sh +17 -0
- scripts/validate.py +140 -0
- scripts/wipe_and_disable_user.py +90 -0
- taguette/__init__.py +13 -0
- taguette/access_control.py +145 -0
- taguette/convert.py +623 -0
- taguette/database/__init__.py +177 -0
- taguette/database/base.py +9 -0
- taguette/database/copy.py +433 -0
- taguette/database/models.py +920 -0
- taguette/demodata.py +688 -0
- taguette/export.py +500 -0
- taguette/extract.py +287 -0
- taguette/import_codebook.py +110 -0
- taguette/l10n/de_DE/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/de_DE/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/el_EL/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/el_GR/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/el_GR/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/en_US/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/en_US/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/es_ES/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/es_ES/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/fr_FR/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/fr_FR/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/id_ID/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/id_ID/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/it_IT/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/it_IT/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/nl_NL/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/nl_NL/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/pt_PT/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/pt_PT/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/sv_SE/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/sv_SE/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/loki_handler.py +107 -0
- taguette/main.py +649 -0
- taguette/migrations/env.py +72 -0
- taguette/migrations/script.py.mako +24 -0
- taguette/migrations/versions/09c662cd9483_make_documents_filename_not_null.py +39 -0
- taguette/migrations/versions/1b29e68900c1_add_user_roles.py +55 -0
- taguette/migrations/versions/1b96a606551d_update_user_to_have_a_display_name.py +34 -0
- taguette/migrations/versions/2467bab602b7_add_color_column_to_tags_table.py +33 -0
- taguette/migrations/versions/382db36f20f4_add_user_disabled.py +66 -0
- taguette/migrations/versions/43d6c240309d_remove_document_groups.py +81 -0
- taguette/migrations/versions/447d636f72c5_cascades.py +40 -0
- taguette/migrations/versions/491de2dc7cd7_add_indexes.py +51 -0
- taguette/migrations/versions/4c55b3b3ff30_user_login_length.py +58 -0
- taguette/migrations/versions/61a4abcc8769_user_with_parent_for_permissions.py +40 -0
- taguette/migrations/versions/6489b5f9cfb5_increase_password_hash_size.py +34 -0
- taguette/migrations/versions/679f625e6e6a_fix_unique_tags.py +59 -0
- taguette/migrations/versions/77c4543a5b9b_add_parent_id_column_to_collection_table.py +41 -0
- taguette/migrations/versions/7c94b7a31e69_add_is_archived_column_to_projets.py +39 -0
- taguette/migrations/versions/7f56de97e3c8_change_edugain_displayname_length.py +42 -0
- taguette/migrations/versions/807c0cc0ecf6_remove_invalid_hltags.py +43 -0
- taguette/migrations/versions/80b1cc9d4c22_add_user_email.py +35 -0
- taguette/migrations/versions/91ade71ccf4d_add_user_language.py +28 -0
- taguette/migrations/versions/955db2e523d1_add_collections_tables_and_associations_.py +60 -0
- taguette/migrations/versions/a1b2c3d4e5f6_add_user_hide_code_counts.py +28 -0
- taguette/migrations/versions/b23f3b7a638e_add_user_password_change_date.py +36 -0
- taguette/migrations/versions/b7c1e2d3f4a5_add_project_compact_layout.py +28 -0
- taguette/migrations/versions/bc8e0e0677e9_rename_command_fields.py +126 -0
- taguette/migrations/versions/bce44849c2f2_lowercase_logins.py +96 -0
- taguette/migrations/versions/ca076140922f_update_user_for_external_logins.py +32 -0
- taguette/migrations/versions/d86c45d7aa8a_add_document_direction.py +127 -0
- taguette/migrations/versions/db5e31a0233d_remove_blank_highlights.py +88 -0
- taguette/migrations/versions/dd37d9027a24_unique_tags.py +68 -0
- taguette/migrations/versions/de678cef2a94_add_project_id_to_collections_and_.py +41 -0
- taguette/migrations/versions/e459991d960e_add_table_edugainidps.py +39 -0
- taguette/migrations/versions/e4cf92942271_add_index.py +29 -0
- taguette/migrations/versions/e4e090a5b511_remove_commands_document_id_fk.py +38 -0
- taguette/migrations/versions/e9a9f1c77971_add_favorite_column_to_document.py +32 -0
- taguette/migrations/versions/ecb4065de575_don_t_use_server_default_now_timezone_.py +44 -0
- taguette/migrations/versions/fbdc29566f62_edugain_fix_field_type.py +42 -0
- taguette/migrations/versions/fdc6eb54b034_add_parent_id_column_to_tags_table.py +31 -0
- taguette/static/README.md +34 -0
- taguette/static/alt_templates/.gitkeep +0 -0
- taguette/static/assets/test/logos/monsters-university-logo.png +0 -0
- taguette/static/css/custom.css +1696 -0
- taguette/static/css/fa-all.min.css +5 -0
- taguette/static/css/themed.css +3 -0
- taguette/static/css/themed.css.map +25 -0
- taguette/static/css/vendor/bootstrap-treeview.min.css +1 -0
- taguette/static/css/vendor/bootstrap.min.css +6 -0
- taguette/static/css/vendor/font/context-menu-icons.eot +0 -0
- taguette/static/css/vendor/font/context-menu-icons.ttf +0 -0
- taguette/static/css/vendor/font/context-menu-icons.woff +0 -0
- taguette/static/css/vendor/font/context-menu-icons.woff2 +0 -0
- taguette/static/css/vendor/hierarchy-select.min.css +1 -0
- taguette/static/css/vendor/introjs.min.css +2 -0
- taguette/static/css/vendor/jquery.contextMenu.min.css +16 -0
- taguette/static/favicon.ico +0 -0
- taguette/static/js/codebook-viz.js +428 -0
- taguette/static/js/collection/context-menu.js +180 -0
- taguette/static/js/collection/hierarchy-select.js +71 -0
- taguette/static/js/collection/manager.js +217 -0
- taguette/static/js/document/context-menu.js +92 -0
- taguette/static/js/document/custom-select.js +82 -0
- taguette/static/js/document/manager.js +943 -0
- taguette/static/js/document/treeview.js +593 -0
- taguette/static/js/highlight/README.md +18 -0
- taguette/static/js/highlight/crud.js +119 -0
- taguette/static/js/highlight/extracts.js +497 -0
- taguette/static/js/highlight/loader.js +109 -0
- taguette/static/js/highlight/renderer.js +405 -0
- taguette/static/js/login.js +15 -0
- taguette/static/js/permissions.js +153 -0
- taguette/static/js/polling-event.js +145 -0
- taguette/static/js/project/intro.js +524 -0
- taguette/static/js/project/manager.js +0 -0
- taguette/static/js/project/search.js +124 -0
- taguette/static/js/tag/context-menu.js +133 -0
- taguette/static/js/tag/explore-codes.js +400 -0
- taguette/static/js/tag/hierarchy-select.js +69 -0
- taguette/static/js/tag/manager.js +545 -0
- taguette/static/js/tag/treeview.js +613 -0
- taguette/static/js/taguette.js +1790 -0
- taguette/static/js/utilities.js +714 -0
- taguette/static/js/vendor/bootstrap-treeview-custum.js +421 -0
- taguette/static/js/vendor/bootstrap-treeview-custum.min.js +1 -0
- taguette/static/js/vendor/bootstrap-treeview-default.min.js +1 -0
- taguette/static/js/vendor/bootstrap.bundle.min.js +7 -0
- taguette/static/js/vendor/bootstrap.bundle.min.js.map +1 -0
- taguette/static/js/vendor/hierarchy-select.min.js +1 -0
- taguette/static/js/vendor/introjs.min.js +17 -0
- taguette/static/js/vendor/jquery-3.7.1.js +2 -0
- taguette/static/js/vendor/jquery.contextMenu.min.js +2 -0
- taguette/static/libreqda.ico +0 -0
- taguette/static/logo-h.png +0 -0
- taguette/static/logo.png +0 -0
- taguette/static/logo_blanc.png +0 -0
- taguette/static/missing.png +0 -0
- taguette/static/robots.txt +2 -0
- taguette/static/scss/components/_badge.scss +42 -0
- taguette/static/scss/components/_button.scss +169 -0
- taguette/static/scss/components/_checkbox.scss +40 -0
- taguette/static/scss/components/_context_menu.scss +28 -0
- taguette/static/scss/components/_custom_select.scss +150 -0
- taguette/static/scss/components/_dropdown.scss +43 -0
- taguette/static/scss/components/_form.scss +54 -0
- taguette/static/scss/components/_modal.scss +57 -0
- taguette/static/scss/components/_project_card.scss +163 -0
- taguette/static/scss/components/_tag.scss +308 -0
- taguette/static/scss/components/_treeview.scss +76 -0
- taguette/static/scss/custom.scss +809 -0
- taguette/static/scss/layout/_credits.scss +62 -0
- taguette/static/scss/layout/_footer.scss +6 -0
- taguette/static/scss/layout/_navigation.scss +165 -0
- taguette/static/scss/layout/_sidebar.scss +37 -0
- taguette/static/scss/themes/default/_variables.scss +103 -0
- taguette/static/scss/themes/default/theme.css +3 -0
- taguette/static/scss/themes/default/theme.css.map +26 -0
- taguette/static/scss/themes/default/theme.scss +4 -0
- taguette/static/webfonts/BadScript-OFL.txt +94 -0
- taguette/static/webfonts/BadScript.ttf +0 -0
- taguette/static/webfonts/Montserrat-Light.ttf +0 -0
- taguette/static/webfonts/Montserrat-OFL.txt +93 -0
- taguette/static/webfonts/fa-brands-400.eot +0 -0
- taguette/static/webfonts/fa-brands-400.svg +1184 -0
- taguette/static/webfonts/fa-brands-400.ttf +0 -0
- taguette/static/webfonts/fa-brands-400.woff +0 -0
- taguette/static/webfonts/fa-brands-400.woff2 +0 -0
- taguette/static/webfonts/fa-regular-400.eot +0 -0
- taguette/static/webfonts/fa-regular-400.svg +467 -0
- taguette/static/webfonts/fa-regular-400.ttf +0 -0
- taguette/static/webfonts/fa-regular-400.woff +0 -0
- taguette/static/webfonts/fa-regular-400.woff2 +0 -0
- taguette/static/webfonts/fa-solid-900.eot +0 -0
- taguette/static/webfonts/fa-solid-900.svg +2618 -0
- taguette/static/webfonts/fa-solid-900.ttf +0 -0
- taguette/static/webfonts/fa-solid-900.woff +0 -0
- taguette/static/webfonts/fa-solid-900.woff2 +0 -0
- taguette/templates/alt_templates/.gitkeep +0 -0
- taguette/templates/archived_projects.html +50 -0
- taguette/templates/auth/account.html +74 -0
- taguette/templates/auth/cilogon.login.html +89 -0
- taguette/templates/auth/email_reset_password.html +5 -0
- taguette/templates/auth/external_account.html +60 -0
- taguette/templates/auth/login.html +144 -0
- taguette/templates/auth/reset_password.html +27 -0
- taguette/templates/auth/token_needed.html +7 -0
- taguette/templates/base.html +209 -0
- taguette/templates/bug_report.html +158 -0
- taguette/templates/cookies_prompt.html +15 -0
- taguette/templates/downloads.html +127 -0
- taguette/templates/email_bug_report.txt +9 -0
- taguette/templates/email_reset_password.txt +5 -0
- taguette/templates/error.html +8 -0
- taguette/templates/export_codebook.html +20 -0
- taguette/templates/export_document.html +20 -0
- taguette/templates/export_highlights.html +47 -0
- taguette/templates/index.html +347 -0
- taguette/templates/maintenance.html +8 -0
- taguette/templates/messages.js +74 -0
- taguette/templates/modal/change_document.html +68 -0
- taguette/templates/modal/create_collection.html +100 -0
- taguette/templates/modal/create_document.html +84 -0
- taguette/templates/modal/create_highlight.html +38 -0
- taguette/templates/modal/create_tag.html +73 -0
- taguette/templates/modal/explore_codes.html +58 -0
- taguette/templates/modal/manage_collaborators.html +44 -0
- taguette/templates/modal/merge_tag.html +49 -0
- taguette/templates/new_password.html +32 -0
- taguette/templates/partials/alert.html +16 -0
- taguette/templates/partials/codebook_viz_modal.html +34 -0
- taguette/templates/partials/confirm.html +17 -0
- taguette/templates/partials/document_toolbar.html +37 -0
- taguette/templates/partials/hlinfo.html +13 -0
- taguette/templates/partials/project_card.html +44 -0
- taguette/templates/partials/project_card_archived.html +41 -0
- taguette/templates/partials/project_sidebar.html +99 -0
- taguette/templates/partials/spinner.html +11 -0
- taguette/templates/permissions.html +54 -0
- taguette/templates/project.html +288 -0
- taguette/templates/project_delete.html +17 -0
- taguette/templates/project_import.html +122 -0
- taguette/templates/project_import_codebook.html +34 -0
- taguette/templates/project_import_codebook_confirm.html +49 -0
- taguette/templates/project_new.html +31 -0
- taguette/templates/tos.html +8 -0
- taguette/templates/trans.js +7 -0
- taguette/templates/welcome.html +15 -0
- taguette/utils.py +177 -0
- taguette/validate.py +141 -0
- taguette/version_check.py +102 -0
- taguette/web/__init__.py +252 -0
- taguette/web/api.py +1835 -0
- taguette/web/base.py +864 -0
- taguette/web/export.py +338 -0
- taguette/web/views.py +1618 -0
taguette/convert.py
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import bleach
|
|
3
|
+
import bs4
|
|
4
|
+
import chardet
|
|
5
|
+
import codecs
|
|
6
|
+
import io
|
|
7
|
+
import jinja2
|
|
8
|
+
import logging
|
|
9
|
+
import opentelemetry.trace
|
|
10
|
+
import os
|
|
11
|
+
import pkg_resources
|
|
12
|
+
import prometheus_client
|
|
13
|
+
from prometheus_async.aio import time as prom_async_time
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
from subprocess import CalledProcessError
|
|
17
|
+
import subtitle_parser
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
from xml.etree import ElementTree
|
|
21
|
+
|
|
22
|
+
from .utils import log_and_wait_proc, sanitize_filename
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
tracer = opentelemetry.trace.get_tracer(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
BUCKETS = [1.0, 2.0, 3.0, 4.0, 5.0,
|
|
30
|
+
6.0, 8.0, 10.0, 12.0, 15.0, 18.0,
|
|
31
|
+
22.0, 26.0, 30.0, 36.0, 42.0, 48.0, 60.0, 90.0]
|
|
32
|
+
PROM_CALIBRE_TOHTML = prometheus_client.Counter(
|
|
33
|
+
'convert_calibre_tohtml_total',
|
|
34
|
+
"Conversions to HTML using Calibre (calibre_to_html())",
|
|
35
|
+
)
|
|
36
|
+
PROM_CALIBRE_TOHTML_TIME = prometheus_client.Histogram(
|
|
37
|
+
'convert_calibre_tohtml_seconds',
|
|
38
|
+
"Time to convert to HTML using Calibre (calibre_to_html())",
|
|
39
|
+
buckets=BUCKETS,
|
|
40
|
+
)
|
|
41
|
+
PROM_WVWARE_TOHTML = prometheus_client.Counter(
|
|
42
|
+
'convert_wvware_tohtml_total',
|
|
43
|
+
"Conversions to HTML using wvHtml (wvware_to_html())",
|
|
44
|
+
)
|
|
45
|
+
PROM_WVWARE_TOHTML_TIME = prometheus_client.Histogram(
|
|
46
|
+
'convert_wvware_tohtml_seconds',
|
|
47
|
+
"Time to convert to HTML using wvHtml (wvware_to_html())",
|
|
48
|
+
)
|
|
49
|
+
PROM_CALIBRE_FROMHTML = prometheus_client.Counter(
|
|
50
|
+
'convert_calibre_fromhtml_total',
|
|
51
|
+
"Conversions from HTML using Calibre (calibre_from_html())",
|
|
52
|
+
['extension'],
|
|
53
|
+
)
|
|
54
|
+
PROM_CALIBRE_FROMHTML_TIME = prometheus_client.Histogram(
|
|
55
|
+
'convert_calibre_fromhtml_seconds',
|
|
56
|
+
"Time to convert from HTML using Calibre (calibre_from_html())",
|
|
57
|
+
buckets=BUCKETS,
|
|
58
|
+
)
|
|
59
|
+
PROM_CONVERT_PROCESSES = prometheus_client.Gauge(
|
|
60
|
+
'convert_processes',
|
|
61
|
+
"Number of conversion processes currently running",
|
|
62
|
+
)
|
|
63
|
+
PROM_CONVERT_QUEUE = prometheus_client.Gauge(
|
|
64
|
+
'convert_queue',
|
|
65
|
+
"Number of conversions waiting to run",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
HTML_EXTENSIONS = ('.htm', '.html', '.xhtml')
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
template_env = jinja2.Environment(
|
|
73
|
+
loader=jinja2.FileSystemLoader(
|
|
74
|
+
[pkg_resources.resource_filename('taguette', 'templates')],
|
|
75
|
+
),
|
|
76
|
+
autoescape=jinja2.select_autoescape(['html']),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ConversionError(ValueError):
|
|
81
|
+
"""Error converting document.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class UnsupportedFormat(ConversionError):
|
|
86
|
+
"""This format is not supported.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, msg="Unsupported file format",):
|
|
90
|
+
super(UnsupportedFormat, self).__init__(msg)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
PROC_TERM_GRACE = 5 # Wait 5s after SIGTERM before sending SIGKILL
|
|
94
|
+
|
|
95
|
+
PROC_MAX_CONCURRENT = 4 # Maximum concurrent conversion processes
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class MeasuredSemaphore(asyncio.Semaphore):
|
|
99
|
+
def __init__(self, value, metric_acquired, metric_waiting):
|
|
100
|
+
super(MeasuredSemaphore, self).__init__(value)
|
|
101
|
+
self._metric_acquired = metric_acquired
|
|
102
|
+
self._metric_waiting = metric_waiting
|
|
103
|
+
|
|
104
|
+
async def acquire(self):
|
|
105
|
+
self._metric_waiting.inc()
|
|
106
|
+
ret = await super(MeasuredSemaphore, self).acquire()
|
|
107
|
+
self._metric_waiting.dec()
|
|
108
|
+
self._metric_acquired.inc()
|
|
109
|
+
return ret
|
|
110
|
+
|
|
111
|
+
def release(self):
|
|
112
|
+
super(MeasuredSemaphore, self).release()
|
|
113
|
+
self._metric_acquired.dec()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
subprocess_sem = MeasuredSemaphore(
|
|
117
|
+
PROC_MAX_CONCURRENT,
|
|
118
|
+
PROM_CONVERT_PROCESSES,
|
|
119
|
+
PROM_CONVERT_QUEUE,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Windows only supports subprocesses with the asyncio ProactorEventLoop
|
|
124
|
+
# However tornado only supports the SelectorEventLoop
|
|
125
|
+
# https://github.com/tornadoweb/tornado/issues/2608
|
|
126
|
+
# For now we can't use asyncio subprocesses on Windows
|
|
127
|
+
async def _check_call_threadpool(cmd, timeout, env=None):
|
|
128
|
+
async with subprocess_sem:
|
|
129
|
+
with tracer.start_as_current_span(
|
|
130
|
+
'taguette/subprocess',
|
|
131
|
+
attributes={'command': ' '.join(cmd)},
|
|
132
|
+
):
|
|
133
|
+
return await asyncio.get_event_loop().run_in_executor(
|
|
134
|
+
None,
|
|
135
|
+
lambda: subprocess.check_call(cmd, timeout=timeout, env=env),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _check_call_asyncio(cmd, timeout, env=None):
|
|
140
|
+
async with subprocess_sem:
|
|
141
|
+
with tracer.start_as_current_span(
|
|
142
|
+
'taguette/subprocess',
|
|
143
|
+
attributes={'command': ' '.join(cmd)},
|
|
144
|
+
):
|
|
145
|
+
proc = await asyncio.create_subprocess_exec(
|
|
146
|
+
*cmd,
|
|
147
|
+
stdout=subprocess.PIPE,
|
|
148
|
+
stderr=subprocess.STDOUT,
|
|
149
|
+
env=env,
|
|
150
|
+
)
|
|
151
|
+
try:
|
|
152
|
+
retcode = await asyncio.wait_for(
|
|
153
|
+
log_and_wait_proc(logger, proc),
|
|
154
|
+
timeout=timeout,
|
|
155
|
+
)
|
|
156
|
+
except asyncio.TimeoutError:
|
|
157
|
+
logger.warning(
|
|
158
|
+
"Process didn't finish before %ds timeout: %r",
|
|
159
|
+
timeout, cmd,
|
|
160
|
+
)
|
|
161
|
+
try:
|
|
162
|
+
proc.terminate()
|
|
163
|
+
try:
|
|
164
|
+
await asyncio.wait_for(proc.wait(), PROC_TERM_GRACE)
|
|
165
|
+
except asyncio.TimeoutError:
|
|
166
|
+
proc.kill()
|
|
167
|
+
except ProcessLookupError:
|
|
168
|
+
pass
|
|
169
|
+
raise asyncio.TimeoutError
|
|
170
|
+
else:
|
|
171
|
+
if retcode != 0:
|
|
172
|
+
raise CalledProcessError(retcode, cmd)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if sys.platform == 'win32':
|
|
176
|
+
check_call = _check_call_threadpool
|
|
177
|
+
else:
|
|
178
|
+
check_call = _check_call_asyncio
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# Something to HTML
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get_html_body(body):
|
|
185
|
+
# Use beautifulsoup to remove head, script, style elements
|
|
186
|
+
# (bleach can do that, but would keep the text inside them)
|
|
187
|
+
soup = bs4.BeautifulSoup(body, 'html5lib')
|
|
188
|
+
for tag in ['head', 'script', 'style']:
|
|
189
|
+
for e in soup.find_all(tag):
|
|
190
|
+
e.extract()
|
|
191
|
+
# Update 'src' URLs
|
|
192
|
+
for e in soup.find_all('img'):
|
|
193
|
+
e.attrs['src'] = '/static/missing.png'
|
|
194
|
+
# Update 'href' URLs
|
|
195
|
+
for e in soup.find_all('a'):
|
|
196
|
+
if 'href' not in e.attrs:
|
|
197
|
+
e.replace_with(e.text)
|
|
198
|
+
continue
|
|
199
|
+
href = e.attrs['href'].lower()
|
|
200
|
+
if not (href.startswith('http://') or
|
|
201
|
+
href.startswith('https://') or
|
|
202
|
+
href.startswith('mailto:')):
|
|
203
|
+
e.attrs['title'] = e.attrs['href']
|
|
204
|
+
del e.attrs['href']
|
|
205
|
+
else:
|
|
206
|
+
if 'title' in e.attrs:
|
|
207
|
+
del e.attrs['title']
|
|
208
|
+
|
|
209
|
+
# Cleanup empty tags
|
|
210
|
+
for x in soup.find_all():
|
|
211
|
+
if len(x.get_text(strip=True)) == 0 and x.name not in ['br', 'img']:
|
|
212
|
+
x.extract()
|
|
213
|
+
|
|
214
|
+
# Back to string
|
|
215
|
+
body = str(soup)
|
|
216
|
+
del soup
|
|
217
|
+
|
|
218
|
+
# Use bleach to sanitize the content
|
|
219
|
+
body = bleach.clean(
|
|
220
|
+
body,
|
|
221
|
+
tags=['p', 'br', 'code', 'blockquote', 'pre', # formatting
|
|
222
|
+
'sub', 'sup', 'caption',
|
|
223
|
+
'a', 'img', # non-text
|
|
224
|
+
'h1', 'h2', 'h3', 'h4', 'h5', # headers
|
|
225
|
+
'strong', 'em', 'b', 'u', 'q', 'del', # emphasis
|
|
226
|
+
'ul', 'ol', 'li', 'dl', 'dt', 'dd', # lists
|
|
227
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td', # tables
|
|
228
|
+
'colgroup', 'col', # columns
|
|
229
|
+
],
|
|
230
|
+
attributes={'a': ['href', 'title'], 'img': ['src']},
|
|
231
|
+
strip=True,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
body = body.strip()
|
|
235
|
+
|
|
236
|
+
return body
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def is_html_safe(text):
|
|
240
|
+
"""Check whether the given HTML is safe.
|
|
241
|
+
|
|
242
|
+
For situation where we cannot run `get_html_body()`, this will throw out
|
|
243
|
+
unsafe HTML.
|
|
244
|
+
"""
|
|
245
|
+
if isinstance(text, bytes):
|
|
246
|
+
try:
|
|
247
|
+
text = text.decode('utf-8')
|
|
248
|
+
except UnicodeDecodeError as e:
|
|
249
|
+
logging.warning("is_html_safe(): %s", e)
|
|
250
|
+
return False
|
|
251
|
+
elif not isinstance(text, str):
|
|
252
|
+
raise TypeError("is_html_safe() expects str or bytes, not %r" % (
|
|
253
|
+
type(text).__name__,
|
|
254
|
+
))
|
|
255
|
+
|
|
256
|
+
soup = bs4.BeautifulSoup(text, 'html5lib')
|
|
257
|
+
# Check 'src' URLs
|
|
258
|
+
for e in soup.find_all('img'):
|
|
259
|
+
if e.attrs['src'] != '/static/missing.png':
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
# Use bleach to sanitize the content
|
|
263
|
+
cleaned = bleach.clean(
|
|
264
|
+
text,
|
|
265
|
+
tags=['p', 'br', 'code', 'blockquote', 'pre', # formatting
|
|
266
|
+
'sub', 'sup', 'caption',
|
|
267
|
+
'a', 'img', # non-text
|
|
268
|
+
'h1', 'h2', 'h3', 'h4', 'h5', # headers
|
|
269
|
+
'strong', 'em', 'b', 'u', 'q', 'del', # emphasis
|
|
270
|
+
'ul', 'ol', 'li', 'dl', 'dt', 'dd', # lists
|
|
271
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td', # tables
|
|
272
|
+
'colgroup', 'col', # columns
|
|
273
|
+
],
|
|
274
|
+
attributes={'a': ['href', 'title'], 'img': ['src', 'width', 'height']},
|
|
275
|
+
strip=True,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# text.strip() == cleaned.strip()
|
|
279
|
+
# This doesn't work because bleach changed from outputting <br/> to <br>
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
text.strip().replace('/>', '>')
|
|
283
|
+
== cleaned.strip().replace('/>', '>')
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@tracer.start_as_current_span('taguette/convert/calibre_to_html')
|
|
288
|
+
@prom_async_time(PROM_CALIBRE_TOHTML_TIME)
|
|
289
|
+
async def calibre_to_html(input_filename, temp_dir, config):
|
|
290
|
+
PROM_CALIBRE_TOHTML.inc()
|
|
291
|
+
|
|
292
|
+
output_dir = os.path.join(temp_dir, 'output')
|
|
293
|
+
output = []
|
|
294
|
+
convert = 'ebook-convert'
|
|
295
|
+
if os.environ.get('CALIBRE'):
|
|
296
|
+
convert = os.path.join(os.environ['CALIBRE'], convert)
|
|
297
|
+
cmd = [convert, input_filename, output_dir]
|
|
298
|
+
if os.path.splitext(input_filename)[1].lower() == '.pdf':
|
|
299
|
+
cmd.append('--no-images')
|
|
300
|
+
cmd_heuristics = (cmd + ['--enable-heuristics']
|
|
301
|
+
+ ['--disable-unwrap-lines']
|
|
302
|
+
+ ['--input-encoding']
|
|
303
|
+
+ ['UTF-8']
|
|
304
|
+
)
|
|
305
|
+
logger.info("Running: %s", ' '.join(cmd_heuristics))
|
|
306
|
+
try:
|
|
307
|
+
try:
|
|
308
|
+
await check_call(cmd_heuristics, config['CONVERT_TO_HTML_TIMEOUT'],
|
|
309
|
+
env=dict(os.environ, TMPDIR=temp_dir))
|
|
310
|
+
except asyncio.TimeoutError:
|
|
311
|
+
logger.warning("Calibre timed out, trying again without "
|
|
312
|
+
"heuristics...")
|
|
313
|
+
try:
|
|
314
|
+
await check_call(cmd, config['CONVERT_TO_HTML_TIMEOUT'],
|
|
315
|
+
env=dict(os.environ, TMPDIR=temp_dir))
|
|
316
|
+
except asyncio.TimeoutError:
|
|
317
|
+
raise ConversionError("Calibre took too long and was stopped")
|
|
318
|
+
except OSError:
|
|
319
|
+
raise ConversionError("Calibre is not available")
|
|
320
|
+
except CalledProcessError:
|
|
321
|
+
raise ConversionError("Calibre couldn't convert that file")
|
|
322
|
+
logger.info("ebook-convert successful")
|
|
323
|
+
|
|
324
|
+
# Locate OEB manifest
|
|
325
|
+
manifests = [e.lower() for e in os.listdir(output_dir)]
|
|
326
|
+
manifests = [e for e in manifests if e.endswith('.opf')]
|
|
327
|
+
if not manifests:
|
|
328
|
+
logger.error("No OPF manifest in Calibre's output")
|
|
329
|
+
raise ConversionError("Invalid output from Calibre")
|
|
330
|
+
elif manifests == ['content.opf']:
|
|
331
|
+
manifest = 'content.opf' # All good
|
|
332
|
+
elif len(manifests) > 1 and 'content.opf' in manifests:
|
|
333
|
+
logger.warning("Calibre's output contains multiple OPF "
|
|
334
|
+
"manifests! Using content.opf")
|
|
335
|
+
manifest = 'content.opf'
|
|
336
|
+
elif len(manifests) == 1 and manifests[0] != 'content.opf':
|
|
337
|
+
manifest, = manifests
|
|
338
|
+
logger.warning("Unusual name for OPF manifest in Calibre's "
|
|
339
|
+
"output: %r", manifest)
|
|
340
|
+
else:
|
|
341
|
+
logger.error("Multiple OPF manifests in Calibre's output: "
|
|
342
|
+
"%r" % manifests)
|
|
343
|
+
raise ConversionError("Invalid output from Calibre")
|
|
344
|
+
|
|
345
|
+
size = os.stat(os.path.join(output_dir, manifest)).st_size
|
|
346
|
+
if size > config['OPF_OUT_SIZE_LIMIT']:
|
|
347
|
+
logger.warning("OPF manifest is %d bytes; aborting", size)
|
|
348
|
+
raise ConversionError("Output manifest is too long")
|
|
349
|
+
|
|
350
|
+
# Open OEB manifest
|
|
351
|
+
logger.info("Parsing OPF manifest %s", manifest)
|
|
352
|
+
tree = ElementTree.parse(os.path.join(output_dir, manifest))
|
|
353
|
+
root = tree.getroot()
|
|
354
|
+
ns = '{http://www.idpf.org/2007/opf}'
|
|
355
|
+
if root.tag not in ('package', ns + 'package'):
|
|
356
|
+
logger.error("Invalid root tag in OPF manifest: %r", root.tag)
|
|
357
|
+
raise ConversionError("Invalid output from Calibre")
|
|
358
|
+
manifests = [tag for tag in root
|
|
359
|
+
if tag.tag in ('manifest', ns + 'manifest')]
|
|
360
|
+
if len(manifests) != 1:
|
|
361
|
+
logger.error("OPF has %d <manifest> nodes", len(manifests))
|
|
362
|
+
raise ConversionError("Invalid output from Calibre")
|
|
363
|
+
manifest, = manifests
|
|
364
|
+
spines = [tag for tag in root
|
|
365
|
+
if tag.tag in ('spine', ns + 'spine')]
|
|
366
|
+
if len(spines) != 1:
|
|
367
|
+
logger.error("OPF has %d <spine> nodes", len(spines))
|
|
368
|
+
raise ConversionError("Invalid output from Calibre")
|
|
369
|
+
spine, = spines
|
|
370
|
+
|
|
371
|
+
# Read <manifest>
|
|
372
|
+
items = {}
|
|
373
|
+
for item in manifest:
|
|
374
|
+
if item.tag not in ('item', ns + 'item'):
|
|
375
|
+
continue
|
|
376
|
+
try:
|
|
377
|
+
name = item.attrib['href']
|
|
378
|
+
mimetype = item.attrib['media-type']
|
|
379
|
+
id_ = item.attrib['id']
|
|
380
|
+
except KeyError:
|
|
381
|
+
logger.error("Missing attributes from <item> in OPF "
|
|
382
|
+
"manifest. Present: %s",
|
|
383
|
+
', '.join(item.attrib))
|
|
384
|
+
raise ConversionError("Invalid output from Calibre")
|
|
385
|
+
else:
|
|
386
|
+
items[id_] = name, mimetype
|
|
387
|
+
logger.info("Read %d items", len(items))
|
|
388
|
+
|
|
389
|
+
# Read <spine>
|
|
390
|
+
size = 0
|
|
391
|
+
for item in spine:
|
|
392
|
+
if item.tag not in ('itemref', ns + 'itemref'):
|
|
393
|
+
continue
|
|
394
|
+
try:
|
|
395
|
+
idref = item.attrib['idref']
|
|
396
|
+
except KeyError:
|
|
397
|
+
logger.error("Missing attribute 'idref' from <itemref> in "
|
|
398
|
+
"OPF manifest. Present: %s",
|
|
399
|
+
', '.join(item.attrib))
|
|
400
|
+
raise ConversionError("Invalid output from Calibre")
|
|
401
|
+
try:
|
|
402
|
+
output_name, output_mimetype = items[idref]
|
|
403
|
+
except KeyError:
|
|
404
|
+
logger.error("Spine entry references missing item %r",
|
|
405
|
+
idref)
|
|
406
|
+
raise ConversionError("Invalid output from Calibre")
|
|
407
|
+
if output_mimetype not in HTML_MIMETYPES:
|
|
408
|
+
logger.warning("Ignoring item %r, mimetype=%r",
|
|
409
|
+
idref, output_mimetype)
|
|
410
|
+
continue
|
|
411
|
+
output_filename = os.path.join(output_dir, output_name)
|
|
412
|
+
if not os.path.isfile(output_filename):
|
|
413
|
+
logger.error("Missing file from output dir: %r",
|
|
414
|
+
output_name)
|
|
415
|
+
raise ConversionError("Invalid output from Calibre")
|
|
416
|
+
|
|
417
|
+
# Read output
|
|
418
|
+
logger.info("Reading in %r", output_name)
|
|
419
|
+
size += os.stat(output_filename).st_size
|
|
420
|
+
if size > config['HTML_OUT_SIZE_LIMIT']:
|
|
421
|
+
logger.warning(
|
|
422
|
+
"File is %d bytes for a total of %d bytes; aborting",
|
|
423
|
+
os.stat(output_filename).st_size,
|
|
424
|
+
size,
|
|
425
|
+
)
|
|
426
|
+
raise ConversionError("Output file is too long")
|
|
427
|
+
with open(output_filename, 'rb') as fp:
|
|
428
|
+
output.append(get_html_body(fp.read()))
|
|
429
|
+
# TODO: Store media files
|
|
430
|
+
|
|
431
|
+
# Assemble output
|
|
432
|
+
return '\n'.join(output)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@tracer.start_as_current_span('taguette/convert/wvware_to_html')
|
|
436
|
+
@prom_async_time(PROM_WVWARE_TOHTML_TIME)
|
|
437
|
+
async def wvware_to_html(input_filename, tmp, config):
|
|
438
|
+
PROM_WVWARE_TOHTML.inc()
|
|
439
|
+
output_filename = os.path.join(tmp, 'output.html')
|
|
440
|
+
|
|
441
|
+
# Run WV
|
|
442
|
+
convert = 'wvHtml'
|
|
443
|
+
if os.environ.get('WVHTML'):
|
|
444
|
+
convert = os.environ['WVHTML']
|
|
445
|
+
cmd = [convert, input_filename, output_filename]
|
|
446
|
+
logger.info("Running: %s", ' '.join(cmd))
|
|
447
|
+
try:
|
|
448
|
+
await check_call(cmd, config['CONVERT_TO_HTML_TIMEOUT'])
|
|
449
|
+
except OSError:
|
|
450
|
+
raise ConversionError("Can't call wvHtml to convert Word 97 file")
|
|
451
|
+
except CalledProcessError:
|
|
452
|
+
raise ConversionError("wvHtml couldn't convert that file")
|
|
453
|
+
except asyncio.TimeoutError:
|
|
454
|
+
raise ConversionError("wvHtml took too long and was stopped")
|
|
455
|
+
logger.info("wvHtml successful")
|
|
456
|
+
|
|
457
|
+
# Read output
|
|
458
|
+
with open(output_filename, 'rb') as fp:
|
|
459
|
+
return get_html_body(fp.read())
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
HTML_MIMETYPES = {'text/html', 'application/xhtml+xml'}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
async def to_html(body, content_type, filename, config):
|
|
466
|
+
logger.info("Converting file %r, type %r", filename, content_type)
|
|
467
|
+
|
|
468
|
+
ext = os.path.splitext(filename)[1].lower()
|
|
469
|
+
if ext in HTML_EXTENSIONS:
|
|
470
|
+
return get_html_body(body)
|
|
471
|
+
elif not ext:
|
|
472
|
+
raise ConversionError("This file doesn't have an extension!")
|
|
473
|
+
elif ext == '.doc':
|
|
474
|
+
# Convert file to HTML using WV
|
|
475
|
+
tmp = tempfile.mkdtemp(prefix='taguette_wv_')
|
|
476
|
+
try:
|
|
477
|
+
# Write file to temporary directory
|
|
478
|
+
input_filename = os.path.join(tmp, sanitize_filename(filename))
|
|
479
|
+
with open(input_filename, 'wb') as fp:
|
|
480
|
+
fp.write(body)
|
|
481
|
+
|
|
482
|
+
# Run wvHtml
|
|
483
|
+
return await wvware_to_html(input_filename, tmp, config)
|
|
484
|
+
finally:
|
|
485
|
+
shutil.rmtree(tmp)
|
|
486
|
+
elif ext in ('.srt', '.vtt'):
|
|
487
|
+
# Convert file to HTML using subtitle-parser
|
|
488
|
+
|
|
489
|
+
# Pick the parser class
|
|
490
|
+
if ext == '.vtt':
|
|
491
|
+
parser_cls = subtitle_parser.WebVttParser
|
|
492
|
+
else:
|
|
493
|
+
parser_cls = subtitle_parser.SrtParser
|
|
494
|
+
|
|
495
|
+
# Detect encoding
|
|
496
|
+
charset = chardet.detect(body)['encoding'] or 'utf-8'
|
|
497
|
+
file = io.BytesIO(body)
|
|
498
|
+
file = codecs.getreader(charset)(file)
|
|
499
|
+
|
|
500
|
+
# Parse subtitle file
|
|
501
|
+
try:
|
|
502
|
+
parser = parser_cls(file)
|
|
503
|
+
parser.parse()
|
|
504
|
+
except subtitle_parser.SubtitleError as e:
|
|
505
|
+
raise ConversionError("Invalid subtitle file: %s" % (e,))
|
|
506
|
+
|
|
507
|
+
# Turn the result into HTML
|
|
508
|
+
output = io.StringIO()
|
|
509
|
+
subtitle_parser.render_html(parser.subtitles, output)
|
|
510
|
+
return output.getvalue()
|
|
511
|
+
else:
|
|
512
|
+
# Convert file to HTML using Calibre
|
|
513
|
+
tmp = tempfile.mkdtemp(prefix='taguette_calibre_')
|
|
514
|
+
try:
|
|
515
|
+
# Write file to temporary directory
|
|
516
|
+
input_filename = os.path.join(tmp, filename)
|
|
517
|
+
with open(input_filename, 'wb') as fp:
|
|
518
|
+
fp.write(body)
|
|
519
|
+
|
|
520
|
+
# Run Calibre
|
|
521
|
+
return await calibre_to_html(
|
|
522
|
+
input_filename,
|
|
523
|
+
tmp,
|
|
524
|
+
config,
|
|
525
|
+
)
|
|
526
|
+
finally:
|
|
527
|
+
shutil.rmtree(tmp)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
async def to_html_chunks(body, content_type, filename, config):
|
|
531
|
+
html = await to_html(body, content_type, filename, config)
|
|
532
|
+
# TODO: Do chunks
|
|
533
|
+
return html
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# HTML to something
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@tracer.start_as_current_span('taguette/convert/calibre_from_html')
|
|
540
|
+
@prom_async_time(PROM_CALIBRE_FROMHTML_TIME)
|
|
541
|
+
async def calibre_from_html(html, extension, config):
|
|
542
|
+
PROM_CALIBRE_FROMHTML.labels(extension).inc()
|
|
543
|
+
|
|
544
|
+
# Convert file using Calibre
|
|
545
|
+
tmp = tempfile.mkdtemp(prefix='taguette_calibre_')
|
|
546
|
+
try:
|
|
547
|
+
input_filename = os.path.join(tmp, 'input.html')
|
|
548
|
+
with open(input_filename, 'w', encoding='utf-8') as fp:
|
|
549
|
+
fp.write(html)
|
|
550
|
+
output_filename = os.path.join(tmp, 'output.%s' % extension)
|
|
551
|
+
convert = 'ebook-convert'
|
|
552
|
+
if os.environ.get('CALIBRE'):
|
|
553
|
+
convert = os.path.join(os.environ['CALIBRE'], convert)
|
|
554
|
+
cmd = [convert, input_filename, output_filename,
|
|
555
|
+
'--page-breaks-before=/']
|
|
556
|
+
logger.info("Running: %s", ' '.join(cmd))
|
|
557
|
+
try:
|
|
558
|
+
await check_call(cmd, config['CONVERT_FROM_HTML_TIMEOUT'],
|
|
559
|
+
env=dict(os.environ, TMPDIR=tmp))
|
|
560
|
+
except OSError:
|
|
561
|
+
raise ConversionError("Calibre is not available")
|
|
562
|
+
except CalledProcessError:
|
|
563
|
+
raise ConversionError("Calibre couldn't convert that file")
|
|
564
|
+
except asyncio.TimeoutError:
|
|
565
|
+
raise ConversionError("Calibre took too long and was stopped")
|
|
566
|
+
logger.info("ebook-convert successful")
|
|
567
|
+
if not os.path.isfile(output_filename):
|
|
568
|
+
raise RuntimeError("Output file does not exist")
|
|
569
|
+
except Exception:
|
|
570
|
+
shutil.rmtree(tmp)
|
|
571
|
+
raise
|
|
572
|
+
else:
|
|
573
|
+
def reader():
|
|
574
|
+
try:
|
|
575
|
+
with open(output_filename, 'rb') as fp:
|
|
576
|
+
chunk = fp.read(4096)
|
|
577
|
+
yield chunk
|
|
578
|
+
while len(chunk) == 4096:
|
|
579
|
+
chunk = fp.read(4096)
|
|
580
|
+
if chunk:
|
|
581
|
+
yield chunk
|
|
582
|
+
finally:
|
|
583
|
+
shutil.rmtree(tmp)
|
|
584
|
+
|
|
585
|
+
return reader()
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def html_to_html(html, config):
|
|
589
|
+
_ = config
|
|
590
|
+
future = asyncio.get_event_loop().create_future()
|
|
591
|
+
future.set_result([html])
|
|
592
|
+
return future
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
html_to_extensions = {
|
|
596
|
+
'html': (html_to_html,
|
|
597
|
+
'text/html; charset=utf-8'),
|
|
598
|
+
'doc': (lambda html, config: calibre_from_html(html, 'docx', config),
|
|
599
|
+
'application/vnd.openxmlformats-officedocument.'
|
|
600
|
+
'wordprocessingml.document; charset=utf-8'),
|
|
601
|
+
'docx': (lambda html, config: calibre_from_html(html, 'docx', config),
|
|
602
|
+
'application/vnd.openxmlformats-officedocument.'
|
|
603
|
+
'wordprocessingml.document; charset=utf-8'),
|
|
604
|
+
'pdf': (lambda html, config: calibre_from_html(html, 'pdf', config),
|
|
605
|
+
'application/pdf'),
|
|
606
|
+
'rtf': (lambda html, config: calibre_from_html(html, 'rtf', config),
|
|
607
|
+
'application/rtf'),
|
|
608
|
+
}
|
|
609
|
+
for n in html_to_extensions:
|
|
610
|
+
PROM_CALIBRE_FROMHTML.labels(n).inc(0)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def html_to(html, extension, config):
|
|
614
|
+
try:
|
|
615
|
+
func, mimetype = html_to_extensions[extension.lower()]
|
|
616
|
+
except KeyError:
|
|
617
|
+
raise UnsupportedFormat
|
|
618
|
+
return mimetype, asyncio.ensure_future(func(html, config))
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def html_to_plaintext(html):
|
|
622
|
+
soup = bs4.BeautifulSoup(html, 'html5lib')
|
|
623
|
+
return soup.get_text(' ', strip=True)
|