Source code for click_extra.screenshot_html

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Render captured terminal text as self-contained HTML.

{mod}`click_extra.screenshot` decides what a capture shows, then hands the text,
its palette and the resolved {class}`~click_extra.screenshot.Chrome` to
{func}`render_html`, which writes them as an inline-styled `<pre>`: selectable,
searchable text that needs no stylesheet and reflows with the page. The window
around it is drawn with the geometry {mod}`click_extra.screenshot_svg` publishes,
so the two documents look like the same terminal.
"""

from __future__ import annotations

from functools import partial
from html import escape

from .screenshot_presets import CAPTURE_FONT_STACK, CaptureBackground, resolve_palette
from .screenshot_svg import (
    DEFAULT_BORDER_WIDTH,
    DEFAULT_RADIUS,
    NO_PAINT,
    OPAQUE,
    SHADOW_BLUR,
    SHADOW_OFFSET,
    WATERMARK_INK,
    WATERMARK_INSET,
    WATERMARK_SIZE,
    WATERMARK_URL,
    credit_markup,
)
from .styling import ansi_to_html

TYPE_CHECKING = False
if TYPE_CHECKING:
    from .screenshot_presets import TerminalPalette, TerminalPreset


def _credit_link(url: str, name: str) -> str:
    """Wrap the credit line's package name in an HTML link.

    The anchor inherits the credit's own gray rather than taking the page's link
    color, which would make the mark the loudest thing in a capture whose point
    is the terminal above it.
    """
    return (
        f'<a href="{escape(url)}" style="color: inherit">'
        f"{escape(name, quote=False)}</a>"
    )


[docs] def render_html( text: str, *, title: str = "", full: bool = True, background: CaptureBackground = CaptureBackground.DARK, preset: TerminalPreset | None = None, palette: TerminalPalette | None = None, border: str = NO_PAINT, border_width: int = DEFAULT_BORDER_WIDTH, radius: int = DEFAULT_RADIUS, backdrop: str = NO_PAINT, shadow: str = NO_PAINT, margin: int = 0, padding: int = 0, font_stack: str = CAPTURE_FONT_STACK, opacity: float = OPAQUE, watermark: str = "", watermark_color: str = WATERMARK_INK, watermark_url: str = WATERMARK_URL, ) -> str: """Render captured terminal text to HTML. The `<pre>` carries its own inline styling, so a fragment pasted into an existing page needs no stylesheet and cannot be restyled out of legibility by the host. Nothing else is needed either: a `<pre>` preserves the capture's own spacing, which is what spares HTML the column arithmetic {func}`~click_extra.screenshot_svg.render_svg` performs for a picture. ```{caution} The text is escaped before its ANSI is translated, the order {mod}`click_extra.table` uses for its `html` format. Skip it and any `<` a CLI prints opens a tag: click-extra's own `--export-config` help says it writes `to <stdout>`. ``` ```{note} An OSC 8 hyperlink loses its URL and keeps its visible text: the escape is dropped rather than turned into an `<a>`. ``` :param text: captured output, ANSI escape sequences included. :param title: `<title>` of the document. Ignored for a fragment. :param full: wrap the `<pre>` in a standalone document. `False` returns the `<pre>` alone, to paste into a page that has its own. :param background: chrome to draw on, see {class}`~click_extra.screenshot_presets.CaptureBackground`. :param palette: colors the text resolves against. `None` takes the ones the preset and chrome name, which is what a terminal capture wants. Stated by a capture whose colors come from somewhere else, as a {mod}`~click_extra.snippet` one takes them from a syntax style. :param border: color of the block's frame, see {func}`~click_extra.screenshot_svg.render_svg`. :param border_width: thickness of that frame, in pixels. :param radius: how round the block's corners are, in pixels. :param backdrop: paint filling the page behind the block. :param shadow: color of the block's drop shadow, see {func}`~click_extra.screenshot_svg.render_svg`. :param margin: pixels left around the block, on all four sides. :param padding: pixels added inside the block, on top of its own. :param opacity: how solid the block's background is, from {data}`~click_extra.screenshot_svg.OPAQUE` down to `0.0`, where the page shows straight through the text. :param watermark: credit line drawn under the block, against its right edge, where an SVG draws it in the margin. Empty draws none. :param watermark_color: color that line is drawn in, alpha included. :param watermark_url: where the package name points. Empty links nothing. :return: the rendered markup. """ if palette is None: palette = resolve_palette(preset, background) chrome, ink = palette.background, palette.foreground if opacity != OPAQUE: # CSS carries no background-opacity, and the `opacity` property would # take the text down with it, so the color itself is thinned instead. chrome = f"color-mix(in srgb, {chrome} {opacity:.0%}, transparent)" frame = "" if border == NO_PAINT else f"border: {border_width}px solid {border}; " if shadow != NO_PAINT: frame += f"box-shadow: 0 {SHADOW_OFFSET}px {SHADOW_BLUR * 2}px {shadow}; " # A credit line takes the block's bottom margin over, so the two read as one # figure: the same place an SVG draws its mark, which is the margin rather # than the page below it. block_margin = f"{margin}px" if watermark: block_margin = ( f"{margin}px {margin}px {max(margin // 4, WATERMARK_INSET // 2)}px" ) body = ( f'<pre style="background: {chrome}; color: {ink}; ' f"font-family: {font_stack}; line-height: 1.25; " f"margin: {block_margin}; padding: calc(1em + {padding}px); " f"{frame}border-radius: {radius}px; " f'overflow-x: auto">{ansi_to_html(escape(text, quote=False))}</pre>' ) if watermark: credit = credit_markup( watermark, watermark_url, _credit_link, escape=partial(escape, quote=False), ) body += ( f'\n<div style="margin: 0 {margin}px {margin}px; text-align: right; ' f"color: {watermark_color}; font-family: {font_stack}; " f'font-size: {WATERMARK_SIZE}px">{credit}</div>' ) page = "" if backdrop == NO_PAINT else f"background: {backdrop}; " if not full: # A fragment carries no page of its own, so a backdrop needs one. A # credit line needs nothing: a fragment is a run of markup, and the mark # is the second element of it. return f'<div style="{page}">{body}</div>' if page else body return ( "<!doctype html>\n" '<html lang="en">\n' "<head>\n" '<meta charset="utf-8">\n' f"<title>{escape(title, quote=False)}</title>\n" "</head>\n" f'<body style="{page}margin: 0">\n{body}\n</body>\n' "</html>\n" )