Added binary file format

This commit is contained in:
2026-07-20 15:39:34 +02:00
parent 9ed8e371ef
commit 42bf09610b
13 changed files with 106 additions and 461 deletions

View File

@@ -162,6 +162,9 @@ runtime. A tiny generic prompt with one field and OK/Cancel may remain code-only
## Document and library files ## Document and library files
- Documents use the `bedit-document` JSON format. - Documents use the `bedit-document` JSON format.
- Documents can be stored as human-readable `.bedit.json`/`.json` through
`JsonDocumentSerializer`, or as compressed MessagePack `.beb` through
`BebDocumentSerializer`. UI document I/O dispatches via `DocumentSerializer`.
- `test.bedit.json` is a useful manually created example during development. - `test.bedit.json` is a useful manually created example during development.
- Library documents use the same recursive document model. - Library documents use the same recursive document model.
- Library parsing belongs in `core/libraries.py`; Qt change notifications belong - Library parsing belongs in `core/libraries.py`; Qt change notifications belong

View File

@@ -146,7 +146,7 @@ BEdit document used as a copy source. The built-in example defines A, B, and C.
- File → Close Document removes the active document and returns to an empty - File → Close Document removes the active document and returns to an empty
workspace. An open graph uses a light gray grid whose visible and snapping workspace. An open graph uses a light gray grid whose visible and snapping
spacing are configured separately. spacing are configured separately.
- Edit → Settings → Libraries accepts document files or folders of JSON files. - Edit → Settings → Libraries accepts JSON or `.beb` document files and folders.
Every component owns its ports, declarative icon, properties, and child graph: Every component owns its ports, declarative icon, properties, and child graph:

View File

@@ -9,6 +9,7 @@ description = "A starter Qt desktop application"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"msgpack>=1.0,<2",
"PySide6>=6.7,<7", "PySide6>=6.7,<7",
] ]
@@ -25,7 +26,7 @@ bedit = "bedit.gui.app:main"
where = ["src"] where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
bedit = ["data/libraries/*.json"] bedit = ["data/libraries/*.json", "data/libraries/*.beb"]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100

View File

@@ -1,8 +1,8 @@
import json
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from bedit.core.model import GraphDocument from bedit.core.model import GraphDocument
from bedit.core.serializer import DocumentSerializer
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -13,7 +13,7 @@ class LibraryDocument:
def bundled_library_path() -> Path: def bundled_library_path() -> Path:
return Path(__file__).resolve().parents[1] / "data" / "libraries" / "default.json" return Path(__file__).resolve().parents[1] / "data" / "libraries" / "default.beb"
def default_library_paths() -> list[str]: def default_library_paths() -> list[str]:
@@ -21,12 +21,12 @@ def default_library_paths() -> list[str]:
def load_library_file(path: Path) -> LibraryDocument: def load_library_file(path: Path) -> LibraryDocument:
with path.open(encoding="utf-8") as file: document = DocumentSerializer.load(path)
data = json.load(file)
document = GraphDocument.from_dict(data)
name = str(document.metadata.get("name") or path.stem) name = str(document.metadata.get("name") or path.stem)
return LibraryDocument(name, document, str(path)) return LibraryDocument(name, document, str(path))
def library_candidates(path: Path) -> list[Path]: def library_candidates(path: Path) -> list[Path]:
return sorted(path.glob("*.json")) if path.is_dir() else [path] if not path.is_dir():
return [path]
return sorted((*path.glob("*.json"), *path.glob("*.beb")))

View File

