Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
Release 9.1.1 (in development)
==============================

Features added
--------------

* #10688: singlehtml: Add the :confval:`singlehtml_embed_assets` option,
to embed local stylesheets, scripts, and images
into the generated HTML file,
producing a self-contained single file.
Patch by Anmol Sharma.

Bugs fixed
----------

Expand Down
22 changes: 22 additions & 0 deletions doc/usage/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2260,6 +2260,28 @@ so the HTML options also apply where appropriate.
but the only permitted key is :code-py:`'index'`,
and all other keys are ignored.

.. confval:: singlehtml_embed_assets
:type: :code-py:`bool`
:default: :code-py:`False`

If true, embed local assets referenced by the generated page
into the HTML file itself,
making the page self-contained
so that it can be shared or opened anywhere as a single file:

* Local stylesheets are replaced by inline ``<style>`` elements.
``@import`` rules and ``url(...)`` references to local files
within stylesheets are also embedded.
* Local scripts are replaced by inline ``<script>`` elements.
* Images in the document body and icons are embedded as ``data:`` URIs.

External references (e.g. ``https://``) are left unchanged,
as are ``srcset`` attributes and assets referenced only from
:confval:`html_additional_pages`.
Asset files are still written to the output directory,
but the generated page no longer depends on them.

.. versionadded:: 9.2

.. _htmlhelp-options:

Expand Down
194 changes: 194 additions & 0 deletions sphinx/builders/singlehtml.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

from __future__ import annotations

import base64
import html
import re
import warnings
from pathlib import Path
from typing import TYPE_CHECKING
from urllib.parse import unquote, urlsplit

from docutils import nodes

Expand All @@ -14,6 +19,7 @@
from sphinx.locale import __
from sphinx.util import logging
from sphinx.util.display import progress_message
from sphinx.util.images import guess_mimetype
from sphinx.util.nodes import inline_all_toctrees

if TYPE_CHECKING:
Expand Down Expand Up @@ -205,6 +211,185 @@ def write_additional_files(self) -> None:
)


#: Attributes within an HTML start tag (values must be quoted).
_ATTR_RE = re.compile(
r"""(?P<name>[^\s=<>/]+)\s*=\s*(?P<quote>["'])(?P<value>.*?)(?P=quote)""",
re.DOTALL,
)
_LINK_TAG_RE = re.compile(r'<link\b[^>]*>', re.IGNORECASE)
_SCRIPT_TAG_RE = re.compile(r'<script\b(?P<attrs>[^>]*)>\s*</script>', re.IGNORECASE)
_IMG_TAG_RE = re.compile(r'<img\b[^>]*>', re.IGNORECASE)
_SCRIPT_CLOSE_RE = re.compile(r'</(script)', re.IGNORECASE)
#: ``url(...)`` tokens in a stylesheet.
_CSS_URL_RE = re.compile(
r"""url\(\s*(?P<quote>["']?)(?P<url>[^"')]+?)(?P=quote)\s*\)"""
)
#: ``@import "..."`` or ``@import url(...)`` rules without media queries.
_CSS_IMPORT_RE = re.compile(
r"""@import\s+(?:url\(\s*)?(?P<quote>["']?)(?P<url>[^"'();]+?)(?P=quote)\s*\)?\s*;"""
)


def _tag_attributes(tag: str) -> dict[str, str]:
"""Extract the quoted attributes of a single HTML start tag."""
return {m['name'].lower(): m['value'] for m in _ATTR_RE.finditer(tag)}


def _replace_attribute_value(tag: str, name: str, new_value: str) -> str:
"""Replace the value of the *name* attribute within an HTML start tag."""
pattern = re.compile(
rf"""({re.escape(name)}\s*=\s*)(["']).*?\2""",
re.DOTALL | re.IGNORECASE,
)
return pattern.sub(lambda m: f'{m[1]}{m[2]}{new_value}{m[2]}', tag, count=1)


