diff --git a/packages/ducpy/docs/conf.py b/packages/ducpy/docs/conf.py index 1d9f49a..016ff78 100644 --- a/packages/ducpy/docs/conf.py +++ b/packages/ducpy/docs/conf.py @@ -26,6 +26,7 @@ html_theme = "furo" html_static_path = ['_static'] +html_extra_path = ['extra'] html_baseurl = "https://ducflair.github.io/duc/reference/python/" extensions.append('autoapi.extension') diff --git a/packages/ducpy/docs/examples.rst b/packages/ducpy/docs/examples.rst index 6cb104e..870cb19 100644 --- a/packages/ducpy/docs/examples.rst +++ b/packages/ducpy/docs/examples.rst @@ -49,3 +49,25 @@ anything beyond what the high-level builders expose. .. literalinclude:: ../src/examples/sql_builder_demo.py :language: python :linenos: + +---- + +Serialization +------------- + +Demonstrates how to serialize builder-created elements directly to a `.duc` file using `duc.serialize_duc`. + +.. literalinclude:: ../src/examples/serialization_demo.py + :language: python + :linenos: + +---- + +Parsing +------- + +Demonstrates how to parse a `.duc` file or raw binary bytes using `duc.parse_duc`, allowing attribute-style access to the document's content. + +.. literalinclude:: ../src/examples/parsing_demo.py + :language: python + :linenos: diff --git a/packages/ducpy/docs/extra/context7.json b/packages/ducpy/docs/extra/context7.json new file mode 100644 index 0000000..c9e96d2 --- /dev/null +++ b/packages/ducpy/docs/extra/context7.json @@ -0,0 +1,4 @@ +{ + "url": "https://context7.com/websites/ducflair_github_io_duc", + "public_key": "pk_rW7pJ4T4uki1BAr4guCs1" +} \ No newline at end of file diff --git a/packages/ducpy/docs/index.rst b/packages/ducpy/docs/index.rst index ef0278e..017b064 100644 --- a/packages/ducpy/docs/index.rst +++ b/packages/ducpy/docs/index.rst @@ -16,6 +16,29 @@ ducpy

