diff --git a/src/calibre/ebooks/__init__.py b/src/calibre/ebooks/__init__.py index 2605e47980..11b70e2c7a 100644 --- a/src/calibre/ebooks/__init__.py +++ b/src/calibre/ebooks/__init__.py @@ -89,7 +89,7 @@ def extract_calibre_cover(raw, base, log): return return_raster_image(img) -def render_html_svg_workaround(path_to_html, log, width=590, height=750): +def render_html_svg_workaround(path_to_html, log, width=590, height=750, root=''): from calibre.ebooks.oeb.base import SVG_NS with open(path_to_html, 'rb') as f: raw = f.read() @@ -108,11 +108,11 @@ def render_html_svg_workaround(path_to_html, log, width=590, height=750): pass if data is None: - data = render_html_data(path_to_html, width, height) + data = render_html_data(path_to_html, width, height, root=root) return data -def render_html_data(path_to_html, width, height): +def render_html_data(path_to_html, width, height, root=''): from calibre.ptempfile import TemporaryDirectory from calibre.utils.ipc.simple_worker import fork_job, WorkerError result = {} @@ -127,7 +127,7 @@ def render_html_data(path_to_html, width, height): with TemporaryDirectory('-render-html') as tdir: try: - result = fork_job('calibre.ebooks.render_html', 'main', args=(path_to_html, tdir, 'jpeg')) + result = fork_job('calibre.ebooks.render_html', 'main', args=(path_to_html, tdir, 'jpeg', root)) except WorkerError as e: report_error(e.orig_tb) else: diff --git a/src/calibre/ebooks/conversion/plugins/epub_input.py b/src/calibre/ebooks/conversion/plugins/epub_input.py index b5470ccbc4..6b880cd601 100644 --- a/src/calibre/ebooks/conversion/plugins/epub_input.py +++ b/src/calibre/ebooks/conversion/plugins/epub_input.py @@ -218,7 +218,7 @@ class EPUBInput(InputFormatPlugin): elem[0].getparent(), OPF('item'), href=guide_elem.get('href'), id='calibre_raster_cover') t.set('media-type', 'image/jpeg') if os.path.exists(guide_cover): - renderer = render_html_svg_workaround(guide_cover, log) + renderer = render_html_svg_workaround(guide_cover, log, root=os.getcwd()) if renderer is not None: with lopen('calibre_raster_cover.jpg', 'wb') as f: f.write(renderer) diff --git a/src/calibre/ebooks/conversion/plugins/pdf_output.py b/src/calibre/ebooks/conversion/plugins/pdf_output.py index 959d7b6d0b..d45305b404 100644 --- a/src/calibre/ebooks/conversion/plugins/pdf_output.py +++ b/src/calibre/ebooks/conversion/plugins/pdf_output.py @@ -146,16 +146,11 @@ class PDFOutput(OutputFormatPlugin): # Ensure Qt is setup to be used with WebEngine # specialize_options is called early enough in the pipeline # that hopefully no Qt application has been constructed as yet - from qt.webengine import QWebEngineUrlScheme from qt.webengine import QWebEnginePage # noqa from calibre.gui2 import must_use_qt - from calibre.constants import FAKE_PROTOCOL - scheme = QWebEngineUrlScheme(FAKE_PROTOCOL.encode('ascii')) - scheme.setSyntax(QWebEngineUrlScheme.Syntax.Host) - scheme.setFlags(QWebEngineUrlScheme.Flag.SecureScheme) - QWebEngineUrlScheme.registerScheme(scheme) + from calibre.utils.webengine import setup_fake_protocol, setup_default_profile + setup_fake_protocol() must_use_qt() - from calibre.utils.webengine import setup_default_profile setup_default_profile() self.input_fmt = input_fmt diff --git a/src/calibre/ebooks/metadata/epub.py b/src/calibre/ebooks/metadata/epub.py index 85a588aabc..a77d322599 100644 --- a/src/calibre/ebooks/metadata/epub.py +++ b/src/calibre/ebooks/metadata/epub.py @@ -193,7 +193,7 @@ def render_cover(cpage, zf, reader=None): cpage = os.path.join(tdir, cpage) if not os.path.exists(cpage): return - return render_html_svg_workaround(cpage, default_log) + return render_html_svg_workaround(cpage, default_log, root=tdir) def get_cover(raster_cover, first_spine_item, reader): diff --git a/src/calibre/ebooks/oeb/reader.py b/src/calibre/ebooks/oeb/reader.py index ba48e805e9..2aea5ae48c 100644 --- a/src/calibre/ebooks/oeb/reader.py +++ b/src/calibre/ebooks/oeb/reader.py @@ -625,7 +625,7 @@ class OEBReader: writer = OEBWriter() writer(self.oeb, tdir) path = os.path.join(tdir, unquote(hcover.href)) - data = render_html_svg_workaround(path, self.logger) + data = render_html_svg_workaround(path, self.logger, root=tdir) if not data: data = b'' id, href = self.oeb.manifest.generate('cover', 'cover.jpg') diff --git a/src/calibre/ebooks/render_html.py b/src/calibre/ebooks/render_html.py index 9f037e8876..285adc9a32 100644 --- a/src/calibre/ebooks/render_html.py +++ b/src/calibre/ebooks/render_html.py @@ -4,26 +4,88 @@ import os import sys - from qt.core import ( - QApplication, QMarginsF, QPageLayout, QPageSize, Qt, QTimer, QUrl + QApplication, QByteArray, QMarginsF, QPageLayout, QPageSize, Qt, QTimer, QUrl +) +from qt.webengine import ( + QWebEnginePage, QWebEngineProfile, QWebEngineScript, + QWebEngineUrlRequestInterceptor, QWebEngineUrlRequestJob, + QWebEngineUrlSchemeHandler ) -from qt.webengine import QWebEnginePage, QWebEngineScript +from calibre.constants import FAKE_HOST, FAKE_PROTOCOL from calibre.ebooks.metadata.pdf import page_images +from calibre.ebooks.oeb.polish.utils import guess_type from calibre.gui2 import must_use_qt -from calibre.utils.webengine import secure_webengine +from calibre.gui_launch import setup_qt_logging from calibre.utils.filenames import atomic_rename +from calibre.utils.logging import default_log from calibre.utils.monotonic import monotonic +from calibre.utils.webengine import ( + secure_webengine, send_reply, setup_fake_protocol, setup_profile +) LOAD_TIMEOUT = 20 PRINT_TIMEOUT = 10 +class RequestInterceptor(QWebEngineUrlRequestInterceptor): + + def interceptRequest(self, request_info): + method = bytes(request_info.requestMethod()) + if method not in (b'GET', b'HEAD'): + default_log.warn(f'Blocking URL request with method: {method}') + request_info.block(True) + return + qurl = request_info.requestUrl() + if qurl.scheme() not in (FAKE_PROTOCOL,): + default_log.warn(f'Blocking URL request {qurl.toString()} as it is not for a resource related to the HTML file being rendered') + request_info.block(True) + return + + +class UrlSchemeHandler(QWebEngineUrlSchemeHandler): + + def __init__(self, root, parent=None): + self.root = root + super().__init__(parent) + self.allowed_hosts = (FAKE_HOST,) + + def requestStarted(self, rq): + if bytes(rq.requestMethod()) != b'GET': + return self.fail_request(rq, QWebEngineUrlRequestJob.Error.RequestDenied) + url = rq.requestUrl() + host = url.host() + if host not in self.allowed_hosts or url.scheme() != FAKE_PROTOCOL: + return self.fail_request(rq) + path = url.path() + rp = path[1:] + if not rp: + return self.fail_request(rq, QWebEngineUrlRequestJob.Error.UrlNotFound) + resolved_path = os.path.abspath(os.path.join(self.root, rp.replace('/', os.sep))) + if not resolved_path.startswith(self.root): + return self.fail_request(rq, QWebEngineUrlRequestJob.Error.UrlNotFound) + + try: + with open(resolved_path, 'rb') as f: + data = f.read() + except OSError as err: + default_log(f'Failed to read file: {rp} with error: {err}') + return self.fail_request(rq, QWebEngineUrlRequestJob.Error.RequestFailed) + + send_reply(rq, guess_type(os.path.basename(resolved_path)), data) + + def fail_request(self, rq, fail_code=None): + if fail_code is None: + fail_code = QWebEngineUrlRequestJob.Error.UrlNotFound + rq.fail(fail_code) + print(f"Blocking FAKE_PROTOCOL request: {rq.requestUrl().toString()} with code: {fail_code}", file=sys.stderr) + + class Render(QWebEnginePage): - def __init__(self): - QWebEnginePage.__init__(self) + def __init__(self, profile): + QWebEnginePage.__init__(self, profile, QApplication.instance()) secure_webengine(self) self.printing_started = False self.loadFinished.connect(self.load_finished, type=Qt.ConnectionType.QueuedConnection) @@ -32,6 +94,11 @@ class Render(QWebEnginePage): t.setInterval(500) t.timeout.connect(self.hang_check) + def break_cycles(self): + self.hang_timer.timeout.disconnect() + self.pdfPrintingFinished.disconnect() + self.setParent(None) + def load_finished(self, ok): if ok: self.runJavaScript(''' @@ -52,8 +119,10 @@ class Render(QWebEnginePage): def javaScriptConsoleMessage(self, level, msg, linenumber, source_id): pass - def start_load(self, path_to_html): - self.load(QUrl.fromLocalFile(path_to_html)) + def start_load(self, path_to_html, root): + url = QUrl(f'{FAKE_PROTOCOL}://{FAKE_HOST}') + url.setPath('/' + os.path.relpath(path_to_html, root).replace(os.sep, '/')) + self.setUrl(url) self.start_time = monotonic() self.hang_timer.start() @@ -79,7 +148,9 @@ class Render(QWebEnginePage): if type(getattr(QPageSize, sz, None)) is type(QPageSize.PageSizeId.A4): # noqa page_size = QPageSize(getattr(QPageSize, sz)) else: - from calibre.ebooks.pdf.image_writer import parse_pdf_page_size + from calibre.ebooks.pdf.image_writer import ( + parse_pdf_page_size + ) ps = parse_pdf_page_size(sz, data.get('unit', 'inch')) if ps is not None: page_size = ps @@ -95,17 +166,25 @@ class Render(QWebEnginePage): self.hang_timer.stop() -def main(path_to_html, tdir, image_format='jpeg'): +def main(path_to_html, tdir, image_format='jpeg', root=''): if image_format not in ('jpeg', 'png'): raise ValueError('Image format must be either jpeg or png') must_use_qt() - from calibre.utils.webengine import setup_default_profile - setup_default_profile() + setup_qt_logging() + setup_fake_protocol() + profile = setup_profile(QWebEngineProfile(QApplication.instance())) path_to_html = os.path.abspath(path_to_html) + url_handler = UrlSchemeHandler(root or os.path.dirname(path_to_html), parent=profile) + interceptor = RequestInterceptor(profile) + profile.installUrlSchemeHandler(QByteArray(FAKE_PROTOCOL.encode('ascii')), url_handler) + profile.setUrlRequestInterceptor(interceptor) + os.chdir(tdir) - renderer = Render() - renderer.start_load(path_to_html) + renderer = Render(profile) + renderer.start_load(path_to_html, url_handler.root) ret = QApplication.instance().exec() + renderer.break_cycles() + del renderer if ret == 0: page_images('rendered.pdf', image_format=image_format) ext = {'jpeg': 'jpg'}.get(image_format, image_format) diff --git a/src/calibre/gui2/toc/main.py b/src/calibre/gui2/toc/main.py index b8c336b481..16861bdb52 100644 --- a/src/calibre/gui2/toc/main.py +++ b/src/calibre/gui2/toc/main.py @@ -1188,8 +1188,9 @@ def main(shm_name=None): override = 'calibre-gui' if islinux else None app = Application([], override_program_name=override) - from calibre.utils.webengine import setup_default_profile + from calibre.utils.webengine import setup_default_profile, setup_fake_protocol setup_default_profile() + setup_fake_protocol() d = TOCEditor(path, title=title, write_result_to=path + '.result') d.start() ok = 0 diff --git a/src/calibre/gui2/tweak_book/main.py b/src/calibre/gui2/tweak_book/main.py index 8fb8457af2..b103949e73 100644 --- a/src/calibre/gui2/tweak_book/main.py +++ b/src/calibre/gui2/tweak_book/main.py @@ -8,7 +8,7 @@ import time from qt.core import QIcon -from calibre.constants import EDITOR_APP_UID, FAKE_PROTOCOL, islinux +from calibre.constants import EDITOR_APP_UID, islinux from calibre.ebooks.oeb.polish.check.css import shutdown as shutdown_css_check_pool from calibre.gui2 import ( Application, decouple, set_gui_prefs, setup_gui_option_parser @@ -50,14 +50,11 @@ def gui_main(path=None, notify=None): def _run(args, notify=None): - from qt.webengine import QWebEngineUrlScheme + from calibre.utils.webengine import setup_fake_protocol # Ensure we can continue to function if GUI is closed os.environ.pop('CALIBRE_WORKER_TEMP_DIR', None) reset_base_dir() - scheme = QWebEngineUrlScheme(FAKE_PROTOCOL.encode('ascii')) - scheme.setSyntax(QWebEngineUrlScheme.Syntax.Host) - scheme.setFlags(QWebEngineUrlScheme.Flag.SecureScheme) - QWebEngineUrlScheme.registerScheme(scheme) + setup_fake_protocol() # The following two lines are needed to prevent circular imports causing # errors during initialization of plugins that use the polish container diff --git a/src/calibre/gui2/viewer/main.py b/src/calibre/gui2/viewer/main.py index 71681dc3ac..f9cc69cdc2 100644 --- a/src/calibre/gui2/viewer/main.py +++ b/src/calibre/gui2/viewer/main.py @@ -8,7 +8,7 @@ import sys from contextlib import closing from qt.core import QIcon, QObject, Qt, QTimer, pyqtSignal -from calibre.constants import FAKE_PROTOCOL, VIEWER_APP_UID, islinux +from calibre.constants import VIEWER_APP_UID, islinux from calibre.gui2 import Application, error_dialog, setup_gui_option_parser from calibre.gui2.listener import send_message_in_process from calibre.gui2.viewer.config import get_session_pref, vprefs @@ -167,14 +167,11 @@ def run_gui(app, opts, args, internal_book_data, listener=None): def main(args=sys.argv): - from qt.webengine import QWebEngineUrlScheme + from calibre.utils.webengine import setup_fake_protocol # Ensure viewer can continue to function if GUI is closed os.environ.pop('CALIBRE_WORKER_TEMP_DIR', None) reset_base_dir() - scheme = QWebEngineUrlScheme(FAKE_PROTOCOL.encode('ascii')) - scheme.setSyntax(QWebEngineUrlScheme.Syntax.Host) - scheme.setFlags(QWebEngineUrlScheme.Flag.SecureScheme) - QWebEngineUrlScheme.registerScheme(scheme) + setup_fake_protocol() override = 'calibre-ebook-viewer' if islinux else None processed_args = [] internal_book_data = internal_book_data_path = None diff --git a/src/calibre/scraper/simple.py b/src/calibre/scraper/simple.py index 328fb33bc3..bb34526425 100644 --- a/src/calibre/scraper/simple.py +++ b/src/calibre/scraper/simple.py @@ -16,11 +16,11 @@ from calibre.utils.ipc.simple_worker import start_pipe_worker def worker_main(source): - from qt.core import QLoggingCategory, QUrl - QLoggingCategory.setFilterRules('''\ -qt.webenginecontext.info=false -''') + from qt.core import QUrl + from calibre.gui2 import must_use_qt + from calibre.gui_launch import setup_qt_logging + setup_qt_logging() from .simple_backend import SimpleScraper must_use_qt() diff --git a/src/calibre/utils/rapydscript.py b/src/calibre/utils/rapydscript.py index 747ee74272..cb7ede3919 100644 --- a/src/calibre/utils/rapydscript.py +++ b/src/calibre/utils/rapydscript.py @@ -59,7 +59,9 @@ def compiler(): from calibre import walk from calibre.gui2 import must_use_qt - from calibre.utils.webengine import secure_webengine, setup_default_profile, setup_profile + from calibre.utils.webengine import ( + secure_webengine, setup_default_profile, setup_profile + ) must_use_qt() setup_default_profile() @@ -335,7 +337,7 @@ def run_rapydscript_tests(): from qt.core import QApplication, QByteArray, QEventLoop, QUrl from qt.webengine import ( QWebEnginePage, QWebEngineProfile, QWebEngineScript, QWebEngineUrlRequestJob, - QWebEngineUrlScheme, QWebEngineUrlSchemeHandler + QWebEngineUrlSchemeHandler ) from urllib.parse import parse_qs @@ -343,14 +345,12 @@ def run_rapydscript_tests(): from calibre.gui2 import must_use_qt from calibre.gui2.viewer.web_view import send_reply from calibre.utils.webengine import ( - create_script, insert_scripts, secure_webengine, setup_default_profile, setup_profile + create_script, insert_scripts, secure_webengine, setup_default_profile, + setup_fake_protocol, setup_profile ) must_use_qt() setup_default_profile() - scheme = QWebEngineUrlScheme(FAKE_PROTOCOL.encode('ascii')) - scheme.setSyntax(QWebEngineUrlScheme.Syntax.Host) - scheme.setFlags(QWebEngineUrlScheme.Flag.SecureScheme) - QWebEngineUrlScheme.registerScheme(scheme) + setup_fake_protocol() base = base_dir() rapydscript_dir = os.path.join(base, 'src', 'pyj') diff --git a/src/calibre/utils/webengine.py b/src/calibre/utils/webengine.py index 1b6b3bbfcd..bf2a4c54c1 100644 --- a/src/calibre/utils/webengine.py +++ b/src/calibre/utils/webengine.py @@ -3,11 +3,25 @@ # License: GPL v3 Copyright: 2021, Kovid Goyal -import json, os +import json +import os from qt.core import QBuffer, QIODevice, QObject, pyqtSignal, sip -from qt.webengine import QWebEngineScript, QWebEngineSettings, QWebEngineProfile +from qt.webengine import ( + QWebEngineProfile, QWebEngineScript, QWebEngineSettings, QWebEngineUrlScheme +) -from calibre.constants import cache_dir, SPECIAL_TITLE_FOR_WEBENGINE_COMMS +from calibre.constants import ( + FAKE_PROTOCOL, SPECIAL_TITLE_FOR_WEBENGINE_COMMS, cache_dir +) + + +def setup_fake_protocol(): + p = FAKE_PROTOCOL.encode('ascii') + if not QWebEngineUrlScheme.schemeByName(p).name(): + scheme = QWebEngineUrlScheme(p) + scheme.setSyntax(QWebEngineUrlScheme.Syntax.Host) + scheme.setFlags(QWebEngineUrlScheme.Flag.SecureScheme) + QWebEngineUrlScheme.registerScheme(scheme) def setup_profile(profile):