@@ -1,6 +1,9 @@
import json import json
import zlib
from pathlib import Path from pathlib import Path
import msgpack
from bedit.core.model import GraphDocument from bedit.core.model import GraphDocument
@@ -20,3 +23,54 @@ class JsonDocumentSerializer:
json.dump(document.to_dict(), file, indent=2) json.dump(document.to_dict(), file, indent=2)
file.write("\n") file.write("\n")
temporary_path.replace(path) temporary_path.replace(path)
class BebDocumentSerializer:
"""Compressed MessagePack serializer for the binary .beb format."""
MAGIC = b"BEB\x00"
VERSION = 1
@classmethod
def load(cls, path: Path) -> GraphDocument:
payload = path.read_bytes()
header = cls.MAGIC + bytes([cls.VERSION])
if not payload.startswith(header):
raise ValueError("This is not a supported BEdit binary document")
try:
data = msgpack.unpackb(zlib.decompress(payload[len(header) :]), raw=False)
except (ValueError, zlib.error, msgpack.exceptions.MsgpackException) as error:
raise ValueError("The BEdit binary document is damaged") from error
if not isinstance(data, dict):
raise ValueError("The BEdit binary document has an invalid root value")
return GraphDocument.from_dict(data)
@classmethod
def save(cls, document: GraphDocument, path: Path) -> None:
packed = msgpack.packb(document.to_dict(), use_bin_type=True)
payload = cls.MAGIC + bytes([cls.VERSION]) + zlib.compress(packed, level=9)
temporary_path = path.with_suffix(path.suffix + ".tmp")
temporary_path.write_bytes(payload)
temporary_path.replace(path)
class DocumentSerializer:
"""Select the document serializer from its file extension."""
@staticmethod
def load(path: Path) -> GraphDocument:
serializer = (
BebDocumentSerializer
if path.suffix.lower() == ".beb"
else JsonDocumentSerializer
)
return serializer.load(path)
@staticmethod
def save(document: GraphDocument, path: Path) -> None:
serializer = (
BebDocumentSerializer
if path.suffix.lower() == ".beb"
else JsonDocumentSerializer
)
serializer.save(document, path)

Binary file not shown.

View File

