from __future__ import annotations
import json
import os
import pathlib
from dataclasses import dataclass
from time import time
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Tuple
from warnings import warn
import img2pdf # type: ignore
from typing_extensions import TypeAlias
from neoscore.core.brush import Brush, BrushDef
from neoscore.core.color import Color, ColorDef
from neoscore.core.exceptions import InvalidImageFormatError
from neoscore.core.key_event import KeyEvent
from neoscore.core.mouse_event import MouseEvent
from neoscore.core.paper import A4, Paper
from neoscore.core.pen import Pen
from neoscore.core.point import Point, PointDef
from neoscore.core.propagating_thread import PropagatingThread
from neoscore.core.rect import RectDef
from neoscore.core.units import Unit
from neoscore.interface.app_interface import AppInterface
if TYPE_CHECKING:
from neoscore.core.document import Document
from neoscore.core.font import Font
"""The global application state module."""
default_font: Font
"""The default font to be used in ``Text`` objects."""
document: Document
"""The root document object."""
registered_music_fonts: Dict[str, dict] = {}
"""A map from registered music font names to SMuFL metadata"""
registered_font_family_names: Set[str] = set()
"""A set of family names of all registered fonts, including music fonts"""
background_brush = Brush("#ffffff")
"""The brush used to draw the scene background.
Defaults to solid white. Set this using :obj:`.set_background_brush`.
"""
app_interface: AppInterface
"""The underlying application interface.
You generally shouldn't directly interact with this.
"""
_display_page_geometry_in_refresh_func: bool = False
"""Whether to render the page geometry in refresh renders.
This global serves as a bit of a hack for passing the "display page geometry?" flag into
refresh functions.
"""
_must_clear_scene_before_next_render: bool = False
"""Whether the scene must be cleared before rendering.
When refresh functions indicate no re-render is required, that indication takes
precedence over this flag.
"""
_supported_image_extensions = {
".bmp",
".jpg",
".jpeg",
".png",
".pbm",
".pgm",
".ppm",
".xbm",
".xpm",
}
# Directories
_FONTS_DIR = pathlib.Path(__file__).parent / ".." / "resources" / "fonts"
# Text Font
_LORA_DIR = _FONTS_DIR / "lora"
_DEFAULT_LORA_FONT_FAMILY_NAME = "Lora"
_DEFAULT_LORA_FONT_SIZE = Unit(12)
_LORA_REGULAR_PATH = _LORA_DIR / "Lora-VariableFont_wght.ttf"
_LORA_ITALIC_PATH = _LORA_DIR / "Lora-Italic-VariableFont_wght.ttf"
# Music Text Font
_BRAVURA_DIR = _FONTS_DIR / "bravura"
_BRAVURA_PATH = _BRAVURA_DIR / "Bravura.otf"
_BRAVURA_METADATA_PATH = _BRAVURA_DIR / "bravura_metadata.json"
[docs]def setup(paper: Paper = A4):
"""Initialize the application and set up the global state.
This initializes the global ``Document`` and a back-end
AppInterface instance.
This should be called once at the beginning of every script using ``neoscore``;
calling this multiple times in one script will cause unexpected behavior.
Args:
paper: The paper to use in the document.
"""
global app_interface
global default_font
global document
global background_brush
# Some things are imported here to work around cyclic import problems
from neoscore.core.document import Document
from neoscore.core.font import Font
# Some guardrails against repeated `setup()` calls, which cause crashes.
try:
# If this is the first `setup()` call, referencing `document`
# here will raise a NameError
document
# If Neoscore was previously `setup()` then cleanly `shutdown()`,
# `document` will be `None`
if document is not None:
warn(
"`neoscore.setup()` was called but Neoscore is already running."
+ "To prevent unexpected behavior, only run initial setup once "
+ "or call `neoscore.shutdown()` first. "
+ "Skipping this call."
)
return
except NameError:
pass
document = Document(paper)
app_interface = AppInterface(
document, _repl_refresh_func, background_brush.interface, True
)
_register_default_fonts()
default_font = Font(
_DEFAULT_LORA_FONT_FAMILY_NAME, _DEFAULT_LORA_FONT_SIZE, 1, False
)
[docs]def set_default_color(color: ColorDef):
"""Set the default color used in unspecified pens and brushes.
This only affects objects created after this is called."""
# Objects using unspecified pens and brushes make copies of these
# global default objects.
c = Color.from_def(color)
Pen._default_color = c
Brush._default_color = c
[docs]def set_background_brush(brush: BrushDef):
"""Set the brush used to paint the scene background.
See :obj:`.background_brush`.
"""
global background_brush
global app_interface
background_brush = Brush.from_def(brush)
app_interface.background_brush = background_brush.interface
[docs]def register_font(font_file_path: str | pathlib.Path) -> List[str]:
"""Register a font file with the application.
If successful, this makes the font available for use in :obj:`.Font` objects,
to be referenced by the family name embedded in the font file.
Args:
font_file_path: A path to a font file. Only TrueType and OpenType
fonts are supported.
Returns:
A list of family names registered. Typically, this will have length 1.
Raises:
FontRegistrationError: If the font could not be loaded.
Typically, this is because the given path does not lead to
a valid font file.
"""
global registered_font_family_names
global app_interface
family_names = app_interface.register_font(font_file_path)
for name in family_names:
registered_font_family_names.add(name)
return family_names
[docs]def register_music_font(
font_file_path: str | pathlib.Path, metadata_path: str | pathlib.Path
):
"""Register a music font with the application.
Args:
font_file_path: A path to a font file.
metadata_path: A path to a SMuFL metadata JSON file
for this font. The standard SMuFL format for this file name
will be ``{lowercase_font_name}_metadata.json``.
Returns:
A list of family names registered. Typically, this will have length 1.
Raises:
FontRegistrationError: If the font could not be loaded.
Typically, this is because the given path does not lead to
a valid font file.
"""
global registered_music_fonts
family_names = register_font(font_file_path)
try:
with open(metadata_path, "r") as metadata_file:
metadata = json.load(metadata_file)
except FileNotFoundError:
raise FileNotFoundError(
"Music font metadata file {} could not be found".format(metadata_path)
)
except json.JSONDecodeError as e:
e.msg = "Invalid JSON metadata in music font " "metadata file {}".format(
metadata_path
)
raise e
name = family_names[0]
if len(family_names) > 1:
print(
f"Warning: music font at {font_file_path} contained more than 1 font "
+ f"family. SMuFL metadata will only be stored for {name}."
)
registered_music_fonts[name] = metadata
[docs]@dataclass
class RefreshFuncResult:
"""Results passed back to the neoscore runtime from refresh functions."""
scene_render_needed: bool = True
"""If True, neoscore will clear the scene and re-render it.
Refresh functions can set this to false to tell neoscore that no changes to the
scene were made during the refresh, and so re-rendering is not necessary. This is a
helpful optimization in situations where the scene does not change in every frame.
"""
RefreshFunc: TypeAlias = Callable[[float], Optional[RefreshFuncResult]]
"""A user-providable function for updating the scene every frame(ish).
The function should accept one argument - the current time in seconds.
Refresh functions can modify the scene, create new objects, and :obj:`remove
<.PositionedObject.remove>` them, though not all objects respond well to mutability.
The function may optionally return a :obj:`.RefreshFuncResult` to pass information back
to the neoscore runtime. If this is omitted, a default :obj:`.RefreshFuncResult` is
automatically returned.
"""
[docs]def show(
refresh_func: Optional[RefreshFunc] = None,
display_page_geometry=True,
auto_viewport_interaction_enabled=True,
min_window_size: Optional[Tuple[int, int]] = None,
max_window_size: Optional[Tuple[int, int]] = None,
fullscreen: bool = False,
):
"""Display the score in an interactive GUI window.
Args:
refresh_func: A scene update function to run on a timer approximating the
frame rate. This can also be set with :obj:`.set_refresh_func`, which
allows customizing the target frame rate.
display_page_geometry: Whether to include a preview of page geometry,
including a page outline and a dotted outline of the page's live
area inside its margins. This should not be used in combination with
a transparent :obj:`.background_brush`.
auto_viewport_interaction_enabled: Whether mouse and scrollbar viewport
interaction is enabled. If false, scrollbars do not appear, mousewheel
zooming is disabled, and click-and-drag view movement is disabled.
min_window_size: An optional ``(width, height)`` minimum window size tuple.
max_window_size: An optional ``(width, height)`` maximum window size tuple.
fullscreen: Whether to show the window in fullscreen mode.
This doesn't mix well with ``max_window_size``.
"""
global app_interface
global background_brush
global _display_page_geometry_in_refresh_func
_display_page_geometry_in_refresh_func = display_page_geometry
_render_document(display_page_geometry, background_brush)
if refresh_func:
set_refresh_func(refresh_func)
app_interface.auto_viewport_interaction_enabled = auto_viewport_interaction_enabled
app_interface.show(min_window_size, max_window_size, fullscreen)
def _render_document(display_page_geometry: bool, background_brush: Brush):
"""Render the document, clearing the scene before if needed.
This should be used instead of using ``document.render`` directly.
"""
global document
global app_interface
global _must_clear_scene_before_next_render
if _must_clear_scene_before_next_render:
app_interface.clear_scene()
for page in document.pages:
for obj in page.descendants:
interfaces = getattr(obj, "interfaces", None)
if interfaces:
interfaces.clear()
if hasattr(obj, "_interface_for_children"):
obj._interface_for_children = None
document.render(display_page_geometry, background_brush)
_must_clear_scene_before_next_render = True
[docs]def set_viewport_center_pos(document_pos: PointDef):
"""Center the interactive viewport at a given document-space position.
If the given position is such that the requested view goes beyond the document's
bounding rect, the actual position used is adjusted to prevent that. This is caused
by an internal limitation and may change in the future. To work around this issue,
you can expand the document bounding rect by creating an arbitrarily large invisible
path like so::
Path.rect((Mm(-1000000), Mm(-1000000)), None, Mm(2000000), Mm(2000000),
Brush.no_brush(), Pen.no_pen())
This is mostly recommended when not using automatic viewport interaction.
"""
global app_interface
app_interface.viewport_center_pos = Point.from_def(document_pos)
[docs]def get_viewport_center_pos() -> Point:
"""Return the interactive viewport's center position in document-space.
This value may differ slightly from values set in ``set_viewport_center_pos``.
"""
global app_interface
return app_interface.viewport_center_pos
[docs]def set_viewport_scale(scale: float):
"""Set the interactive viewport's scale (zoom).
Values should be greater than 0, with 1 as the base zoom and
larger numbers zooming in.
"""
global app_interface
app_interface.viewport_scale = scale
[docs]def get_viewport_scale() -> float:
"""Return the interactive viewport's scale (zoom).
This value is always greater than 0, with 1 as the base zoom and
larger numbers zooming in.
This value may differ slightly from values set in ``set_viewport_scale``.
"""
global app_interface
return app_interface.viewport_scale
[docs]def set_viewport_rotation(rotation: float):
"""Set the interactive viewport's rotation angle in degrees.
The viewport is rotated about its center.
"""
global app_interface
app_interface.viewport_rotation = rotation
[docs]def get_viewport_rotation() -> float:
"""Return the interactive viewport's rotation angle in degrees."""
global app_interface
return app_interface.viewport_rotation
[docs]def render_pdf(pdf_path: str | pathlib.Path, dpi: int = 300):
"""Render the score as a pdf.
Args:
pdf_path: The output pdf path
dpi: Resolution to render at
"""
global app_interface
global background_brush
_render_document(False, background_brush)
# Render all pages to temp files
page_imgs = []
render_threads = []
for page in document.pages:
img_buffer = bytearray()
page_imgs.append(img_buffer)
render_threads.append(
render_image(
page.document_space_bounding_rect,
img_buffer,
dpi,
preserve_alpha=False,
wait=False,
)
)
for thread in render_threads:
thread.join()
# Assemble into PDF and write it to file path
with open(pdf_path, "wb") as f:
f.write(img2pdf.convert([bytes(buf) for buf in page_imgs]))
[docs]def render_image(
rect: Optional[RectDef],
dest: str | pathlib.Path | bytearray,
dpi: int = 300,
quality: int = -1,
autocrop: bool = False,
preserve_alpha: bool = True,
wait: bool = True,
) -> PropagatingThread:
"""Render a section of the document to an image.
The following file extensions are supported:
* ``.bmp``
* ``.jpg``
* ``.png``
* ``.pbm``
* ``.pgm``
* ``.ppm``
* ``.xbm``
* ``.xpm``
If `wait == False`, this renders on the main thread but autocrops and saves the
image on a spawned thread which is returned to allow efficient rendering of many
images in parallel.
For a transparent background, use :obj:`.set_background_brush` to set the
background brush to any fully transparent color, for example
``neoscore.set_background_brush('#00000000')``. You'll also need to use an image
format that supports transparency like PNG, and set ``preserve_alpha=True``.
Args:
rect: The part of the document to render, in document coordinates.
If ``None``, the entire scene will be rendered.
dest: An output file path or a bytearray to save to. If a bytearray
is given, the output format will be PNG.
dpi: The pixels per inch of the rendered image. quality: The quality of the
output image for compressed
image formats. Must be either ``-1`` (default compression) or between ``0``
(most compressed) and ``100`` (least compressed).
autocrop: Whether to crop the output image to tightly
fit the contents of the frame.
preserve_alpha: Whether to preserve the alpha channel. This should be set ``false``
for export formats that don't support alpha.
wait: Whether to block until the image is fully exported.
Raises:
InvalidImageFormatError: If the given ``image_path`` does not have a
supported image format file extension.
ImageExportError: If low level Qt image export fails for
unknown reasons.
"""
global app_interface
global background_brush
if not ((0 <= quality <= 100) or quality == -1):
warn("render_image quality {} invalid; using default.".format(quality))
quality = -1
if (
not isinstance(dest, bytearray)
and not os.path.splitext(dest)[1] in _supported_image_extensions
):
raise InvalidImageFormatError(
"image_path {} is not in a supported format.".format(dest)
)
_render_document(False, background_brush)
thread = app_interface.render_image(
rect,
dest,
dpi,
quality,
background_brush.color,
autocrop,
preserve_alpha,
)
if wait:
thread.join()
return thread
[docs]def render_to_notebook(
rect: Optional[RectDef] = None,
dpi: int = 300,
autocrop: bool = True,
preserve_alpha: bool = False,
):
"""Render an image to a Jupyter Notebook environment.
This works mostly like :obj:`.render_image`, but instead of saving
a file it sends the image to the active notebook environment.
If there is no active environment, this may error or have no effect.
For convenience, some default arguments differ from ``render_image``
to align with what you usually want in a notebook.
:ref:`See the docs <jupyter integration>` for more information.
This functionality is experimental and subject to change.
"""
try:
from IPython.display import Image, display
except: # noqa
warn("Attempted to render to notebook, but IPython is not installed.")
return
buffer = bytearray()
thread = render_image(rect, buffer, dpi, -1, autocrop, preserve_alpha, True)
display(Image(data=buffer))
def _repl_refresh_func(_: float) -> float:
"""Default refresh func to be used in REPL mode.
Refreshes at a rate of 5 FPS.
"""
global background_brush
global _display_page_geometry_in_refresh_func
_render_document(_display_page_geometry_in_refresh_func, background_brush)
return 0.2
[docs]def set_refresh_func(refresh_func: RefreshFunc, target_fps: int = 60):
"""Update the global scene refresh function.
Args:
refresh_func: The new refresh function
target_fps: The requested frame rate to run the function at.
"""
global app_interface
global background_brush
global _display_page_geometry_in_refresh_func
frame_wait = 1 / target_fps
# Wrap the user-provided refresh function with code that clears
# the scene and re-renders it, then returns the requested delay
# before the next frame, calculated to automatically compensate
# for refresh time.
def wrapped_refresh_func(frame_time: float) -> float:
result = refresh_func(frame_time)
if result is None:
# Construct default result if none was provided
result = RefreshFuncResult()
if result.scene_render_needed:
_render_document(_display_page_geometry_in_refresh_func, background_brush)
elapsed_time = time() - frame_time
return max(frame_wait - elapsed_time, 0)
app_interface.set_refresh_func(wrapped_refresh_func)
[docs]def set_mouse_event_handler(handler: Callable[[MouseEvent], None]):
"""Set the global mouse event handler function.
Mouse events are fired on mouse moves and button presses, releases, and double
clicks.
Args:
handler: A callback function which accepts a mouse event.
See :obj:`.MouseEvent` for what event data is provided.
"""
global app_interface
app_interface.set_mouse_event_handler(handler)
[docs]def set_key_event_handler(handler: Callable[[KeyEvent], None]):
"""Set the global key event handler function.
Key events are fired on key presses, releases, and auto-repeats from being held.
Args:
handler: A callback function which accepts a key event.
See :obj:`.KeyEvent` for what event data is provided.
"""
global app_interface
app_interface.set_key_event_handler(handler)
def _register_default_fonts():
register_music_font(
_BRAVURA_PATH,
_BRAVURA_METADATA_PATH,
)
register_font(_LORA_REGULAR_PATH)
register_font(_LORA_ITALIC_PATH)
[docs]def shutdown():
"""Tear down the global state, including the document tree.
After running this, :obj:`.neoscore.setup` can be called again to start a new
document.
"""
global app_interface
global default_font
global document
app_interface.destroy()
app_interface = None
document = None
default_font = None