def _local_asset_path(url: str, base_dir: Path, outdir: Path) -> Path | None:
"""Resolve *url* to an existing file within *outdir*, if it is local.

Returns ``None`` for external references (e.g. ``https://`` or
protocol-relative URLs), ``data:`` URIs, fragments,
and references to files that do not exist within the output directory.
"""
if not url or url.startswith(('#', '//')):
return None
if urlsplit(url).scheme:
return None
# remove any query string ('?v=...' checksums) or fragment
path_part = url.partition('#')[0].partition('?')[0]
if not path_part:
return None
path = (base_dir / unquote(path_part)).resolve()
if not path.is_relative_to(outdir.resolve()):
return None
if not path.is_file():
return None
return path


def _data_uri(path: Path) -> str:
"""Encode the file at *path* as a ``data:`` URI."""
mimetype = guess_mimetype(path, default='application/octet-stream')
payload = base64.b64encode(path.read_bytes()).decode('ascii')
return f'data:{mimetype};base64,{payload}'


def _inline_css(path: Path, outdir: Path, _depth: int = 0) -> str | None:
"""Load a stylesheet, inlining local ``@import`` and ``url()`` references.

Returns ``None`` if the stylesheet cannot be embedded safely.
"""
if _depth > 10: # avoid cyclic or pathological @import chains
return None
try:
css = path.read_text(encoding='utf-8')
except (OSError, UnicodeDecodeError):
return None
if '</style' in css.lower():
# would terminate the enclosing <style> element early
return None
base_dir = path.parent

def replace_import(match: re.Match[str]) -> str:
target = _local_asset_path(match['url'], base_dir, outdir)
if target is None or target == path:
return match[0]
inlined = _inline_css(target, outdir, _depth + 1)
if inlined is None:
return match[0]
return inlined

css = _CSS_IMPORT_RE.sub(replace_import, css)

def replace_url(match: re.Match[str]) -> str:
target = _local_asset_path(match['url'], base_dir, outdir)
if target is None:
return match[0]
return f'url({_data_uri(target)})'

return _CSS_URL_RE.sub(replace_url, css)


def _embed_assets_in_page(page_path: Path, outdir: Path) -> None:
"""Rewrite *page_path* so that local assets are embedded into the page.

Local stylesheets become ``<style>`` elements, local scripts become
inline ``<script>`` elements, and local images and icons are embedded
as ``data:`` URIs. External (e.g. ``https://``) references are
left untouched.
"""
page = page_path.read_text(encoding='utf-8')
page_dir = page_path.parent

def replace_link(match: re.Match[str]) -> str:
tag = match[0]
attrs = _tag_attributes(tag)
rel = attrs.get('rel', '').lower().split()
href = attrs.get('href', '')
path = _local_asset_path(href, page_dir, outdir)
if path is None:
return tag
if 'stylesheet' in rel and 'alternate' not in rel:
css = _inline_css(path, outdir)
if css is None:
return tag
media = attrs.get('media', '')
media_attr = f' media="{html.escape(media, quote=True)}"' if media else ''
return f'<style{media_attr}>\n{css}\n</style>'
if 'icon' in rel or 'apple-touch-icon' in rel:
return _replace_attribute_value(tag, 'href', _data_uri(path))
return tag

page = _LINK_TAG_RE.sub(replace_link, page)

def replace_script(match: re.Match[str]) -> str:
tag = match[0]
attrs = _tag_attributes(tag)
src = attrs.get('src', '')
path = _local_asset_path(src, page_dir, outdir)
if path is None:
return tag
try:
script = path.read_text(encoding='utf-8')
except (OSError, UnicodeDecodeError):
return tag
# prevent the script content from terminating the element early;
# within JavaScript string literals this is an escaped forward slash
script = _SCRIPT_CLOSE_RE.sub(r'<\\/\1', script)
remaining = _ATTR_RE.sub(
lambda m: '' if m['name'].lower() == 'src' else m[0],
match['attrs'],
).strip()
remaining_attrs = f' {remaining}' if remaining else ''
return f'<script{remaining_attrs}>{script}</script>'

page = _SCRIPT_TAG_RE.sub(replace_script, page)

