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.
This commit is contained in:
@@ -184,3 +184,7 @@ class CommonMarkRenderer(Renderer):
|
|||||||
def ordered_list_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
|
def ordered_list_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
|
||||||
self._list_stack.pop()
|
self._list_stack.pop()
|
||||||
return ""
|
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''
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ class HTMLRenderer(Renderer):
|
|||||||
result += self._close_headings(None)
|
result += self._close_headings(None)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _pull_image(self, path: str) -> str:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
def text(self, token: Token, tokens: Sequence[Token], i: int) -> str:
|
def text(self, token: Token, tokens: Sequence[Token], i: int) -> str:
|
||||||
return escape(token.content)
|
return escape(token.content)
|
||||||
def paragraph_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
|
def paragraph_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
|
||||||
@@ -224,6 +227,16 @@ class HTMLRenderer(Renderer):
|
|||||||
return '<p class="title"><strong>'
|
return '<p class="title"><strong>'
|
||||||
def example_title_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
|
def example_title_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
|
||||||
return '</strong></p><div class="example-contents">'
|
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]:
|
def _make_hN(self, level: int) -> tuple[str, str]:
|
||||||
return f"h{min(6, max(1, level + self._hlevel_offset))}", ""
|
return f"h{min(6, max(1, level + self._hlevel_offset))}", ""
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
@@ -241,25 +242,42 @@ class HTMLParameters(NamedTuple):
|
|||||||
toc_depth: int
|
toc_depth: int
|
||||||
chunk_toc_depth: int
|
chunk_toc_depth: int
|
||||||
section_toc_depth: int
|
section_toc_depth: int
|
||||||
|
media_dir: Path
|
||||||
|
|
||||||
class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
|
class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
|
||||||
_base_path: Path
|
_base_path: Path
|
||||||
|
_in_dir: Path
|
||||||
_html_params: HTMLParameters
|
_html_params: HTMLParameters
|
||||||
|
|
||||||
def __init__(self, toplevel_tag: str, revision: str, html_params: HTMLParameters,
|
def __init__(self, toplevel_tag: str, revision: str, html_params: HTMLParameters,
|
||||||
manpage_urls: Mapping[str, str], xref_targets: dict[str, XrefTarget],
|
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)
|
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:
|
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._hlevel_offset += hlevel_offset
|
||||||
self._toplevel_tag, self._headings, self._attrspans = tag, [], []
|
self._toplevel_tag, self._headings, self._attrspans = tag, [], []
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _pop(self, state: Any) -> None:
|
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:
|
def _render_book(self, tokens: Sequence[Token]) -> str:
|
||||||
assert tokens[4].children
|
assert tokens[4].children
|
||||||
@@ -481,8 +499,10 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
|
|||||||
# we do not set _hlevel_offset=0 because docbook doesn't either.
|
# we do not set _hlevel_offset=0 because docbook doesn't either.
|
||||||
else:
|
else:
|
||||||
inner = outer
|
inner = outer
|
||||||
|
in_dir = self._in_dir
|
||||||
for included, path in fragments:
|
for included, path in fragments:
|
||||||
try:
|
try:
|
||||||
|
self._in_dir = (in_dir / path).parent
|
||||||
inner.append(self.render(included))
|
inner.append(self.render(included))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"rendering {path}") from e
|
raise RuntimeError(f"rendering {path}") from e
|
||||||
@@ -525,8 +545,9 @@ class HTMLConverter(BaseConverter[ManualHTMLRenderer]):
|
|||||||
# renderer not set on purpose since it has a dependency on the output path!
|
# renderer not set on purpose since it has a dependency on the output path!
|
||||||
|
|
||||||
def convert(self, infile: Path, outfile: Path) -> None:
|
def convert(self, infile: Path, outfile: Path) -> None:
|
||||||
self._renderer = ManualHTMLRenderer('book', self._revision, self._html_params,
|
self._renderer = ManualHTMLRenderer(
|
||||||
self._manpage_urls, self._xref_targets, outfile.parent)
|
'book', self._revision, self._html_params, self._manpage_urls, self._xref_targets,
|
||||||
|
infile.parent, outfile.parent)
|
||||||
super().convert(infile, outfile)
|
super().convert(infile, outfile)
|
||||||
|
|
||||||
def _parse(self, src: str) -> list[Token]:
|
def _parse(self, src: str) -> list[Token]:
|
||||||
@@ -687,6 +708,7 @@ def _build_cli_html(p: argparse.ArgumentParser) -> None:
|
|||||||
p.add_argument('--toc-depth', default=1, type=int)
|
p.add_argument('--toc-depth', default=1, type=int)
|
||||||
p.add_argument('--chunk-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('--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('infile', type=Path)
|
||||||
p.add_argument('outfile', type=Path)
|
p.add_argument('outfile', type=Path)
|
||||||
|
|
||||||
@@ -700,7 +722,7 @@ def _run_cli_html(args: argparse.Namespace) -> None:
|
|||||||
md = HTMLConverter(
|
md = HTMLConverter(
|
||||||
args.revision,
|
args.revision,
|
||||||
HTMLParameters(args.generator, args.stylesheet, args.script, args.toc_depth,
|
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))
|
json.load(manpage_urls))
|
||||||
md.convert(args.infile, args.outfile)
|
md.convert(args.infile, args.outfile)
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ class Renderer:
|
|||||||
"example_close": self.example_close,
|
"example_close": self.example_close,
|
||||||
"example_title_open": self.example_title_open,
|
"example_title_open": self.example_title_open,
|
||||||
"example_title_close": self.example_title_close,
|
"example_title_close": self.example_title_close,
|
||||||
|
"image": self.image,
|
||||||
}
|
}
|
||||||
|
|
||||||
self._admonitions = {
|
self._admonitions = {
|
||||||
@@ -225,6 +226,8 @@ class Renderer:
|
|||||||
raise RuntimeError("md token not supported", token)
|
raise RuntimeError("md token not supported", token)
|
||||||
def example_title_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
|
def example_title_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
|
||||||
raise RuntimeError("md token not supported", token)
|
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:
|
def _is_escaped(src: str, pos: int) -> bool:
|
||||||
found = 0
|
found = 0
|
||||||
|
|||||||
@@ -91,3 +91,9 @@ some nested anchors
|
|||||||
- *more stuff in same deflist*
|
- *more stuff in same deflist*
|
||||||
|
|
||||||
foo""".replace(' ', ' ')
|
foo""".replace(' ', ' ')
|
||||||
|
|
||||||
|
def test_images() -> None:
|
||||||
|
c = Converter({})
|
||||||
|
assert c._render("") == (
|
||||||
|
""
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import pytest
|
|||||||
|
|
||||||
from sample_md import sample1
|
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]):
|
class Converter(nrd.md.Converter[nrd.html.HTMLRenderer]):
|
||||||
def __init__(self, manpage_urls: dict[str, str], xrefs: dict[str, nrd.manual_structure.XrefTarget]):
|
def __init__(self, manpage_urls: dict[str, str], xrefs: dict[str, nrd.manual_structure.XrefTarget]):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._renderer = nrd.html.HTMLRenderer(manpage_urls, xrefs)
|
self._renderer = Renderer(manpage_urls, xrefs)
|
||||||
|
|
||||||
def unpretty(s: str) -> str:
|
def unpretty(s: str) -> str:
|
||||||
return "".join(map(str.strip, s.splitlines())).replace('␣', ' ').replace('↵', '\n')
|
return "".join(map(str.strip, s.splitlines())).replace('␣', ' ').replace('↵', '\n')
|
||||||
@@ -69,6 +73,16 @@ def test_xrefs() -> None:
|
|||||||
c._render("[](#baz)")
|
c._render("[](#baz)")
|
||||||
assert exc.value.args[0] == 'bad local reference, id #baz not known'
|
assert exc.value.args[0] == 'bad local reference, id #baz not known'
|
||||||
|
|
||||||
|
def test_images() -> None:
|
||||||
|
c = Converter({}, {})
|
||||||
|
assert c._render("") == unpretty("""
|
||||||
|
<p>
|
||||||
|
<div class="mediaobject">
|
||||||
|
<img src="foo" alt="*alt text*" title="title text" />
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
""")
|
||||||
|
|
||||||
def test_full() -> None:
|
def test_full() -> None:
|
||||||
c = Converter({ 'man(1)': 'http://example.org' }, {})
|
c = Converter({ 'man(1)': 'http://example.org' }, {})
|
||||||
assert c._render(sample1) == unpretty("""
|
assert c._render(sample1) == unpretty("""
|
||||||
|
|||||||
Reference in New Issue
Block a user