import copy
import enum
import hashlib
import os
import pathlib
import shutil
import subprocess
import sphinx
from docutils.nodes import (
Element,
FixedTextElement,
General,
SkipNode,
image,
literal_block,
)
from docutils.parsers.rst import Directive, directives
from sphinx.util import FilenameUniqDict, logging
from sphinx.util.console import brown # type: ignore
from sphinx.util.nodes import set_source_info
from sphinx.util.osutil import copyfile, ensuredir
from uqbar.book.extensions import Extension
from uqbar.strings import normalize
from .. import format as _format
from .. import lilypondfile as _lilypondfile
from .. import tag as _tag
from ..configuration import Configuration
from ..contextmanagers import TemporaryDirectoryChange
from ..illustrators import illustrate
from ..io import Illustrator, LilyPondIO, Player
configuration = Configuration()
logger = logging.getLogger(__name__)
[docs]class HiddenDoctestDirective(Directive):
"""
A hidden doctest directive.
Contributes no formatting to documents built by Sphinx.
"""
### CLASS VARIABLES ###
__documentation_ignore_inherited__ = True
has_content = True
required_arguments = 0
optional_arguments = 0
final_argument_whitespace = True
option_spec: dict[str, str] = {}
### PUBLIC METHODS ###
[docs] def run(self):
"""Executes the directive."""
self.assert_has_content()
return []
[docs]class ShellDirective(Directive):
"""
A shell directive.
Represents a shell session.
Generates a docutils ``literal_block`` node.
"""
### CLASS VARIABLES ###
__documentation_ignore_inherited__ = True
has_content = True
required_arguments = 0
optional_arguments = 0
final_argument_whitespace = False
option_spec: dict[str, str] = {}
### PUBLIC METHODS ###
[docs] def run(self):
self.assert_has_content()
result = []
with TemporaryDirectoryChange(configuration.abjad_directory):
cwd = pathlib.Path.cwd()
for line in self.content:
result.append(f"{cwd.name}$ {line}")
completed_process = subprocess.run(
line,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
result.append(completed_process.stdout)
code = "\n".join(result)
literal = literal_block(code, code)
literal["language"] = "console"
set_source_info(self, literal)
return [literal]
[docs]class ThumbnailDirective(Directive):
"""
A thumbnail directive.
"""
### CLASS VARIABLES ###
__documentation_ignore_inherited__ = True
final_argument_whitespace = True
has_content = False
option_spec = {
"class": directives.class_option,
"group": directives.unchanged,
"title": directives.unchanged,
}
optional_arguments = 0
required_arguments = 1
### PUBLIC METHODS ###
[docs] def run(self):
"""Executes the directive."""
node = thumbnail_block()
node["classes"] += self.options.get("class", "")
node["group"] = self.options.get("group", "")
node["title"] = self.options.get("title", "")
node["uri"] = self.arguments[0]
environment = self.state.document.settings.env
name, suffix = os.path.splitext(node["uri"])
environment.images.add_file(environment.docname, node["uri"])
thumbnail_uri = name + "-thumbnail" + suffix
environment.thumbnails.add_file(environment.docname, thumbnail_uri)
return [node]
[docs]class thumbnail_block(image, General, Element):
__documentation_ignore_inherited__ = True
[docs]def visit_thumbnail_block_html(self, node):
template = normalize(
"""
<a data-lightbox="{group}" href="{target_path}" title="{title}" data-title="{title}" class="{cls}">
<img src="{thumbnail_path}" alt="{alt}"/>
</a>
"""
)
title = node["title"]
classes = " ".join(node["classes"])
group = "group-{}".format(node["group"] if node["group"] else node["uri"])
if node["uri"] in self.builder.images:
node["uri"] = os.path.join(
self.builder.imgpath, self.builder.images[node["uri"]]
)
target_path = node["uri"]
prefix, suffix = os.path.splitext(target_path)
if suffix == ".svg":
thumbnail_path = target_path
else:
thumbnail_path = "{}-thumbnail{}".format(prefix, suffix)
output = template.format(
alt=title,
group=group,
target_path=target_path,
cls=classes,
thumbnail_path=thumbnail_path,
title=title,
)
self.body.append(output)
raise SkipNode
[docs]def visit_thumbnail_block_latex(self, node):
raise SkipNode
[docs]def on_builder_inited(app):
app.env.thumbnails = FilenameUniqDict() # separate so Sphinx doesn't purge it
install_lightbox_static_files(app)
(pathlib.Path(app.builder.outdir) / "_images").mkdir(exist_ok=True)
[docs]class LilyPondExtension(Extension):
class Kind(enum.Enum):
IMAGE = 1
AUDIO = 2
class lilypond_block(General, FixedTextElement):
pass
[docs] @classmethod
def setup_console(cls, console, monkeypatch):
monkeypatch.setattr(
Illustrator,
"__call__",
lambda self: console.push_proxy(
cls(
self.illustrable,
cls.Kind.IMAGE,
**{
key.replace("lilypond/", "").replace("-", "_"): value
for key, value in console.proxy_options.items()
if key.startswith("lilypond/")
},
),
),
)
monkeypatch.setattr(
Player,
"__call__",
lambda self: console.push_proxy(
cls(
self.illustrable,
cls.Kind.AUDIO,
**{
key.replace("lilypond/", "").replace("-", "_"): value
for key, value in console.proxy_options.items()
if key.startswith("lilypond/")
},
),
),
)
[docs] @classmethod
def setup_sphinx(cls, app):
app.add_node(
cls.lilypond_block,
html=[cls.visit_block_html, None],
latex=[cls.visit_block_latex, None],
text=[cls.visit_block_text, cls.depart_block_text],
)
cls.add_option("lilypond/no-trim", directives.flag)
cls.add_option("lilypond/pages", directives.unchanged)
cls.add_option("lilypond/with-columns", int)
def __init__(
self,
illustrable,
kind,
no_trim=None,
pages=None,
with_columns=None,
**keywords,
):
self.illustrable = copy.deepcopy(illustrable)
self.keywords = keywords
self.kind = kind
self.no_trim = no_trim
self.pages = pages
self.with_columns = with_columns
[docs] def to_docutils(self):
if isinstance(self.illustrable, _lilypondfile.LilyPondFile):
illustration = self.illustrable
else:
illustration = illustrate(self.illustrable, **self.keywords)
if self.kind == self.Kind.AUDIO:
block = _lilypondfile.Block("midi")
illustration["score"].items.append(block)
illustration.lilypond_version_token = r'\version "2.19.83"'
code = illustration._get_lilypond_format()
code = _format.remove_site_comments(code)
code = _tag.remove_tags(code)
node = self.lilypond_block(code, code)
node["kind"] = self.kind.name.lower()
node["no-trim"] = self.no_trim
node["pages"] = self.pages
node["with-columns"] = self.with_columns
return [node]
[docs] @staticmethod
def visit_block_html(self, node):
output_directory = pathlib.Path(self.builder.outdir) / "_images"
render_prefix = "lilypond-{}".format(
hashlib.sha256(node[0].encode()).hexdigest()
)
if node["kind"] == "audio":
flags = []
glob = f"{render_prefix}.mid*"
else:
flags = ["-dcrop", "-dbackend=svg"]
glob = f"{render_prefix}*.svg"
lilypond_io = LilyPondIO(
None,
flags=flags,
output_directory=output_directory,
render_prefix=render_prefix,
should_copy_stylesheets=True,
should_open=False,
should_persist_log=False,
string=node[0],
)
if not list(output_directory.glob(glob)):
_, _, _, success, log = lilypond_io()
if not success:
logger.warning(f"LilyPond render failed\n{log}", location=node)
source_path = (pathlib.Path(self.builder.imgpath) / render_prefix).with_suffix(
".ly"
)
if node["kind"] == "audio":
pass
else:
embed_images(self, node, output_directory, render_prefix, source_path)
raise SkipNode
table_row_open_template = '<div class="table-row">'
table_row_close_template = "</div>"
basic_image_template = normalize(
"""
<div class="uqbar-book">
<a href="{source_path}"><img src="{relative_path}"/></a>
</div>
"""
)
thumbnail_template = normalize(
"""
<a data-lightbox="{group}" href="{fullsize_path}" title="{title}" data-title="{title}" class="{cls}">
<img src="{thumbnail_path}" alt="{alt}"/>
</a>
"""
)
[docs]def embed_images(self, node, output_directory, render_prefix, source_path):
paths_to_embed = []
if node.get("pages"):
for page_spec in node["pages"].split(","):
page_spec = page_spec.strip()
if "-" in page_spec:
start_spec, _, stop_spec = page_spec.partition("-")
start_page, stop_page = int(start_spec), int(stop_spec) + 1
else:
start_page, stop_page = int(page_spec), int(page_spec) + 1
for page_number in range(start_page, stop_page):
for path in output_directory.glob(f"{render_prefix}-{page_number}.svg"):
paths_to_embed.append(path)
elif node.get("no-trim"):
for path in output_directory.glob(f"{render_prefix}.svg"):
paths_to_embed.append(path)
if not paths_to_embed:
page_count = len(list(output_directory.glob(f"{render_prefix}-*.svg")))
for page_number in range(1, page_count + 1):
for path in output_directory.glob(f"{render_prefix}-{page_number}.svg"):
paths_to_embed.append(path)
else:
for path in output_directory.glob(f"{render_prefix}.cropped.svg"):
paths_to_embed.append(path)
with_columns = node.get("with-columns")
if with_columns:
for i in range(0, len(paths_to_embed), with_columns):
self.body.append(table_row_open_template)
for path in paths_to_embed[i : i + with_columns]:
relative_path = pathlib.Path(self.builder.imgpath) / path.name
self.body.append(
thumbnail_template.format(
alt="",
cls="table-cell thumbnail",
group=f"group-{render_prefix}",
fullsize_path=relative_path,
thumbnail_path=relative_path,
title="",
)
)
self.body.append(table_row_close_template)
else:
for path in paths_to_embed:
relative_path = pathlib.Path(self.builder.imgpath) / path.name
self.body.append(
basic_image_template.format(
relative_path=relative_path, source_path=source_path
)
)
[docs]def install_lightbox_static_files(app):
source_static_path = os.path.join(app.builder.srcdir, "_static")
target_static_path = os.path.join(app.builder.outdir, "_static")
source_lightbox_path = os.path.join(source_static_path, "lightbox2")
target_lightbox_path = os.path.join(target_static_path, "lightbox2")
relative_file_paths = []
for root, _, file_names in os.walk(source_lightbox_path):
for file_name in file_names:
absolute_file_path = os.path.join(root, file_name)
relative_file_path = os.path.relpath(absolute_file_path, source_static_path)
relative_file_paths.append(relative_file_path)
if os.path.exists(target_lightbox_path):
shutil.rmtree(target_lightbox_path)
for relative_file_path in sphinx.util.status_iterator(
relative_file_paths,
"installing lightbox files... ",
brown,
len(relative_file_paths),
):
source_path = os.path.join(source_static_path, relative_file_path)
target_path = os.path.join(target_static_path, relative_file_path)
target_directory = os.path.dirname(target_path)
if not os.path.exists(target_directory):
ensuredir(target_directory)
copyfile(source_path, target_path)
if relative_file_path.endswith(".js"):
app.add_js_file(relative_file_path, defer="defer")
elif relative_file_path.endswith(".css"):
app.add_css_file(relative_file_path)
[docs]def on_html_collect_pages(app):
for path in sphinx.util.status_iterator(
app.env.thumbnails,
"copying gallery thumbnails...",
"brown",
len(app.env.thumbnails),
):
source_path = pathlib.Path(app.srcdir) / path
target_path = pathlib.Path(app.outdir) / "_images" / source_path.name
try:
shutil.copy(source_path, target_path)
except Exception:
logger.warning(f"Could not copy {source_path}")
return []
[docs]def setup(app):
app.connect("builder-inited", on_builder_inited)
app.connect("html-collect-pages", on_html_collect_pages)
app.add_css_file("abjad.css")
app.add_directive("docs", HiddenDoctestDirective)
app.add_directive("shell", ShellDirective)
app.add_directive("thumbnail", ThumbnailDirective)
app.add_js_file("copybutton.js", defer="defer")
app.add_js_file("ga.js")
app.add_node(
thumbnail_block,
html=[visit_thumbnail_block_html, None],
latex=[visit_thumbnail_block_latex, None],
)