@@ -1,435 +0,0 @@
{
"format": "bedit-document",
"version": 1,
"metadata": {
"name": "Untitled"
},
"roots": [
{
"id": "7ad651ef-6f76-4064-81d3-3fd6dd4d2918",
"name": "gain",
"position": {
"x": 0.0,
"y": 0.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "port-df34ce84",
"name": "in",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 64.0,
"y": 64.0
}
},
"type": "signal",
"multipleConnections": false
}
],
"outputs": [
{
"id": "port-fe9e6486",
"name": "out",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 88.0,
"y": 40.0
}
},
"type": "signal",
"multipleConnections": false
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Text",
"size": {
"width": 128.0,
"height": 128.0
},
"elements": [
{
"type": "rectangle",
"x": 32.0,
"y": 32.0,
"width": 64.0,
"height": 64.0,
"fill": "#f4f4f4",
"stroke": "#303030",
"lineWidth": 1.5,
"lineStyle": "solid",
"cornerRadius": 5.0
},
{
"type": "text",
"x": 40.0,
"y": 40.0,
"width": 48.0,
"height": 48.0,
"text": "K",
"color": "#00007f",
"fontSize": 24.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#00007f",
"fill": "#ffffff"
}
]
},
"properties": {
"showName": false
},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": "out = k*in;",
"parameters": [
{
"id": "parameter-61a43a86",
"name": "k",
"type": "real",
"value": "1"
}
]
}
}
},
{
"id": "12b21b11-8828-4a90-a561-19491dc9632e",
"name": "differentiate",
"position": {
"x": 0.0,
"y": 0.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "port-1cbabc8f",
"name": "in",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 64.0,
"y": 64.0
}
},
"type": "signal",
"multipleConnections": false
}
],
"outputs": [
{
"id": "port-4eeea4e7",
"name": "out",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 88.0,
"y": 40.0
}
},
"type": "signal",
"multipleConnections": false
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Text",
"size": {
"width": 128.0,
"height": 128.0
},
"elements": [
{
"type": "rectangle",
"x": 32.0,
"y": 32.0,
"width": 64.0,
"height": 64.0,
"fill": "#f4f4f4",
"stroke": "#303030",
"lineWidth": 1.5,
"lineStyle": "solid",
"cornerRadius": 5.0
},
{
"type": "text",
"x": 40.0,
"y": 40.0,
"width": 48.0,
"height": 48.0,
"text": "d/dt",
"color": "#00007f",
"fontSize": 18.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#00007f",
"fill": "#ffffff"
}
]
},
"properties": {
"showName": false
},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": "initial out = initial;\nout = der(in);",
"parameters": [
{
"id": "parameter-331e38be",
"name": "initial",
"type": "real",
"value": "0"
}
]
}
}
},
{
"id": "afdc7696-622b-47e4-b2c3-28ec60786bf2",
"name": "integrate",
"position": {
"x": 0.0,
"y": 0.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "port-12903207",
"name": "in",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 64.0,
"y": 64.0
}
},
"type": "signal",
"multipleConnections": false
}
],
"outputs": [
{
"id": "port-b3370b1a",
"name": "out",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 88.0,
"y": 40.0
}
},
"type": "signal",
"multipleConnections": false
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Text",
"size": {
"width": 128.0,
"height": 128.0
},
"elements": [
{
"type": "rectangle",
"x": 32.0,
"y": 32.0,
"width": 64.0,
"height": 64.0,
"fill": "#f4f4f4",
"stroke": "#303030",
"lineWidth": 1.5,
"lineStyle": "solid",
"cornerRadius": 5.0
},
{
"type": "text",
"x": 64.0,
"y": 40.0,
"width": 28.0,
"height": 48.0,
"text": "dt",
"color": "#00007f",
"fontSize": 18.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#00007f",
"fill": "#ffffff"
},
{
"type": "text",
"x": 40.0,
"y": 32.0,
"width": 28.0,
"height": 54.0,
"fill": "none",
"stroke": "#00007f",
"lineWidth": 1.5,
"lineStyle": "none",
"text": "\u222b",
"fontSize": 32.0,
"color": "#00007f"
}
]
},
"properties": {
"showName": false
},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": "initial out = initial;\nder(out) = in;",
"parameters": [
{
"id": "parameter-6bdc1c76",
"name": "initial",
"type": "real",
"value": "0"
}
]
}
}
},
{
"id": "f6b8e5c9-4d13-4f7b-b574-e00ca96e42c3",
"name": "add",
"position": {
"x": 0.0,
"y": 0.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "port-c995845c",
"name": "in",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 64.0,
"y": 64.0
}
},
"type": "signal",
"multipleConnections": true
}
],
"outputs": [
{
"id": "port-a2f0dea6",
"name": "out",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 80.0,
"y": 48.0
}
},
"type": "signal",
"multipleConnections": false
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Text",
"size": {
"width": 128.0,
"height": 128.0
},
"elements": [
{
"type": "circle",
"x": 40.0,
"y": 40.0,
"width": 48.0,
"height": 48.0,
"fill": "#f4f4f4",
"stroke": "#303030",
"lineWidth": 1.5,
"lineStyle": "solid"
},
{
"type": "text",
"x": 48.0,
"y": 48.0,
"width": 32.0,
"height": 32.0,
"fill": "none",
"stroke": "#00007f",
"lineWidth": 1.5,
"lineStyle": "none",
"text": "+",
"fontSize": 18.0,
"color": "#00007f"
}
]
},
"properties": {
"showName": false
},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": "out = sum(in[i] for i in 1:in.N);",
"parameters": []
}
}
}
]
}

View File

@@ -40,7 +40,7 @@ from bedit.core.model import (
) )
from bedit.core.simulation import Simulation from bedit.core.simulation import Simulation
from bedit.core.port_types import PortTypeRegistry from bedit.core.port_types import PortTypeRegistry
from bedit.core.serializer import JsonDocumentSerializer from bedit.core.serializer import DocumentSerializer
class DocumentController(QObject): class DocumentController(QObject):
@@ -108,7 +108,7 @@ class DocumentController(QObject):
self.filePathChanged.emit(None) self.filePathChanged.emit(None)
def load(self, path: Path) -> None: def load(self, path: Path) -> None:
self.document = JsonDocumentSerializer.load(path) self.document = DocumentSerializer.load(path)
self.active_component_id = next(iter(self.document.roots), None) self.active_component_id = next(iter(self.document.roots), None)
self.file_path = path self.file_path = path
self.undo_stack.clear() self.undo_stack.clear()
@@ -124,7 +124,7 @@ class DocumentController(QObject):
target = path or self.file_path target = path or self.file_path
if target is None: if target is None:
raise ValueError("No file path has been selected") raise ValueError("No file path has been selected")
JsonDocumentSerializer.save(self.document, target) DocumentSerializer.save(self.document, target)
self.file_path = target self.file_path = target
self.undo_stack.setClean() self.undo_stack.setClean()
self.filePathChanged.emit(target) self.filePathChanged.emit(target)