+Overview +-------- + +**Builders API (High-level)** + The easy way to build, manage ``.duc`` files. + Construct elements, apply styles, manage layers, build blocks, + and handle document state with the + :doc:`builders ` module. + +**SQL Builder (Low-level)** + A ``.duc`` file is a zlib-compressed SQLite database. Use the + :doc:`sql_builder ` + for direct schema access, bulk queries, and low-level manipulation. + +**Search** + Query/search elements and files programmatically via the + :doc:`search ` API. + +**File I/O** + Read and write ``.duc`` files using the + :doc:`parse ` and + :doc:`serialize ` modules. + .. toctree:: :maxdepth: 2 :caption: Contents: diff --git a/packages/ducpy/src/ducpy/__init__.py b/packages/ducpy/src/ducpy/__init__.py index 8b24000..abcb04d 100644 --- a/packages/ducpy/src/ducpy/__init__.py +++ b/packages/ducpy/src/ducpy/__init__.py @@ -1,12 +1,25 @@ -"""Main module for duc_py package. +"""Python library for the DUC 2D CAD file format. -Import usage examples: - import ducpy as duc - - duc.classes.SomeClass - duc.parse_duc(source) - duc.serialize_duc(name=..., elements=...) - duc.utils.some_utility +Usage:: + ``import ducpy as duc`` + +Builders API (High-level): + The easy way to build, manage ``.duc`` files. + Construct elements, apply styles, manage layers, build blocks, + and handle document state with the ``duc.builders`` module. + +SQL Builder (Low-level): + A ``.duc`` file is a zlib-compressed SQLite database. Use + ``duc.builders.sql_builder`` for direct schema access, bulk + queries, and low-level manipulation. + +Search: + Query/search elements and files programmatically via the + ``duc.search`` API. + +File I/O: + Read and write ``.duc`` files using the ``duc.parse`` + and ``duc.serialize`` modules. """ from .builders import * diff --git a/packages/ducpy/src/ducpy/parse.py b/packages/ducpy/src/ducpy/parse.py index 7342389..a6a24f8 100644 --- a/packages/ducpy/src/ducpy/parse.py +++ b/packages/ducpy/src/ducpy/parse.py @@ -57,16 +57,29 @@ def _read_bytes(source: Union[bytes, bytearray, BinaryIO, str]) -> bytes: def parse_duc(source: Union[bytes, bytearray, BinaryIO, str]) -> DucData: """Parse a ``.duc`` file into a :class:`DucData` dict. + This function reads a raw `.duc` binary blob or file path and parses it using + the Rust native extension. It returns a specialized dictionary (`DucData`) + that allows attribute-style access to the parsed properties (e.g. `data.elements[0].id`), + using `snake_case` keys instead of the internal `camelCase` format. + Parameters ---------- source : bytes | file | str - Raw bytes, an open binary file, or a path to a ``.duc`` file. + Raw bytes, an open binary file, or a string path to a ``.duc`` file. Returns ------- DucData - Attribute-accessible dict matching the ExportedDataState schema with - snake_case keys. + An attribute-accessible dictionary matching the internal `ExportedDataState` + schema with snake_case keys. Common keys include `elements`, `global_state`, + `local_state`, and `version_graph`. + + Examples + -------- + >>> data = duc.parse_duc("path/to/file.duc") + >>> data = duc.parse_duc(binary_data) + >>> print(f"Found {len(data.elements)} elements") + >>> print(f"First element type: {data.elements[0].type}") """ buf = _read_bytes(source) raw = ducpy_native.parse_duc(buf) diff --git a/packages/ducpy/src/ducpy/serialize.py b/packages/ducpy/src/ducpy/serialize.py index 18f3a17..c6908f3 100644 --- a/packages/ducpy/src/ducpy/serialize.py +++ b/packages/ducpy/src/ducpy/serialize.py @@ -216,9 +216,48 @@ def serialize_duc( layers: Optional[list] = None, external_files: Optional[list] = None, ) -> bytes: - """Serialize elements to ``.duc`` format. - Element instances and state dataclasses are automatically converted to the - camelCase dicts expected by the Rust native module. + """Serialize elements and document state to raw ``.duc`` binary format. + + This function accepts lists of elements created via the `ducpy.builders` API + (e.g., `ElementBuilder`) and serializes them into the compressed format + expected by `.duc` files. Element instances and state dataclasses are + automatically converted to the camelCase dicts expected by the Rust native module. + + Parameters + ---------- + name : str + The document name or identifier (used to populate the `source` field). + thumbnail : Optional[bytes], default=None + Raw PNG bytes representing a thumbnail of the document. + dictionary : Optional[list], default=None + List of Key-Value string pairs for dictionary entries. + elements : Optional[list], default=None + A list of elements (e.g., created via `ElementBuilder`) to include. + duc_local_state : Any, default=None + A `DucLocalState` object representing viewport state (pan, zoom, etc). + duc_global_state : Any, default=None + A `DucGlobalState` object representing document-wide settings. + version_graph : Any, default=None + Version history metadata of the document. + blocks : Optional[list], default=None + List of block definitions. + block_instances : Optional[list], default=None + List of block instances. + block_collections : Optional[list], default=None + List of block collections (libraries). + groups : Optional[list], default=None + List of element groups. + regions : Optional[list], default=None + List of boolean regions. + layers : Optional[list], default=None + List of document layers. + external_files : Optional[list], default=None + List of external files (e.g., embedded images or PDFs). + + Returns + ------- + bytes + The raw `.duc` binary data, ready to be written to a file. """ thumb = bytes(thumbnail) if thumbnail is not None else None diff --git a/packages/ducpy/src/examples/parsing_demo.py b/packages/ducpy/src/examples/parsing_demo.py new file mode 100644 index 0000000..f93bb77 --- /dev/null +++ b/packages/ducpy/src/examples/parsing_demo.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" +Example demonstrating how to parse a .duc file using the parsing API. + +This demo shows how to read a `.duc` binary blob or file path and access +the parsed data using attribute-style access via DucData. +""" + +import os +import tempfile +import ducpy as duc + +def main(): + print("Parsing Demo") + print("=" * 30) + + # First, let's create a temporary .duc file to parse + from ducpy.builders.style_builders import create_fill_and_stroke_style, create_solid_content + elements = [ + duc.ElementBuilder() + .at_position(10, 20) + .with_size(100, 50) + .with_label("Parsed Rectangle") + .with_styles(create_fill_and_stroke_style( + fill_content=create_solid_content("#FF6B6B"), + stroke_content=create_solid_content("#2C3E50"), + stroke_width=2.0 + )) + .build_rectangle() + .build() + ] + duc_bytes = duc.serialize_duc(name="parsing_example", elements=elements) + + with tempfile.NamedTemporaryFile(suffix=".duc", delete=False) as tmp: + tmp.write(duc_bytes) + tmp_path = tmp.name + + print("1. Parsing a .duc file from a file path...") + + # You can pass a string path directly to parse_duc + parsed_data = duc.parse_duc(tmp_path) + + print(f" Document Source: {parsed_data.source}") + print(f" Parsed {len(parsed_data.elements)} elements.") + + print("\n2. Accessing element attributes (snake_case)...") + + # Element properties are accessible via dot-notation with snake_case keys + # because parse_duc returns a DucData object. + first_element = parsed_data.elements[0] + print(f" Element ID: {first_element.id}") + print(f" Element Type: {first_element.type}") + print(f" Element Label: {first_element.label}") + print(f" Element Position: (X: {first_element.x}, Y: {first_element.y})") + + print("\n3. Parsing directly from raw bytes...") + + # You can also pass raw bytes directly to parse_duc + parsed_from_bytes = duc.parse_duc(duc_bytes) + print(f" Parsed successfully from bytes. Found {len(parsed_from_bytes.elements)} elements.") + + # Clean up the temporary file + os.unlink(tmp_path) + + print("\n✅ Parsing demo complete!") + +if __name__ == "__main__": + main() diff --git a/packages/ducpy/src/examples/serialization_demo.py b/packages/ducpy/src/examples/serialization_demo.py new file mode 100644 index 0000000..226c3b1 --- /dev/null +++ b/packages/ducpy/src/examples/serialization_demo.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" +Example demonstrating how to serialize elements created by the Builder API into a .duc file. + +This demo shows the correct pattern for taking in-memory python elements +and writing them to a raw `.duc` binary blob. +""" + +import os +import ducpy as duc +from ducpy.builders.style_builders import create_fill_and_stroke_style, create_solid_content + +def main(): + print("Serialization Demo") + print("=" * 30) + + print("1. Creating elements via Builder API...") + elements = [] + + # Create some basic elements + rect = (duc.ElementBuilder() + .at_position(0, 0) + .with_size(100, 50) + .with_label("Sample Rectangle") + .with_styles(create_fill_and_stroke_style( + fill_content=create_solid_content("#FF6B6B"), + stroke_content=create_solid_content("#2C3E50"), + stroke_width=2.0 + )) + .build_rectangle() + .build()) + elements.append(rect) + + ellipse = (duc.ElementBuilder() + .at_position(120, 0) + .with_size(60, 40) + .with_label("Sample Ellipse") + .with_styles(create_fill_and_stroke_style( + fill_content=create_solid_content("#4ECDC4"), + stroke_content=create_solid_content("#34495E"), + stroke_width=1.5 + )) + .build_ellipse() + .build()) + elements.append(ellipse) + + print(f" Created {len(elements)} elements.") + + print("2. Serializing to .duc format...") + # NOTE: The serialize_duc function takes keyword arguments for elements, + # blocks, global state, etc. and bridges to the Rust native backend. + duc_bytes = duc.serialize_duc( + name="serialization_example", + elements=elements + ) + + print(f" Successfully serialized {len(duc_bytes)} bytes.") + + # You would typically write this to a file + # output_file = "example.duc" + # with open(output_file, "wb") as f: + # f.write(duc_bytes) + # print(f"3. Saved to {output_file}") + + print("\n✅ Serialization demo complete!") + +if __name__ == "__main__": + main() diff --git a/packages/ducpy/src/tests/src/test_empire_state_floor_plan.py b/packages/ducpy/src/tests/src/test_empire_state_floor_plan.py new file mode 100644 index 0000000..157e49a --- /dev/null +++ b/packages/ducpy/src/tests/src/test_empire_state_floor_plan.py @@ -0,0 +1,89 @@ +import os +import ducpy as duc +import ducpy.serialize +from ducpy.builders.style_builders import create_fill_and_stroke_style, create_simple_styles, create_solid_content +from ducpy.classes.DataStateClass import ExportedDataState + +def test_empire_state_floor_plan(test_output_dir): + elements = [] + + def create_wall(x1, y1, x2, y2, label=""): + return (duc.ElementBuilder() + .with_label(label) + .with_styles(create_simple_styles( + strokes=[duc.create_stroke(create_solid_content("#000000"), width=2.0)] + )) + .build_linear_element() + .with_points([(x1, y1), (x2, y2)]) + .build()) + + def create_rect(x, y, w, h, fill_color="#CCCCCC", stroke_color="#000000", label=""): + return (duc.ElementBuilder() + .at_position(x, y) + .with_size(w, h) + .with_label(label) + .with_styles(create_fill_and_stroke_style( + fill_content=create_solid_content(fill_color), + stroke_content=create_solid_content(stroke_color), + stroke_width=1.5 + )) + .build_rectangle() + .build()) + + floor_outline_points = [ + (-40, -50), (-40, -97), (40, -97), (40, -50), + (50, -40), (100, -40), (100, 40), (50, 40), + (40, 50), (40, 97), (-40, 97), (-40, 50), + (-50, 40), (-100, 40), (-100, -40), (-50, -40), + (-40, -50), + ] + + for i in range(len(floor_outline_points) - 1): + p1 = floor_outline_points[i] + p2 = floor_outline_points[i + 1] + elements.append(create_wall(p1[0], p1[1], p2[0], p2[1])) + + elements.append(create_rect(-35, -25, 30, 50, "#E0E0E0", "#000000", "West Elevator Core")) + elements.append(create_rect(5, -25, 30, 50, "#E0E0E0", "#000000", "East Elevator Core")) + elements.append(create_rect(-15, -45, 30, 15, "#D0D0D0", "#000000", "North Elevator Bank")) + elements.append(create_rect(-15, 30, 30, 15, "#D0D0D0", "#000000", "South Elevator Bank")) + + for x, y, w, h, label in [(-40, -30, 5, 10, "Stair NW"), (-40, 20, 5, 10, "Stair SW"), (35, -30, 5, 10, "Stair NE"), (35, 20, 5, 10, "Stair SE")]: + elements.append(create_rect(x, y, w, h, "#C0C0C0", "#000000", label)) + + column_size = 3 + for x in [-30, -15, 0, 15, 30]: + elements.append(create_rect(x - column_size/2, -70 - column_size/2, column_size, column_size, "#808080", "#000000")) + elements.append(create_rect(x - column_size/2, 70 - column_size/2, column_size, column_size, "#808080", "#000000")) + for y in [-30, -15, 0, 15, 30]: + elements.append(create_rect(-70 - column_size/2, y - column_size/2, column_size, column_size, "#808080", "#000000")) + elements.append(create_rect(70 - column_size/2, y - column_size/2, column_size, column_size, "#808080", "#000000")) + + for x1, y1, x2, y2, label in [(-50, -25, -5, -25, "Corridor N"), (-50, 25, -5, 25, "Corridor S"), (5, -25, 50, -25, "Corridor N"), (5, 25, 50, 25, "Corridor S"), (-35, -50, -35, -30, "Wall W1"), (-35, 30, -35, 50, "Wall W2"), (35, -50, 35, -30, "Wall E1"), (35, 30, 35, 50, "Wall E2")]: + elements.append(create_wall(x1, y1, x2, y2, label)) + + title_text = (duc.ElementBuilder() + .at_position(-50, 110) + .with_size(100, 15) + .with_label("Title") + .with_styles(create_simple_styles(opacity=1.0)) + .build_text_element() + .with_text("EMPIRE STATE BUILDING - TYPICAL FLOOR PLAN (6th-20th Floors)") + .build()) + elements.append(title_text) + + global_state = (duc.StateBuilder().build_global_state().with_main_scope("ft").build()) + local_state = (duc.StateBuilder().build_local_state().build()) + + serialized_bytes = ducpy.serialize.serialize_duc( + name="empire_state_floor_plan", + elements=elements + ) + + output_file_path = os.path.join(test_output_dir, "test_empire_state_floor_plan.duc") + with open(output_file_path, "wb") as f: + f.write(serialized_bytes) + + assert os.path.exists(output_file_path) + assert os.path.getsize(output_file_path) > 0 + print(f"Empire State floor plan created successfully at {output_file_path}") diff --git a/packages/ducpy/src/tests/src/test_examples.py b/packages/ducpy/src/tests/src/test_examples.py index 1eda21c..59603f7 100644 --- a/packages/ducpy/src/tests/src/test_examples.py +++ b/packages/ducpy/src/tests/src/test_examples.py @@ -18,6 +18,8 @@ import external_files_demo import mutation_demo import sql_builder_demo +import serialization_demo +import parsing_demo class TestElementCreationDemo: @@ -97,6 +99,40 @@ def test_sql_builder_demo_runs_successfully(self): assert "All DucSQL demos completed successfully!" in output_text +class TestSerializationDemo: + """Test the serialization demo.""" + + def test_serialization_demo_runs_successfully(self): + """Test that the serialization demo runs without errors.""" + output = StringIO() + with redirect_stdout(output): + serialization_demo.main() + + output_text = output.getvalue() + assert "Serialization Demo" in output_text + assert "Creating elements via Builder API" in output_text + assert "Serializing to .duc format" in output_text + assert "Successfully serialized" in output_text + assert "Serialization demo complete" in output_text + + +class TestParsingDemo: + """Test the parsing demo.""" + + def test_parsing_demo_runs_successfully(self): + """Test that the parsing demo runs without errors.""" + output = StringIO() + with redirect_stdout(output): + parsing_demo.main() + + output_text = output.getvalue() + assert "Parsing Demo" in output_text + assert "Parsing a .duc file from a file path" in output_text + assert "Accessing element attributes" in output_text + assert "Parsing directly from raw bytes" in output_text + assert "Parsing demo complete" in output_text + + class TestStyleBuilders: """Test that style builders produce valid style objects.""" diff --git a/packages/ducpy/src/tests/src/test_flywheel.py b/packages/ducpy/src/tests/src/test_flywheel.py new file mode 100644 index 0000000..e204803 --- /dev/null +++ b/packages/ducpy/src/tests/src/test_flywheel.py @@ -0,0 +1,78 @@ +import math +import os +import ducpy as duc +from ducpy.builders.style_builders import create_fill_and_stroke_style, create_simple_styles, create_solid_content, create_text_style + +def test_create_flywheel(test_output_dir): + center_x, center_y = 400, 300 + outer_radius, rim_thickness = 200, 30 + inner_radius = outer_radius - rim_thickness + hub_radius, spoke_count = 40, 6 + + elements = [] + + outer_rim = (duc.ElementBuilder() + .at_position(center_x - outer_radius, center_y - outer_radius) + .with_size(outer_radius * 2, outer_radius * 2) + .with_label("Outer Rim") + .with_styles(create_fill_and_stroke_style(fill_content=create_solid_content("#4A90D9"), stroke_content=create_solid_content("#1E3A5F"), stroke_width=2.0)) + .build_ellipse().build()) + elements.append(outer_rim) + + inner_bg = (duc.ElementBuilder() + .at_position(center_x - inner_radius, center_y - inner_radius) + .with_size(inner_radius * 2, inner_radius * 2) + .with_label("Inner Background") + .with_styles(create_fill_and_stroke_style(fill_content=create_solid_content("#E8F0F8"), stroke_content=create_solid_content("#1E3A5F"), stroke_width=1.0)) + .build_ellipse().build()) + elements.append(inner_bg) + + hub = (duc.ElementBuilder() + .at_position(center_x - hub_radius, center_y - hub_radius) + .with_size(hub_radius * 2, hub_radius * 2) + .with_label("Hub") + .with_styles(create_fill_and_stroke_style(fill_content=create_solid_content("#2C5F8A"), stroke_content=create_solid_content("#1E3A5F"), stroke_width=2.0)) + .build_ellipse().build()) + elements.append(hub) + + center_hole = (duc.ElementBuilder() + .at_position(center_x - 15, center_y - 15).with_size(30, 30) + .with_label("Center Hole") + .with_styles(create_fill_and_stroke_style(fill_content=create_solid_content("#1A3A52"), stroke_content=create_solid_content("#1E3A5F"), stroke_width=1.0)) + .build_ellipse().build()) + elements.append(center_hole) + + for i in range(spoke_count): + a = (2 * math.pi * i) / spoke_count + spoke = (duc.ElementBuilder() + .with_label(f"Spoke {i}") + .with_styles(create_simple_styles(strokes=[duc.create_stroke(create_solid_content("#5BA3E6"), width=20.0)])) + .build_linear_element() + .with_points([(center_x + hub_radius * math.cos(a), center_y + hub_radius * math.sin(a)), (center_x + (inner_radius - 5) * math.cos(a), center_y + (inner_radius - 5) * math.sin(a))]) + .build()) + elements.append(spoke) + + for i in range(4): + a = (2 * math.pi * i) / 4 + math.pi / 4 + bx, by = center_x + 25 * math.cos(a), center_y + 25 * math.sin(a) + bolt = (duc.ElementBuilder() + .at_position(bx - 5, by - 5).with_size(10, 10).with_label(f"Bolt {i}") + .with_styles(create_fill_and_stroke_style(fill_content=create_solid_content("#1A3A52"), stroke_content=create_solid_content("#1E3A5F"), stroke_width=1.0)) + .build_ellipse().build()) + elements.append(bolt) + + label = (duc.ElementBuilder() + .at_position(center_x - 50, center_y + outer_radius + 40).with_size(200, 30) + .with_label("Flywheel Label").with_styles(create_simple_styles(opacity=1.0)) + .build_text_element().with_text("Flywheel").build()) + elements.append(label) + + duc_bytes = duc.serialize_duc(name="flywheel", elements=elements) + + output_file_path = os.path.join(test_output_dir, "test_flywheel.duc") + with open(output_file_path, "wb") as f: + f.write(duc_bytes) + + assert os.path.exists(output_file_path) + assert os.path.getsize(output_file_path) > 0 + print(f"Flywheel created successfully at {output_file_path}")