Source code for neoscore.core.neoscore

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