View File

@@ -3,7 +3,7 @@ from pathlib import Path
from PySide6.QtCore import QSettings, Signal from PySide6.QtCore import QSettings, Signal
from PySide6.QtWidgets import QDialog, QFileDialog from PySide6.QtWidgets import QDialog, QFileDialog
from bedit.core.libraries import default_library_paths from bedit.core.libraries import bundled_library_path, default_library_paths
from bedit.gui.preferences import application_settings from bedit.gui.preferences import application_settings
from bedit.gui.generated.ui_settings_dialog import Ui_SettingsDialog from bedit.gui.generated.ui_settings_dialog import Ui_SettingsDialog
@@ -56,8 +56,22 @@ class SettingsDialog(QDialog):
settings = settings if settings is not None else application_settings() settings = settings if settings is not None else application_settings()
value = settings.value("libraries/paths", default_library_paths()) value = settings.value("libraries/paths", default_library_paths())
if isinstance(value, str): if isinstance(value, str):
return [value] paths = [value]
return [str(path) for path in value] else:
paths = [str(path) for path in value]
bundled = bundled_library_path()
migrated = [
str(bundled)
if not Path(path).exists()
and Path(path).parent == bundled.parent
and Path(path).suffix.lower() == ".json"
else path
for path in paths
]
if migrated != paths:
settings.setValue("libraries/paths", migrated)
settings.sync()
return migrated
@staticmethod @staticmethod
def graph_grid_size(settings: QSettings | None = None) -> int: def graph_grid_size(settings: QSettings | None = None) -> int:
@@ -79,7 +93,7 @@ class SettingsDialog(QDialog):
self, self,
"Add library", "Add library",
"", "",
"BEdit libraries (*.json);;All files (*)", "BEdit libraries (*.beb *.bedit.json *.json);;All files (*)",
) )
if path: if path:
self._append_unique_path(path) self._append_unique_path(path)

View File

@@ -176,7 +176,7 @@ class Ui_SettingsDialog(object):
self.iconGridLabel.setText(QCoreApplication.translate("SettingsDialog", u"Icon grid size:", None)) self.iconGridLabel.setText(QCoreApplication.translate("SettingsDialog", u"Icon grid size:", None))
self.iconGridSpinBox.setSuffix(QCoreApplication.translate("SettingsDialog", u" units", None)) self.iconGridSpinBox.setSuffix(QCoreApplication.translate("SettingsDialog", u" units", None))
self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.generalTab), QCoreApplication.translate("SettingsDialog", u"General", None)) self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.generalTab), QCoreApplication.translate("SettingsDialog", u"General", None))
self.libraryPathsLabel.setText(QCoreApplication.translate("SettingsDialog", u"Load library JSON files from these files or folders at startup:", None)) self.libraryPathsLabel.setText(QCoreApplication.translate("SettingsDialog", u"Load library JSON or BEdit Binary files from these files or folders at startup:", None))
self.addLibraryFileButton.setText(QCoreApplication.translate("SettingsDialog", u"Add File\u2026", None)) self.addLibraryFileButton.setText(QCoreApplication.translate("SettingsDialog", u"Add File\u2026", None))
self.addLibraryFolderButton.setText(QCoreApplication.translate("SettingsDialog", u"Add Folder\u2026", None)) self.addLibraryFolderButton.setText(QCoreApplication.translate("SettingsDialog", u"Add Folder\u2026", None))
self.removeLibraryPathButton.setText(QCoreApplication.translate("SettingsDialog", u"Remove", None)) self.removeLibraryPathButton.setText(QCoreApplication.translate("SettingsDialog", u"Remove", None))

View File

