diff --git a/.github/workflows/prototype-prerender.yml b/.github/workflows/prototype-prerender.yml new file mode 100644 index 0000000..de39cab --- /dev/null +++ b/.github/workflows/prototype-prerender.yml @@ -0,0 +1,30 @@ +name: prototype prerender tests + +on: + push: + branches: + - prototype-stupid-prerenderers + pull_request: + paths: + - 'prototype/**' + - '.github/workflows/prototype-prerender.yml' + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run reference pre-renderer tests + run: python3 prototype/test_prerender.py + - name: Generate sample first paint + run: | + python3 prototype/prerender.py prototype/example.html \ + --source https://example.test/ \ + --out preview-out + ls -lh preview-out + - name: Retain sample preview + uses: actions/upload-artifact@v4 + with: + name: stupid-prerender-sample + path: preview-out/ diff --git a/prototype/.gitignore b/prototype/.gitignore new file mode 100644 index 0000000..e645b44 --- /dev/null +++ b/prototype/.gitignore @@ -0,0 +1,2 @@ +preview-out/ +*.pbm diff --git a/prototype/NOTES.md b/prototype/NOTES.md new file mode 100644 index 0000000..39c6c14 --- /dev/null +++ b/prototype/NOTES.md @@ -0,0 +1,12 @@ +# Notes + +This prototype is intentionally stupid. + +It exists to answer four questions before ib chooses its implementation language or real renderer: + +1. Can first paint be produced from browser-owned bytes without starting a full renderer? +2. Can the first paint stay small enough to cache for many sleeping tabs? +3. Can previews be deleted and regenerated without changing durable browsing state? +4. Can the UI progressively ask for metadata, text, one visual screen, more visual screens, and finally a live renderer instead of loading everything at once? + +The answer should remain yes even if this Python prototype is thrown away completely. diff --git a/prototype/README.md b/prototype/README.md new file mode 100644 index 0000000..8ec27ed --- /dev/null +++ b/prototype/README.md @@ -0,0 +1,32 @@ +# Deliberately stupid pre-renderers + +This directory is an executable design probe for the browser-core/renderer boundary. It is not a choice of implementation language and it is not intended to grow into the real renderer. + +`prerender.py` accepts already-fetched HTML and emits two disposable forms of first paint: + +- `preview.txt`: title, source URL/label, and visible-ish text, capped at 8 KiB by default. +- `screen-01.pbm` through `screen-03.pbm`: at most three 180×320 one-bit rough visual previews. Each default PBM is about 7.2 KiB. + +The bitmap preview is deliberately not a screenshot of a fully rendered web page. It ignores CSS, JavaScript, images, fonts, layout, forms, media, and subresources. It rasterizes extracted text with a tiny built-in 5×7 font. Its purpose is to test whether ib can show something recognizable immediately while the real renderer is absent, sleeping, loading, crashed, or being replaced. + +## Try it + +```sh +python3 prototype/prerender.py page.html \ + --source https://example.org/ \ + --out /tmp/ib-preview +``` + +The input may also be piped on stdin by using `-` as the input path. + +## Tests + +```sh +python3 prototype/test_prerender.py +``` + +The tests check that scripts/styles are excluded, text output remains bounded, visual previews stay below 8 KiB per default screen, and visual output is capped at three screens. + +## Architectural rule being tested + +A pre-renderer consumes browser-owned fetched/snapshot material and produces a disposable preview. Neither `preview.txt` nor the PBM files are canonical browsing state. Deleting all of them must not delete a tab, a history entry, a snapshot, or user organization. diff --git a/prototype/example.html b/prototype/example.html new file mode 100644 index 0000000..b186914 --- /dev/null +++ b/prototype/example.html @@ -0,0 +1,21 @@ + + +
+ +The text preview is intentionally boring. It exists so a sleeping tab can show recognizable content without waking Chromium, Servo, WebView, or anything else.
+The bitmap version is only a tiny monochrome approximation of this text. A real renderer may replace it whenever it becomes available.
+ +This paragraph is repeated to make scrolling through a rough preview meaningful. The preview can contain one, two, or three phone-sized images and no more.
+This paragraph is repeated to make scrolling through a rough preview meaningful. The preview can contain one, two, or three phone-sized images and no more.
+This paragraph is repeated to make scrolling through a rough preview meaningful. The preview can contain one, two, or three phone-sized images and no more.
+This paragraph is repeated to make scrolling through a rough preview meaningful. The preview can contain one, two, or three phone-sized images and no more.
+This paragraph is repeated to make scrolling through a rough preview meaningful. The preview can contain one, two, or three phone-sized images and no more.
+This paragraph is repeated to make scrolling through a rough preview meaningful. The preview can contain one, two, or three phone-sized images and no more.
+ + diff --git a/prototype/prerender.py b/prototype/prerender.py new file mode 100644 index 0000000..73e5042 --- /dev/null +++ b/prototype/prerender.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Tiny reference pre-renderers for ib. + +This is an executable design probe, not a choice of implementation language or +real rendering engine. It turns already-fetched HTML into: + +* preview.txt: a small plaintext first paint +* screen-XX.pbm: up to three 1-bit, phone-shaped, text-derived rough previews + +No CSS, JavaScript, images, fonts, forms, cookies, or subresources are used. +""" + +from __future__ import annotations + +import argparse +import html as html_module +from html.parser import HTMLParser +from pathlib import Path +import re +import sys +from typing import Iterable + + +DEFAULT_WIDTH = 180 +DEFAULT_HEIGHT = 320 +DEFAULT_SCREENS = 3 +DEFAULT_TEXT_BYTES = 8 * 1024 + + +class VisibleTextParser(HTMLParser): + """Extract a title and boring visible-ish text from HTML.""" + + SKIP = {"script", "style", "noscript", "template", "svg", "canvas"} + BREAK = { + "address", "article", "aside", "blockquote", "br", "div", "dl", "dt", + "dd", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", + "h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav", "ol", + "p", "pre", "section", "table", "tr", "ul", + } + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.skip_depth = 0 + self.in_title = False + self.title_parts: list[str] = [] + self.parts: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + tag = tag.lower() + if tag in self.SKIP: + self.skip_depth += 1 + return + if tag == "title": + self.in_title = True + return + if self.skip_depth: + return + if tag in self.BREAK: + self.parts.append("\n") + if tag == "img": + alt = next((value for key, value in attrs if key.lower() == "alt"), None) + if alt: + self.parts.append(f" [{alt}] ") + + def handle_endtag(self, tag: str) -> None: + tag = tag.lower() + if tag in self.SKIP: + if self.skip_depth: + self.skip_depth -= 1 + return + if tag == "title": + self.in_title = False + return + if not self.skip_depth and tag in self.BREAK: + self.parts.append("\n") + + def handle_data(self, data: str) -> None: + if self.in_title: + self.title_parts.append(data) + elif not self.skip_depth: + self.parts.append(data) + + +SPACE_RUN = re.compile(r"[ \t\f\v]+") +BLANK_RUN = re.compile(r"\n{3,}") + + +def clean_text(parts: Iterable[str]) -> str: + raw = html_module.unescape("".join(parts)).replace("\r", "") + lines = [SPACE_RUN.sub(" ", line).strip() for line in raw.split("\n")] + text = "\n".join(line for line in lines if line) + return BLANK_RUN.sub("\n\n", text).strip() + + +def extract(html: str) -> tuple[str, str]: + parser = VisibleTextParser() + parser.feed(html) + parser.close() + title = SPACE_RUN.sub(" ", " ".join(parser.title_parts)).strip() + return title, clean_text(parser.parts) + + +def utf8_prefix(text: str, byte_limit: int) -> str: + if byte_limit <= 0: + return "" + encoded = text.encode("utf-8") + if len(encoded) <= byte_limit: + return text + clipped = encoded[:byte_limit] + while clipped: + try: + return clipped.decode("utf-8") + "\n[… clipped …]" + except UnicodeDecodeError: + clipped = clipped[:-1] + return "[… clipped …]" + + +def make_text_preview(title: str, source: str, body: str, byte_limit: int) -> str: + header = [] + if title: + header.append(title) + if source: + header.append(source) + if header and body: + header.append("") + result = "\n".join(header) + body if not header else "\n".join(header + ([body] if body else [])) + return utf8_prefix(result.strip() + "\n", byte_limit) + + +# Five-by-seven uppercase bitmap font. Lowercase is intentionally mapped to +# uppercase: this renderer is for recognizable rough previews, not typography. +FONT: dict[str, tuple[str, ...]] = { + " ": ("00000",) * 7, + "A": ("01110","10001","10001","11111","10001","10001","10001"), + "B": ("11110","10001","10001","11110","10001","10001","11110"), + "C": ("01111","10000","10000","10000","10000","10000","01111"), + "D": ("11110","10001","10001","10001","10001","10001","11110"), + "E": ("11111","10000","10000","11110","10000","10000","11111"), + "F": ("11111","10000","10000","11110","10000","10000","10000"), + "G": ("01111","10000","10000","10111","10001","10001","01111"), + "H": ("10001","10001","10001","11111","10001","10001","10001"), + "I": ("11111","00100","00100","00100","00100","00100","11111"), + "J": ("00111","00010","00010","00010","10010","10010","01100"), + "K": ("10001","10010","10100","11000","10100","10010","10001"), + "L": ("10000","10000","10000","10000","10000","10000","11111"), + "M": ("10001","11011","10101","10101","10001","10001","10001"), + "N": ("10001","11001","10101","10011","10001","10001","10001"), + "O": ("01110","10001","10001","10001","10001","10001","01110"), + "P": ("11110","10001","10001","11110","10000","10000","10000"), + "Q": ("01110","10001","10001","10001","10101","10010","01101"), + "R": ("11110","10001","10001","11110","10100","10010","10001"), + "S": ("01111","10000","10000","01110","00001","00001","11110"), + "T": ("11111","00100","00100","00100","00100","00100","00100"), + "U": ("10001","10001","10001","10001","10001","10001","01110"), + "V": ("10001","10001","10001","10001","10001","01010","00100"), + "W": ("10001","10001","10001","10101","10101","10101","01010"), + "X": ("10001","10001","01010","00100","01010","10001","10001"), + "Y": ("10001","10001","01010","00100","00100","00100","00100"), + "Z": ("11111","00001","00010","00100","01000","10000","11111"), + "0": ("01110","10001","10011","10101","11001","10001","01110"), + "1": ("00100","01100","00100","00100","00100","00100","01110"), + "2": ("01110","10001","00001","00010","00100","01000","11111"), + "3": ("11110","00001","00001","01110","00001","00001","11110"), + "4": ("00010","00110","01010","10010","11111","00010","00010"), + "5": ("11111","10000","10000","11110","00001","00001","11110"), + "6": ("01110","10000","10000","11110","10001","10001","01110"), + "7": ("11111","00001","00010","00100","01000","01000","01000"), + "8": ("01110","10001","10001","01110","10001","10001","01110"), + "9": ("01110","10001","10001","01111","00001","00001","01110"), + ".": ("00000","00000","00000","00000","00000","01100","01100"), + ",": ("00000","00000","00000","00000","01100","01100","01000"), + ":": ("00000","01100","01100","00000","01100","01100","00000"), + ";": ("00000","01100","01100","00000","01100","01100","01000"), + "!": ("00100","00100","00100","00100","00100","00000","00100"), + "?": ("01110","10001","00001","00010","00100","00000","00100"), + "-": ("00000","00000","00000","11111","00000","00000","00000"), + "_": ("00000","00000","00000","00000","00000","00000","11111"), + "/": ("00001","00010","00010","00100","01000","01000","10000"), + "\\": ("10000","01000","01000","00100","00010","00010","00001"), + "(": ("00010","00100","01000","01000","01000","00100","00010"), + ")": ("01000","00100","00010","00010","00010","00100","01000"), + "[": ("01110","01000","01000","01000","01000","01000","01110"), + "]": ("01110","00010","00010","00010","00010","00010","01110"), + "'": ("00100","00100","00000","00000","00000","00000","00000"), + '"': ("01010","01010","00000","00000","00000","00000","00000"), + "@": ("01110","10001","10111","10101","10111","10000","01110"), + "#": ("01010","01010","11111","01010","11111","01010","01010"), + "%": ("11001","11010","00100","01000","10110","00110","00000"), + "&": ("01100","10010","10100","01000","10101","10010","01101"), + "+": ("00000","00100","00100","11111","00100","00100","00000"), + "=": ("00000","11111","00000","11111","00000","00000","00000"), + "<": ("00010","00100","01000","10000","01000","00100","00010"), + ">": ("01000","00100","00010","00001","00010","00100","01000"), +} + + +def glyph(ch: str) -> tuple[str, ...]: + ch = ch.upper() + return FONT.get(ch, FONT["?"]) + + +def wrap_lines(text: str, columns: int) -> list[str]: + if columns < 1: + return [] + output: list[str] = [] + for paragraph in text.splitlines(): + words = paragraph.split() + if not words: + output.append("") + continue + line = "" + for word in words: + while len(word) > columns: + if line: + output.append(line) + line = "" + output.append(word[:columns]) + word = word[columns:] + candidate = word if not line else line + " " + word + if len(candidate) <= columns: + line = candidate + else: + output.append(line) + line = word + if line: + output.append(line) + return output + + +def set_black(bitmap: bytearray, row_bytes: int, x: int, y: int) -> None: + index = y * row_bytes + x // 8 + bitmap[index] |= 1 << (7 - (x % 8)) + + +def draw_line(bitmap: bytearray, width: int, height: int, x: int, y: int, text: str) -> None: + row_bytes = (width + 7) // 8 + cursor = x + for ch in text: + for gy, pattern in enumerate(glyph(ch)): + py = y + gy + if py >= height: + break + for gx, bit in enumerate(pattern): + px = cursor + gx + if bit == "1" and 0 <= px < width: + set_black(bitmap, row_bytes, px, py) + cursor += 6 + if cursor + 5 > width: + break + + +def render_pbm_pages(text: str, width: int, height: int, max_screens: int) -> list[bytes]: + margin_x = 6 + margin_y = 8 + char_width = 6 + line_height = 8 + columns = max(1, (width - 2 * margin_x) // char_width) + rows = max(1, (height - 2 * margin_y) // line_height) + lines = wrap_lines(text, columns) + pages: list[bytes] = [] + row_bytes = (width + 7) // 8 + + for screen in range(max_screens): + page_lines = lines[screen * rows:(screen + 1) * rows] + if not page_lines: + break + bitmap = bytearray(row_bytes * height) + for row, line in enumerate(page_lines): + draw_line(bitmap, width, height, margin_x, margin_y + row * line_height, line) + header = f"P4\n{width} {height}\n".encode("ascii") + pages.append(header + bytes(bitmap)) + return pages + + +def read_input(path: str) -> str: + if path == "-": + return sys.stdin.read() + return Path(path).read_text(encoding="utf-8", errors="replace") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input", help="already-fetched HTML file, or - for stdin") + parser.add_argument("--source", default="", help="URL/label to display in the text preview") + parser.add_argument("--out", type=Path, default=Path("preview-out")) + parser.add_argument("--width", type=int, default=DEFAULT_WIDTH) + parser.add_argument("--height", type=int, default=DEFAULT_HEIGHT) + parser.add_argument("--screens", type=int, default=DEFAULT_SCREENS) + parser.add_argument("--text-bytes", type=int, default=DEFAULT_TEXT_BYTES) + args = parser.parse_args() + + if args.width < 32 or args.height < 32: + parser.error("preview dimensions must be at least 32x32") + if not 1 <= args.screens <= 3: + parser.error("--screens must be between 1 and 3") + if args.text_bytes < 128: + parser.error("--text-bytes must be at least 128") + + html = read_input(args.input) + title, body = extract(html) + text_preview = make_text_preview(title, args.source, body, args.text_bytes) + + visual_source = "\n".join(part for part in (title, args.source, "", body) if part) + pages = render_pbm_pages(visual_source, args.width, args.height, args.screens) + + args.out.mkdir(parents=True, exist_ok=True) + (args.out / "preview.txt").write_text(text_preview, encoding="utf-8") + for index, page in enumerate(pages, start=1): + (args.out / f"screen-{index:02d}.pbm").write_bytes(page) + + print(f"text: {args.out / 'preview.txt'} ({len(text_preview.encode('utf-8'))} bytes)") + for index, page in enumerate(pages, start=1): + print(f"screen {index}: {args.out / f'screen-{index:02d}.pbm'} ({len(page)} bytes)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/prototype/preview-contract.md b/prototype/preview-contract.md new file mode 100644 index 0000000..6cf1194 --- /dev/null +++ b/prototype/preview-contract.md @@ -0,0 +1,34 @@ +# Preview cache contract + +The pre-renderers in this directory are disposable producers. Their outputs are cache entries, never canonical browser state. + +For one history entry the browser core may retain zero or more preview representations: + +```text +text/plain-preview +image/x-portable-bitmap-preview; screen=1 +image/x-portable-bitmap-preview; screen=2 +image/x-portable-bitmap-preview; screen=3 +``` + +Each preview records or is keyed by: + +- history-entry id +- source snapshot/content hash when available +- pre-renderer id and version +- viewport class or exact preview dimensions for visual output +- creation time + +A preview is stale when its source snapshot/content hash changes. It may still be displayed as a stale placeholder if the UI marks that fact and immediately allows navigation away. + +Deleting every preview must leave tabs, history, snapshots, labels, collections, and URLs intact. + +The UI must not require all previews to be loaded to enumerate tabs. A preview is fetched only when a tab/card/view actually needs it. + +A sleeping tab therefore has no live renderer requirement. Its first paint can be, in increasing cost order: + +1. URL/title metadata already present in an index or tab record. +2. `preview.txt`, if cached. +3. one PBM preview screen, if cached. +4. additional PBM screens on demand. +5. a real renderer session only when interaction or fidelity requires it. diff --git a/prototype/test_prerender.py b/prototype/test_prerender.py new file mode 100644 index 0000000..6a1af1c --- /dev/null +++ b/prototype/test_prerender.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 + +import tempfile +from pathlib import Path +import unittest + +import prerender + + +class PreRendererTests(unittest.TestCase): + def test_extracts_visible_text_but_not_script_or_style(self) -> None: + html = """ + +Useful words.
+" + ("words " * 2000) + "
" + title, body = prerender.extract(html) + text = prerender.make_text_preview(title, "https://example.test/", body, 8192) + pages = prerender.render_pbm_pages(title + "\n" + body, 180, 320, 3) + self.assertLess(len(text.encode("utf-8")), 8300) + self.assertLessEqual(sum(map(len, pages)), 24 * 1024) + + +if __name__ == "__main__": + unittest.main()