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 html import escape

from .screenshot_presets import (
    CAPTURE_FONT_STACK,
    CAPTURE_FOREGROUND,
    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_segments,
)
from .styling import ansi_to_html

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


[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, buttons: WindowButtons | None = None, buttons_color: str = CAPTURE_FOREGROUND, font_stack: str = CAPTURE_FONT_STACK, titlebar: str = NO_PAINT, collapse_titlebar: bool = False, 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 buttons: ignored. HTML reflows with the page embedding it, so it carries the text and its colors, not a window drawn around them. :param buttons_color: ignored, see `buttons`. :param titlebar: ignored, see `buttons`. :param collapse_titlebar: ignored, see `buttons`. :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 = escape(watermark, quote=False) segments = credit_segments(watermark) if watermark_url else None if segments: before, name, after = segments # 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. credit = ( f"{escape(before, quote=False)}" f'<a href="{escape(watermark_url)}" style="color: inherit">' f"{escape(name, quote=False)}</a>{escape(after, 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" )