@@ -14,7 +14,7 @@ from PySide6.QtWidgets import (
from bedit.core.model import Component, Parameter from bedit.core.model import Component, Parameter
from bedit.core.application_log import get_logger from bedit.core.application_log import get_logger
from bedit.core.serializer import JsonDocumentSerializer from bedit.core.serializer import DocumentSerializer
from bedit.core.simulation import Simulation from bedit.core.simulation import Simulation
from bedit.gui.controllers.document import DocumentController from bedit.gui.controllers.document import DocumentController
from bedit.gui.dialogs.component_options import ComponentOptionsDialog from bedit.gui.dialogs.component_options import ComponentOptionsDialog
@@ -407,7 +407,11 @@ class MainWindow(QMainWindow):
if not self._resolve_source_edits() or not self._maybe_save(): if not self._resolve_source_edits() or not self._maybe_save():
return return
filename, _ = QFileDialog.getOpenFileName( filename, _ = QFileDialog.getOpenFileName(
self, "Open graph", "", "BEdit graphs (*.bedit.json *.json);;All files (*)" self,
"Open graph",
"",
"BEdit documents (*.bedit.json *.json *.beb);;"
"BEdit JSON (*.bedit.json *.json);;BEdit Binary (*.beb);;All files (*)",
) )
if not filename: if not filename:
return return
@@ -437,16 +441,21 @@ class MainWindow(QMainWindow):
def save_document_as(self) -> bool: def save_document_as(self) -> bool:
if self.document_controller.document is None: if self.document_controller.document is None:
return False return False
filename, _ = QFileDialog.getSaveFileName( filename, selected_filter = QFileDialog.getSaveFileName(
self, self,
"Save graph", "Save graph",
"untitled.bedit.json", "untitled.beb",
"BEdit graphs (*.bedit.json);;JSON files (*.json);;All files (*)", "BEdit Binary (*.beb);;BEdit JSON (*.bedit.json *.json);;All files (*)",
) )
if not filename: if not filename:
return False return False
path = Path(filename)
if path.suffix.lower() not in {".json", ".beb"}:
path = path.with_suffix(
".beb" if "Binary" in selected_filter else ".bedit.json"
)
try: try:
path = self.document_controller.save(Path(filename)) path = self.document_controller.save(path)
self.log.info("Saved document %s", path) self.log.info("Saved document %s", path)
except OSError as error: except OSError as error:
self.log.error("Could not save document %s: %s", filename, error) self.log.error("Could not save document %s: %s", filename, error)
@@ -557,7 +566,7 @@ class MainWindow(QMainWindow):
try: try:
if library is not None: if library is not None:
library.document.validate() library.document.validate()
JsonDocumentSerializer.save(library.document, Path(library.source_path)) DocumentSerializer.save(library.document, Path(library.source_path))
except (OSError, ValueError) as error: except (OSError, ValueError) as error:
component.inputs, component.outputs = old_inputs, old_outputs component.inputs, component.outputs = old_inputs, old_outputs
self.log.error("Could not change library ports: %s", error) self.log.error("Could not change library ports: %s", error)

View File

@@ -1,4 +1,3 @@
import json
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QObject, Signal from PySide6.QtCore import QObject, Signal
@@ -23,7 +22,7 @@ class LibraryRepository(QObject):
for candidate in library_candidates(path): for candidate in library_candidates(path):
try: try:
libraries.append(load_library_file(candidate)) libraries.append(load_library_file(candidate))
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as error: except (OSError, ValueError, KeyError, TypeError) as error:
warnings.append(f"{candidate}: {error}") warnings.append(f"{candidate}: {error}")
self.libraries = libraries self.libraries = libraries
self.load_warnings = warnings self.load_warnings = warnings

View File

@@ -94,7 +94,7 @@
<item> <item>
<widget class="QLabel" name="libraryPathsLabel"> <widget class="QLabel" name="libraryPathsLabel">
<property name="text"> <property name="text">
<string>Load library JSON files from these files or folders at startup:</string> <string>Load library JSON or BEdit Binary files from these files or folders at startup:</string>
</property> </property>
<property name="wordWrap"> <property name="wordWrap">
<bool>true</bool> <bool>true</bool>