def replace_img(match: re.Match[str]) -> str:
tag = match[0]
src = _tag_attributes(tag).get('src', '')
path = _local_asset_path(src, page_dir, outdir)
if path is None:
return tag
return _replace_attribute_value(tag, 'src', _data_uri(path))

page = _IMG_TAG_RE.sub(replace_img, page)

page_path.write_text(page, encoding='utf-8')


def _embed_assets(app: Sphinx, exc: Exception | None) -> None:
"""Embed local assets into the page written by the singlehtml builder."""
builder = app.builder
if exc is not None or not isinstance(builder, SingleFileHTMLBuilder):
return
if not app.config.singlehtml_embed_assets:
return
with progress_message(__('embedding assets')):
page_path = builder.get_output_path(builder.config.root_doc)
_embed_assets_in_page(page_path, Path(builder.outdir))


def setup(app: Sphinx) -> ExtensionMetadata:
app.setup_extension('sphinx.builders.html')

Expand All @@ -215,6 +400,15 @@ def setup(app: Sphinx) -> ExtensionMetadata:
'html',
types=frozenset({dict}),
)
app.add_config_value(
'singlehtml_embed_assets',
False,
'html',
types=frozenset({bool}),
)
# run late (priority 999) so that assets copied by other extensions'
# ``build-finished`` handlers are embedded as well
app.connect('build-finished', _embed_assets, priority=999)

return {
'version': 'builtin',
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions tests/roots/test-singlehtml-embed/_static/imported.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
p.user-imported {
color: #123456;
}
5 changes: 5 additions & 0 deletions tests/roots/test-singlehtml-embed/_static/user.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@import "imported.css";

div.user-background {
background-image: url(background.png);
}
1 change: 1 addition & 0 deletions tests/roots/test-singlehtml-embed/_static/user.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
const userEmbeddedScript = '</script>';
12 changes: 12 additions & 0 deletions tests/roots/test-singlehtml-embed/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
html_static_path = ['_static']
html_css_files = [
'user.css',
(
'https://example.com/external.css',
{'media': 'print', 'priority': 400},
),
]
html_js_files = [
'user.js',
'https://example.com/external.js',
]
Binary file added tests/roots/test-singlehtml-embed/img.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions tests/roots/test-singlehtml-embed/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
test-singlehtml-embed
=====================

.. image:: img.png
57 changes: 57 additions & 0 deletions tests/test_builders/test_build_singlehtml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Test the SingleFileHTMLBuilder."""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

if TYPE_CHECKING:
from sphinx.testing.util import SphinxTestApp


@pytest.mark.sphinx('singlehtml', testroot='singlehtml-embed')
def test_singlehtml_embed_assets_disabled_by_default(app: SphinxTestApp) -> None:
app.build(force_all=True)
content = (app.outdir / 'index.html').read_text(encoding='utf8')

# assets are referenced, not embedded
assert 'href="_static/user.css' in content
assert 'src="_static/user.js' in content
assert 'src="_images/img.png"' in content
assert 'data:image/png;base64,' not in content


@pytest.mark.sphinx(
'singlehtml',
testroot='singlehtml-embed',
confoverrides={'singlehtml_embed_assets': True},
)
def test_singlehtml_embed_assets(app: SphinxTestApp) -> None:
app.build(force_all=True)
content = (app.outdir / 'index.html').read_text(encoding='utf8')

# no references to local stylesheets or scripts remain
assert 'href="_static/' not in content
assert 'src="_static/' not in content
assert 'src="_images/' not in content

# local stylesheets are inlined, including @import-ed stylesheets
# and url() references within them
assert 'p.user-imported' in content
assert 'background-image: url(data:image/png;base64,' in content

# local scripts are inlined, with '</script>' escaped so that the
# script content does not terminate the element early
assert "const userEmbeddedScript = '<\\/script>';" in content

# images in the document body are embedded as data URIs
assert 'src="data:image/png;base64,' in content

# external assets are left untouched
assert 'href="https://example.com/external.css"' in content
assert 'src="https://example.com/external.js"' in content

# asset files are still written to the output directory
assert (app.outdir / '_static' / 'user.css').is_file()
assert (app.outdir / '_images' / 'img.png').is_file()
Loading