Source code for ggblab.construction

import base64
import zipfile
import json
import xml.etree.ElementTree as ET
import io
import os

from .schema import ggb_schema

[docs] class ggb_construction: """GeoGebra construction file (.ggb) loader and saver. Handles multiple file formats: - .ggb files (base64-encoded ZIP archives) - Plain ZIP archives - JSON format - Plain XML (geogebra.xml) The loader automatically detects file type from magic bytes and extracts the construction XML. The geogebra_xml is automatically stripped to the <construction> element and scientific notation is normalized. Attributes: ggb_schema: XML schema for validation source_file (str): Path to the loaded file base64_buffer (bytes): Base64-encoded .ggb archive (if applicable) geogebra_xml (str): Extracted construction XML Example: >>> construction = ggb_construction() >>> construction.load('myfile.ggb') >>> construction.save('output.ggb') """ def __init__(self): self.ggb_schema = ggb_schema().schema
[docs] def load(self, file): """Load a GeoGebra construction from file. Supports multiple formats: - Base64-encoded .ggb (starts with 'UEsD') - ZIP archive (starts with 'PK') - JSON format (starts with '{' or '[') - Plain XML The construction XML is automatically extracted and normalized: - Stripped to <construction> element only - Scientific notation fixed (e-1 → E-1) Args: file (str): Path to the .ggb, .zip, .json, or .xml file. Returns: ggb_construction: Self reference for method chaining. Raises: FileNotFoundError: If the file does not exist. RuntimeError: If file loading fails. Example: >>> c = ggb_construction().load('circle.ggb') >>> print(c.geogebra_xml[:100]) """ self.source_file = file self.base64_buffer = None self.geogebra_xml = None try: with open(self.source_file, 'rb') as f: def unzip(buff): with zipfile.ZipFile(io.BytesIO(base64.b64decode(buff)), 'r') as zf: # for fileinfo in zf.infolist(): # print(fileinfo) with zf.open('geogebra.xml', 'r') as zff: try: s = zff.read() except: pass return s match tuple(f.read(4).decode()): case ('U', 'E', 's', 'D'): # base64 encoded zip f.close() with open(self.source_file, 'rb') as f2: self.base64_buffer = f2.read() # base64.b64decode(f2.read()) self.geogebra_xml = unzip(self.base64_buffer) case ('P', 'K', _, _): # zip f.close() with open(self.source_file, 'rb') as f2: # b64encode for sending GeoGebra Applet self.base64_buffer = base64.b64encode(f2.read()) self.geogebra_xml = unzip(self.base64_buffer) case ('{', _, _, _) | ('[', _, _, _): # json f.close() with open(self.source_file, 'r', encoding='utf-8') as f2: self.base64_buffer = json.load(f2) for f in self.base64_buffer['archive']: if f['fileName'] == 'geogebra.xml': self.geogebra_xml = f['fileContent'] case _: # xml? with open(self.source_file, 'r', encoding='utf-8') as f2: self.geogebra_xml = f2.read() # return self.initialize_dataframe(file) except FileNotFoundError: raise FileNotFoundError(f"File not found: {self.source_file}") except Exception as e: raise RuntimeError(f"Failed to load the file: {e}") # strip to construction element and fix scientific notation self.geogebra_xml = (ET.tostring(ET.fromstring(self.geogebra_xml) .find('./construction'), encoding='unicode') .replace('e-1', 'E-1')) return self
[docs] def save(self, overwrite=False, file=None): """Save the construction to a file. Saving behavior: - If base64_buffer is set: writes decoded archive (.ggb format) - If base64_buffer is None: writes plain XML (geogebra_xml) - Target extension does not enforce format (e.g., saving to .ggb with no base64_buffer will write plain XML bytes) Args: overwrite (bool): If True, overwrite source_file. Defaults to False. file (str, optional): Target file path. If None, auto-generates next available filename (name_1.ggb, name_2.ggb, ...). Returns: ggb_construction: Self reference for method chaining. Example: >>> c = ggb_construction().load('circle.ggb') >>> c.save() # Saves to circle_1.ggb >>> c.save(overwrite=True) # Overwrites circle.ggb >>> c.save(file='output.ggb') # Saves to output.ggb Note: getBase64() from the applet may not include non-XML artifacts (thumbnails, etc.) from the original archive. Saving after API changes produces a leaner .ggb file. """ def get_next_revised_filename(filename): """ Generates the next available non-existing filename by appending '_1', '_2', etc. before the file extension. """ if not os.path.exists(filename): return filename root, ext = os.path.splitext(filename) i = 1 new_filename = f"{root}_{i}{ext}" while os.path.exists(new_filename): i += 1 new_filename = f"{root}_{i}{ext}" return new_filename if file is None: if overwrite: file = self.source_file else: file = get_next_revised_filename(self.source_file) with open(file, 'wb') as f: if self.base64_buffer is not None: f.write(base64.b64decode(self.base64_buffer)) else: f.write(self.geogebra_xml.encode('utf-8')) return self