nixos-render-docs: add image support

currently only supported for html. docbook could also support images,
but it's on the way out for manual generation anyway so we won't add
image support there. options docs can't use images because they also
target manpages, which leaves no viable users.

pennae 8c2d14a6 b962ff92

Changed files
+70 -8
pkgs
tools
nix
nixos-render-docs
+4
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/commonmark.py
···
def ordered_list_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
self._list_stack.pop()
return ""
+
def image(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+
if title := cast(str, token.attrs.get('title', '')):
+
title = ' "' + title.replace('"', '\\"') + '"'
+
return f'![{token.content}]({token.attrs["src"]}{title})'
+13
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/html.py
···
result += self._close_headings(None)
return result
+
def _pull_image(self, path: str) -> str:
+
raise NotImplementedError()
+
def text(self, token: Token, tokens: Sequence[Token], i: int) -> str:
return escape(token.content)
def paragraph_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
···
return '<p class="title"><strong>'
def example_title_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
return '</strong></p><div class="example-contents">'
+
def image(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+
src = self._pull_image(cast(str, token.attrs['src']))
+
alt = f'alt="{escape(token.content, True)}"' if token.content else ""
+
if title := cast(str, token.attrs.get('title', '')):
+
title = f'title="{escape(title, True)}"'
+
return (
+
'<div class="mediaobject">'
+
f'<img src="{escape(src, True)}" {alt} {title} />'
+
'</div>'
+
)
def _make_hN(self, level: int) -> tuple[str, str]:
return f"h{min(6, max(1, level + self._hlevel_offset))}", ""
+29 -7
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual.py
···
import argparse
+
import hashlib
import html
import json
import re
···
toc_depth: int
chunk_toc_depth: int
section_toc_depth: int
+
media_dir: Path
class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
_base_path: Path
+
_in_dir: Path
_html_params: HTMLParameters
def __init__(self, toplevel_tag: str, revision: str, html_params: HTMLParameters,
manpage_urls: Mapping[str, str], xref_targets: dict[str, XrefTarget],
-
base_path: Path):
+
in_dir: Path, base_path: Path):
super().__init__(toplevel_tag, revision, manpage_urls, xref_targets)
-
self._base_path, self._html_params = base_path, html_params
+
self._in_dir = in_dir
+
self._base_path = base_path.absolute()
+
self._html_params = html_params
+
+
def _pull_image(self, src: str) -> str:
+
src_path = Path(src)
+
content = (self._in_dir / src_path).read_bytes()
+
# images may be used more than once, but we want to store them only once and
+
# in an easily accessible (ie, not input-file-path-dependent) location without
+
# having to maintain a mapping structure. hashing the file and using the hash
+
# as both the path of the final image provides both.
+
content_hash = hashlib.sha3_256(content).hexdigest()
+
target_name = f"{content_hash}{src_path.suffix}"
+
target_path = self._base_path / self._html_params.media_dir / target_name
+
target_path.write_bytes(content)
+
return f"./{self._html_params.media_dir}/{target_name}"
def _push(self, tag: str, hlevel_offset: int) -> Any:
-
result = (self._toplevel_tag, self._headings, self._attrspans, self._hlevel_offset)
+
result = (self._toplevel_tag, self._headings, self._attrspans, self._hlevel_offset, self._in_dir)
self._hlevel_offset += hlevel_offset
self._toplevel_tag, self._headings, self._attrspans = tag, [], []
return result
def _pop(self, state: Any) -> None:
-
(self._toplevel_tag, self._headings, self._attrspans, self._hlevel_offset) = state
+
(self._toplevel_tag, self._headings, self._attrspans, self._hlevel_offset, self._in_dir) = state
def _render_book(self, tokens: Sequence[Token]) -> str:
assert tokens[4].children
···
# we do not set _hlevel_offset=0 because docbook doesn't either.
else:
inner = outer
+
in_dir = self._in_dir
for included, path in fragments:
try:
+
self._in_dir = (in_dir / path).parent
inner.append(self.render(included))
except Exception as e:
raise RuntimeError(f"rendering {path}") from e
···
# renderer not set on purpose since it has a dependency on the output path!
def convert(self, infile: Path, outfile: Path) -> None:
-
self._renderer = ManualHTMLRenderer('book', self._revision, self._html_params,
-
self._manpage_urls, self._xref_targets, outfile.parent)
+
self._renderer = ManualHTMLRenderer(
+
'book', self._revision, self._html_params, self._manpage_urls, self._xref_targets,
+
infile.parent, outfile.parent)
super().convert(infile, outfile)
def _parse(self, src: str) -> list[Token]:
···
p.add_argument('--toc-depth', default=1, type=int)
p.add_argument('--chunk-toc-depth', default=1, type=int)
p.add_argument('--section-toc-depth', default=0, type=int)
+
p.add_argument('--media-dir', default="media", type=Path)
p.add_argument('infile', type=Path)
p.add_argument('outfile', type=Path)
···
md = HTMLConverter(
args.revision,
HTMLParameters(args.generator, args.stylesheet, args.script, args.toc_depth,
-
args.chunk_toc_depth, args.section_toc_depth),
+
args.chunk_toc_depth, args.section_toc_depth, args.media_dir),
json.load(manpage_urls))
md.convert(args.infile, args.outfile)
+3
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py
···
"example_close": self.example_close,
"example_title_open": self.example_title_open,
"example_title_close": self.example_title_close,
+
"image": self.image,
}
self._admonitions = {
···
def example_title_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
raise RuntimeError("md token not supported", token)
def example_title_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+
raise RuntimeError("md token not supported", token)
+
def image(self, token: Token, tokens: Sequence[Token], i: int) -> str:
raise RuntimeError("md token not supported", token)
def _is_escaped(src: str, pos: int) -> bool:
+6
pkgs/tools/nix/nixos-render-docs/src/tests/test_commonmark.py
···
- *‌more stuff in same deflist‌*
   
foo""".replace(' ', ' ')
+
+
def test_images() -> None:
+
c = Converter({})
+
assert c._render("![*alt text*](foo \"title \\\"quoted\\\" text\")") == (
+
"![*alt text*](foo \"title \\\"quoted\\\" text\")"
+
)
+15 -1
pkgs/tools/nix/nixos-render-docs/src/tests/test_html.py
···
from sample_md import sample1
+
class Renderer(nrd.html.HTMLRenderer):
+
def _pull_image(self, src: str) -> str:
+
return src
+
class Converter(nrd.md.Converter[nrd.html.HTMLRenderer]):
def __init__(self, manpage_urls: dict[str, str], xrefs: dict[str, nrd.manual_structure.XrefTarget]):
super().__init__()
-
self._renderer = nrd.html.HTMLRenderer(manpage_urls, xrefs)
+
self._renderer = Renderer(manpage_urls, xrefs)
def unpretty(s: str) -> str:
return "".join(map(str.strip, s.splitlines())).replace('␣', ' ').replace('↵', '\n')
···
with pytest.raises(nrd.html.UnresolvedXrefError) as exc:
c._render("[](#baz)")
assert exc.value.args[0] == 'bad local reference, id #baz not known'
+
+
def test_images() -> None:
+
c = Converter({}, {})
+
assert c._render("![*alt text*](foo \"title text\")") == unpretty("""
+
<p>
+
<div class="mediaobject">
+
<img src="foo" alt="*alt text*" title="title text" />
+
</div>
+
</p>
+
""")
def test_full() -> None:
c = Converter({ 'man(1)': 'http://example.org' }, {})