ducpy 3.3.5__tar.gz → 3.3.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {ducpy-3.3.5 → ducpy-3.3.6}/Cargo.lock +1 -1
  2. {ducpy-3.3.5 → ducpy-3.3.6}/PKG-INFO +1 -1
  3. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducpy/crate/Cargo.toml +1 -1
  4. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/src/parse.rs +34 -5
  5. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/src/serialize.rs +22 -8
  6. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/src/types.rs +2 -0
  7. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/tests/common/mod.rs +1 -0
  8. {ducpy-3.3.5 → ducpy-3.3.6}/pyproject.toml +4 -0
  9. {ducpy-3.3.5 → ducpy-3.3.6}/schema/duc.sql +12 -2
  10. ducpy-3.3.6/schema/migrations/3000003_to_3000004.sql +41 -0
  11. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/__init__.py +1 -1
  12. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/builders/element_builders.py +54 -44
  13. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/classes/ElementsClass.py +8 -0
  14. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/serialize.py +243 -2
  15. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/utils/__init__.py +2 -1
  16. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/utils/convert.py +47 -3
  17. ducpy-3.3.5/schema/migrations/3000003_to_3000004.sql +0 -20
  18. {ducpy-3.3.5 → ducpy-3.3.6}/Cargo.toml +0 -0
  19. {ducpy-3.3.5 → ducpy-3.3.6}/LICENSE +0 -0
  20. {ducpy-3.3.5 → ducpy-3.3.6}/README.md +0 -0
  21. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducpy/crate/src/lib.rs +0 -0
  22. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/.gitignore +0 -0
  23. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/Cargo.toml +0 -0
  24. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/LICENSE +0 -0
  25. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/README.md +0 -0
  26. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/build.rs +0 -0
  27. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/package.json +0 -0
  28. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/release.config.cjs +0 -0
  29. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/src/api/document.rs +0 -0
  30. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/src/api/meta.rs +0 -0
  31. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/src/api/mod.rs +0 -0
  32. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/src/api/version_control.rs +0 -0
  33. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/src/db/bootstrap.rs +0 -0
  34. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/src/db/mod.rs +0 -0
  35. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/src/db/native.rs +0 -0
  36. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/src/db/wasm.rs +0 -0
  37. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/src/lib.rs +0 -0
  38. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/src/serde_utils.rs +0 -0
  39. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/sst-env.d.ts +0 -0
  40. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/tests/.gitignore +0 -0
  41. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/tests/parser_assets.rs +0 -0
  42. {ducpy-3.3.5 → ducpy-3.3.6}/packages/ducrs/tests/synthetic_roundtrip.rs +0 -0
  43. {ducpy-3.3.5 → ducpy-3.3.6}/schema/migrations/3000000_to_3000001.sql +0 -0
  44. {ducpy-3.3.5 → ducpy-3.3.6}/schema/migrations/3000001_to_3000002.sql +0 -0
  45. {ducpy-3.3.5 → ducpy-3.3.6}/schema/migrations/3000002_to_3000003.sql +0 -0
  46. {ducpy-3.3.5 → ducpy-3.3.6}/schema/search.sql +0 -0
  47. {ducpy-3.3.5 → ducpy-3.3.6}/schema/version_control.sql +0 -0
  48. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/_version.py +0 -0
  49. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/builders/__init__.py +0 -0
  50. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/builders/block_instance_builder.py +0 -0
  51. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/builders/block_utils.py +0 -0
  52. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/builders/mutate_builder.py +0 -0
  53. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/builders/sql_builder.py +0 -0
  54. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/builders/state_builders.py +0 -0
  55. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/builders/style_builders.py +0 -0
  56. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/classes/DataStateClass.py +0 -0
  57. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/classes/__init__.py +0 -0
  58. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/enums.py +0 -0
  59. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/parse.py +0 -0
  60. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/search/__init__.py +0 -0
  61. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/search/search_elements.py +0 -0
  62. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/utils/constants.py +0 -0
  63. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/utils/io.py +0 -0
  64. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/utils/mutate_utils.py +0 -0
  65. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy/utils/rand_utils.py +0 -0
  66. {ducpy-3.3.5 → ducpy-3.3.6}/src/ducpy_native/__init__.py +0 -0
@@ -412,7 +412,7 @@ dependencies = [
412
412
 
413
413
  [[package]]
414
414
  name = "ducpy-native"
415
- version = "3.3.5"
415
+ version = "3.3.6"
416
416
  dependencies = [
417
417
  "duc",
418
418
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ducpy
3
- Version: 3.3.5
3
+ Version: 3.3.6
4
4
  Classifier: License :: OSI Approved :: MIT License
5
5
  Requires-Dist: nanoid>=2.0.0
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ducpy-native"
3
- version = "3.3.5"
3
+ version = "3.3.6"
4
4
  edition = "2021"
5
5
  description = "Python native extension for the duc file format (used by ducpy)"
6
6
  publish = false
@@ -1460,17 +1460,20 @@ fn read_pdf_element(conn: &Connection, base: DucElementBase) -> ParseResult<DucE
1460
1460
  }
1461
1461
 
1462
1462
  fn read_doc_element(conn: &Connection, base: DucElementBase) -> ParseResult<DucElementEnum> {
1463
+ let id = base.id.clone();
1463
1464
  let (file_id, grid_config) = read_document_grid_config(conn, &base.id)?;
1464
1465
  let mut stmt = conn.prepare_cached(
1465
1466
  "SELECT text FROM element_doc WHERE element_id = ?1"
1466
1467
  )?;
1467
1468
  let text: String = stmt.query_row(params![base.id], |row| row.get(0))?;
1469
+ let referenced_file_ids = read_doc_referenced_file_ids(conn, &id)?;
1468
1470
  Ok(DucElementEnum::DucDocElement(DucDocElement {
1469
1471
  base,
1470
1472
  style: DucDocStyle {},
1471
1473
  text,
1472
1474
  grid_config,
1473
1475
  file_id,
1476
+ referenced_file_ids,
1474
1477
  }))
1475
1478
  }
1476
1479
 
@@ -1512,11 +1515,7 @@ fn read_model_element(conn: &Connection, base: DucElementBase) -> ParseResult<Du
1512
1515
  Ok((row.get::<_, Option<String>>(0)?, row.get::<_, Option<String>>(1)?, row.get::<_, Option<Vec<u8>>>(2)?))
1513
1516
  })?;
1514
1517
 
1515
- let mut f_stmt = conn.prepare_cached(
1516
- "SELECT file_id FROM model_element_files WHERE element_id = ?1 ORDER BY sort_order"
1517
- )?;
1518
- let file_ids: Vec<String> = f_stmt.query_map(params![id], |row| row.get(0))?
1519
- .collect::<Result<Vec<_>, _>>()?;
1518
+ let file_ids = read_element_file_ids(conn, &id)?;
1520
1519
 
1521
1520
  let viewer_state = read_model_viewer_state(conn, &id)?;
1522
1521
 
@@ -1525,6 +1524,36 @@ fn read_model_element(conn: &Connection, base: DucElementBase) -> ParseResult<Du
1525
1524
  }))
1526
1525
  }
1527
1526
 
1527
+ fn read_element_file_ids(conn: &Connection, element_id: &str) -> ParseResult<Vec<String>> {
1528
+ let mut stmt = conn.prepare_cached(
1529
+ "SELECT file_id FROM model_element_files WHERE element_id = ?1 ORDER BY sort_order"
1530
+ )?;
1531
+ let file_ids = stmt.query_map(params![element_id], |row| row.get(0))?
1532
+ .collect::<Result<Vec<_>, _>>()?;
1533
+ Ok(file_ids)
1534
+ }
1535
+
1536
+ fn read_doc_referenced_file_ids(conn: &Connection, element_id: &str) -> ParseResult<Vec<String>> {
1537
+ if !table_exists(conn, "doc_element_referenced_files")? {
1538
+ return Ok(Vec::new());
1539
+ }
1540
+
1541
+ let mut stmt = conn.prepare_cached(
1542
+ "SELECT file_id FROM doc_element_referenced_files WHERE element_id = ?1 ORDER BY sort_order"
1543
+ )?;
1544
+ let file_ids = stmt.query_map(params![element_id], |row| row.get(0))?
1545
+ .collect::<Result<Vec<_>, _>>()?;
1546
+ Ok(file_ids)
1547
+ }
1548
+
1549
+ fn table_exists(conn: &Connection, table_name: &str) -> ParseResult<bool> {
1550
+ Ok(conn.query_row(
1551
+ "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
1552
+ params![table_name],
1553
+ |row| row.get::<_, i64>(0),
1554
+ )? != 0)
1555
+ }
1556
+
1528
1557
  fn read_model_viewer_state(conn: &Connection, element_id: &str) -> ParseResult<Option<Viewer3DState>> {
1529
1558
  let mut stmt = conn.prepare_cached(
1530
1559
  "SELECT
@@ -456,6 +456,7 @@ fn write_element_wrapper(tx: &Transaction, wrapper: &ElementWrapper) -> Serializ
456
456
  "INSERT INTO element_doc (element_id, text) VALUES (?1, ?2)",
457
457
  params![e.base.id, e.text],
458
458
  )?;
459
+ write_doc_referenced_file_ids(tx, &e.base.id, &e.referenced_file_ids)?;
459
460
  }
460
461
  DucElementEnum::DucTableElement(e) => {
461
462
  write_base_element(tx, "table", &e.base)?;
@@ -834,14 +835,7 @@ fn write_model_element(tx: &Transaction, e: &DucModelElement) -> SerializeResult
834
835
  params![e.base.id, e.model_type, e.code, e.thumbnail],
835
836
  )?;
836
837
 
837
- {
838
- let mut stmt = tx.prepare_cached(
839
- "INSERT INTO model_element_files (element_id, file_id, sort_order) VALUES (?1, ?2, ?3)"
840
- )?;
841
- for (i, fid) in e.file_ids.iter().enumerate() {
842
- stmt.execute(params![e.base.id, fid, i as i32])?;
843
- }
844
- }
838
+ write_element_file_ids(tx, &e.base.id, &e.file_ids)?;
845
839
 
846
840
  if let Some(ref vs) = e.viewer_state {
847
841
  write_model_viewer_state(tx, &e.base.id, vs)?;
@@ -850,6 +844,26 @@ fn write_model_element(tx: &Transaction, e: &DucModelElement) -> SerializeResult
850
844
  Ok(())
851
845
  }
852
846
 
847
+ fn write_element_file_ids(tx: &Transaction, element_id: &str, file_ids: &[String]) -> SerializeResult<()> {
848
+ let mut stmt = tx.prepare_cached(
849
+ "INSERT INTO model_element_files (element_id, file_id, sort_order) VALUES (?1, ?2, ?3)"
850
+ )?;
851
+ for (i, fid) in file_ids.iter().enumerate() {
852
+ stmt.execute(params![element_id, fid, i as i32])?;
853
+ }
854
+ Ok(())
855
+ }
856
+
857
+ fn write_doc_referenced_file_ids(tx: &Transaction, element_id: &str, file_ids: &[String]) -> SerializeResult<()> {
858
+ let mut stmt = tx.prepare_cached(
859
+ "INSERT INTO doc_element_referenced_files (element_id, file_id, sort_order) VALUES (?1, ?2, ?3)"
860
+ )?;
861
+ for (i, fid) in file_ids.iter().enumerate() {
862
+ stmt.execute(params![element_id, fid, i as i32])?;
863
+ }
864
+ Ok(())
865
+ }
866
+
853
867
  fn write_model_viewer_state(tx: &Transaction, element_id: &str, vs: &Viewer3DState) -> SerializeResult<()> {
854
868
  let cam = &vs.camera;
855
869
  let disp = &vs.display;
@@ -1045,6 +1045,8 @@ pub struct DucDocElement {
1045
1045
  pub text: String,
1046
1046
  pub grid_config: DocumentGridConfig,
1047
1047
  pub file_id: Option<String>,
1048
+ #[serde(default)]
1049
+ pub referenced_file_ids: Vec<String>,
1048
1050
  }
1049
1051
 
1050
1052
  #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -659,6 +659,7 @@ pub fn synthetic_roundtrip_state() -> ExportedDataState {
659
659
  scale: 2.0,
660
660
  },
661
661
  file_id: Some("file-model".to_string()),
662
+ referenced_file_ids: vec!["file-model".to_string()],
662
663
  }),
663
664
  },
664
665
  ElementWrapper {
@@ -51,6 +51,10 @@ dev = [
51
51
  "sphinx-autoapi>=3.6.0",
52
52
  "pytest>=8.3.5",
53
53
  "rich>=13.0.0",
54
+ "typst>=0.14.9",
55
+ "build123d>=0.10.0",
56
+ "ocp-vscode>=3.4.0",
57
+ "ifcopenshell>=0.8.5",
54
58
  ]
55
59
 
56
60
 
@@ -1,7 +1,7 @@
1
1
  -- "DUC_" in ASCII
2
2
  -- Apply in order: duc.sql → version_control.sql → search.sql
3
3
  PRAGMA application_id = 1146569567;
4
- PRAGMA user_version = 3000003;
4
+ PRAGMA user_version = 3000004;
5
5
  PRAGMA journal_mode = WAL;
6
6
  PRAGMA foreign_keys = ON;
7
7
  PRAGMA synchronous = NORMAL;
@@ -702,6 +702,16 @@ CREATE TABLE element_doc (
702
702
  text TEXT NOT NULL DEFAULT ''
703
703
  ); -- no WITHOUT ROWID: implicit integer rowid needed for FTS5 content sync
704
704
 
705
+ -- External files referenced by a doc element's Typst source (images, data files, etc.).
706
+ CREATE TABLE doc_element_referenced_files (
707
+ element_id TEXT NOT NULL REFERENCES element_doc(element_id) ON DELETE CASCADE,
708
+ file_id TEXT NOT NULL,
709
+ sort_order INTEGER NOT NULL DEFAULT 0,
710
+ PRIMARY KEY (element_id, file_id)
711
+ ) WITHOUT ROWID;
712
+
713
+ CREATE INDEX idx_doc_referenced_files_file ON doc_element_referenced_files(file_id);
714
+
705
715
  -- Table element. Source of truth is the linked xlsx file.
706
716
  CREATE TABLE element_table (
707
717
  element_id TEXT PRIMARY KEY REFERENCES elements(id) ON DELETE CASCADE,
@@ -844,4 +854,4 @@ CREATE INDEX idx_external_file_revisions_file ON external_file_revisions(file_id
844
854
  CREATE TABLE external_file_revision_data (
845
855
  revision_id TEXT PRIMARY KEY REFERENCES external_file_revisions(id) ON DELETE CASCADE,
846
856
  data BLOB NOT NULL
847
- ) WITHOUT ROWID;
857
+ ) WITHOUT ROWID;
@@ -0,0 +1,41 @@
1
+ -- Migration: 3000003 → 3000004
2
+ -- Rename element_model.svg_path (TEXT) to thumbnail (BLOB), and add dedicated
3
+ -- references for external files used by doc element Typst source.
4
+
5
+ CREATE TABLE element_model_new (
6
+ element_id TEXT PRIMARY KEY REFERENCES elements(id) ON DELETE CASCADE,
7
+ model_type TEXT,
8
+ code TEXT,
9
+ thumbnail BLOB
10
+ );
11
+
12
+ INSERT INTO element_model_new (element_id, model_type, code, thumbnail)
13
+ SELECT element_id, model_type, code, CAST(svg_path AS BLOB)
14
+ FROM element_model;
15
+
16
+ DROP TABLE element_model;
17
+
18
+ ALTER TABLE element_model_new RENAME TO element_model;
19
+
20
+ CREATE INDEX idx_element_model_type ON element_model(model_type);
21
+
22
+ CREATE TABLE IF NOT EXISTS doc_element_referenced_files (
23
+ element_id TEXT NOT NULL REFERENCES element_doc(element_id) ON DELETE CASCADE,
24
+ file_id TEXT NOT NULL,
25
+ sort_order INTEGER NOT NULL DEFAULT 0,
26
+ PRIMARY KEY (element_id, file_id)
27
+ ) WITHOUT ROWID;
28
+
29
+ CREATE INDEX IF NOT EXISTS idx_doc_referenced_files_file ON doc_element_referenced_files(file_id);
30
+
31
+ -- Defensive cleanup for prerelease files that may have stored doc references in
32
+ -- model_element_files before this dedicated table existed.
33
+ INSERT OR IGNORE INTO doc_element_referenced_files (element_id, file_id, sort_order)
34
+ SELECT mf.element_id, mf.file_id, mf.sort_order
35
+ FROM model_element_files mf
36
+ INNER JOIN element_doc d ON d.element_id = mf.element_id;
37
+
38
+ DELETE FROM model_element_files
39
+ WHERE element_id IN (SELECT element_id FROM element_doc);
40
+
41
+ PRAGMA user_version = 3000004;
@@ -27,6 +27,6 @@ from .classes import *
27
27
  from .enums import *
28
28
  from .parse import (DucData, get_external_file, list_external_files, parse_duc,
29
29
  parse_duc_lazy)
30
- from .serialize import DUC_SCHEMA_VERSION, serialize_duc
30
+ from .serialize import DUC_SCHEMA_VERSION, DucSerializationValidationError, serialize_duc
31
31
  from .search import *
32
32
  from .utils import *
@@ -55,45 +55,46 @@ def _create_element_wrapper(element_class, base_params, element_params, explicit
55
55
  base_params.setdefault('region_ids', [])
56
56
  base_params.setdefault('instance_id', None)
57
57
 
58
- base_params.setdefault('styles', DucElementStylesBase(
59
- roundness=0.0,
60
- stroke=[ElementStroke(
61
- content=ElementContentBase(
62
- preference=None,
63
- src="",
64
- visible=True,
65
- opacity=1.0,
66
- tiling=None,
67
- hatch=None,
68
- image_filter=DucImageFilter(brightness=1.0, contrast=1.0)
69
- ),
70
- width=DEFAULT_STROKE_WIDTH,
71
- style=StrokeStyle(
72
- preference=STROKE_PREFERENCE.SOLID,
73
- dash=None,
74
- dash_line_override=None,
75
- cap=None,
76
- join=None,
77
- dash_cap=None,
78
- miter_limit=None
79
- ),
80
- placement=STROKE_PLACEMENT.CENTER,
81
- stroke_sides=None
82
- )],
83
- background=[ElementBackground(
84
- content=ElementContentBase(
85
- preference=None,
86
- src="",
87
- visible=True,
88
- opacity=1.0,
89
- tiling=None,
90
- hatch=None,
91
- image_filter=DucImageFilter(brightness=1.0, contrast=1.0)
92
- )
93
- )],
94
- opacity=1.0,
95
- blending=None
96
- ))
58
+ if base_params.get('styles') is None:
59
+ base_params['styles'] = DucElementStylesBase(
60
+ roundness=0.0,
61
+ stroke=[ElementStroke(
62
+ content=ElementContentBase(
63
+ preference=None,
64
+ src="",
65
+ visible=True,
66
+ opacity=1.0,
67
+ tiling=None,
68
+ hatch=None,
69
+ image_filter=DucImageFilter(brightness=1.0, contrast=1.0)
70
+ ),
71
+ width=DEFAULT_STROKE_WIDTH,
72
+ style=StrokeStyle(
73
+ preference=STROKE_PREFERENCE.SOLID,
74
+ dash=None,
75
+ dash_line_override=None,
76
+ cap=None,
77
+ join=None,
78
+ dash_cap=None,
79
+ miter_limit=None
80
+ ),
81
+ placement=STROKE_PLACEMENT.CENTER,
82
+ stroke_sides=None
83
+ )],
84
+ background=[ElementBackground(
85
+ content=ElementContentBase(
86
+ preference=None,
87
+ src="",
88
+ visible=True,
89
+ opacity=1.0,
90
+ tiling=None,
91
+ hatch=None,
92
+ image_filter=DucImageFilter(brightness=1.0, contrast=1.0)
93
+ )
94
+ )],
95
+ opacity=1.0,
96
+ blending=None
97
+ )
97
98
 
98
99
  base_element = DucElementBase(**base_params)
99
100
 
@@ -168,8 +169,9 @@ def _create_element_wrapper(element_class, base_params, element_params, explicit
168
169
  base=base_element,
169
170
  style=element_params.get('style') or DucDocStyle(),
170
171
  text=element_params.get('text', ""),
171
- grid_config=element_params.get('grid_config', default_grid_config),
172
+ grid_config=element_params.get('grid_config') or default_grid_config,
172
173
  file_id=element_params.get('file_id'),
174
+ referenced_file_ids=element_params.get('referenced_file_ids') or [],
173
175
  )
174
176
  elif element_class == DucFrameElement:
175
177
  stack_base = element_params.get('stack_base')
@@ -698,6 +700,8 @@ class DocElementBuilder(ElementSpecificBuilder):
698
700
  self.extra["style"] = style; return self
699
701
  def with_file_id(self, file_id: str):
700
702
  self.extra["file_id"] = file_id; return self
703
+ def with_referenced_file_ids(self, referenced_file_ids: List[str]):
704
+ self.extra["referenced_file_ids"] = referenced_file_ids; return self
701
705
  def with_grid_config(self, grid_config: DocumentGridConfig):
702
706
  self.extra["grid_config"] = grid_config; return self
703
707
 
@@ -707,6 +711,7 @@ class DocElementBuilder(ElementSpecificBuilder):
707
711
  "style": self.extra.get('style'),
708
712
  "text": self.extra.get('text', ""),
709
713
  "file_id": self.extra.get('file_id'),
714
+ "referenced_file_ids": self.extra.get('referenced_file_ids', []),
710
715
  "grid_config": self.extra.get('grid_config'),
711
716
  }
712
717
  return _create_element_wrapper(DucDocElement, base_params, element_params,
@@ -727,11 +732,12 @@ class EmbeddableElementBuilder(ElementSpecificBuilder):
727
732
 
728
733
  class ModelElementBuilder(ElementSpecificBuilder):
729
734
  def with_model_type(self, model_type: str):
735
+ valid_types = {"python", "ifc", "dxf", "dwg", "step", "stl"}
736
+ if model_type not in valid_types:
737
+ raise ValueError(f"Invalid model_type: {model_type}. Allowed types: {sorted(list(valid_types))}")
730
738
  self.extra["model_type"] = model_type; return self
731
739
  def with_code(self, code: str):
732
740
  self.extra["code"] = code; return self
733
- def with_svg_path(self, svg_path: str):
734
- self.extra["svg_path"] = svg_path; return self
735
741
  def with_file_ids(self, file_ids: List[str]):
736
742
  self.extra["file_ids"] = file_ids; return self
737
743
  def with_viewer_state(self, viewer_state):
@@ -739,11 +745,15 @@ class ModelElementBuilder(ElementSpecificBuilder):
739
745
 
740
746
  def build(self) -> ElementWrapper:
741
747
  base_params = self.base.__dict__.copy()
748
+ model_type = self.extra.get('model_type', 'python')
749
+ valid_types = {"python", "ifc", "dxf", "dwg", "step", "stl"}
750
+ if model_type not in valid_types:
751
+ raise ValueError(f"Invalid model_type: {model_type}. Allowed types: {sorted(list(valid_types))}")
752
+
742
753
  element_params = {
743
754
  "file_ids": self.extra.get('file_ids', []),
744
- "model_type": self.extra.get('model_type'),
755
+ "model_type": model_type,
745
756
  "code": self.extra.get('code'),
746
- "svg_path": self.extra.get('svg_path'),
747
757
  "viewer_state": self.extra.get('viewer_state'),
748
758
  }
749
759
  return _create_element_wrapper(DucModelElement, base_params, element_params,
@@ -393,6 +393,7 @@ class DucDocElement:
393
393
  text: str
394
394
  grid_config: DocumentGridConfig
395
395
  file_id: Optional[str] = None
396
+ referenced_file_ids: Optional[List[str]] = None
396
397
 
397
398
  @dataclass
398
399
  class DucTableElement:
@@ -482,6 +483,13 @@ class DucModelElement:
482
483
  thumbnail: Optional[bytes] = None
483
484
  viewer_state: Optional[Viewer3DState] = None
484
485
 
486
+ def __post_init__(self):
487
+ valid_types = {"python", "ifc", "dxf", "dwg", "step", "stl"}
488
+ if self.model_type is None:
489
+ self.model_type = "python"
490
+ if self.model_type not in valid_types:
491
+ raise ValueError(f"Invalid model_type: {self.model_type}. Allowed types: {sorted(list(valid_types))}")
492
+
485
493
 
486
494
  # =============== BLOCK DEFINITIONS ===============
487
495
 
@@ -7,9 +7,12 @@ from __future__ import annotations
7
7
  import logging
8
8
  import os
9
9
  import re
10
+ import subprocess
11
+ import sys
12
+ import tempfile
10
13
  from dataclasses import asdict, is_dataclass
11
14
  from pathlib import Path
12
- from typing import Any, Dict, List, Optional, Tuple, Union
15
+ from typing import Any, Dict, List, Optional, Tuple
13
16
 
14
17
  import ducpy_native
15
18
  from ducpy.utils.convert import (deep_snake_to_camel, snake_to_camel,
@@ -18,6 +21,14 @@ from ducpy.utils.convert import (deep_snake_to_camel, snake_to_camel,
18
21
  logger = logging.getLogger(__name__)
19
22
 
20
23
 
24
+ class DucSerializationValidationError(ValueError):
25
+ """Raised when embedded element code fails validation before serialization."""
26
+
27
+ def __init__(self, failures: List[str]):
28
+ self.failures = failures
29
+ super().__init__("Embedded code validation failed:\n" + "\n".join(f"- {failure}" for failure in failures))
30
+
31
+
21
32
  def _find_schema_file() -> Path | None:
22
33
  env_path = Path(os.environ["DUC_SCHEMA_DIR"]) if "DUC_SCHEMA_DIR" in os.environ else None
23
34
  if env_path is not None:
@@ -200,6 +211,218 @@ def _convert_list(items: Optional[list]) -> Optional[list]:
200
211
  return [to_serializable(item) for item in items]
201
212
 
202
213
 
214
+ def _element_label(element: Dict[str, Any]) -> str:
215
+ element_id = element.get("id") or element.get("elementId") or "unknown"
216
+ element_type = element.get("type") or "element"
217
+ return f"{element_type} {element_id}"
218
+
219
+
220
+ def _find_revision(revisions: Any, active_revision_id: str) -> Optional[Dict[str, Any]]:
221
+ if not isinstance(revisions, dict):
222
+ return None
223
+ if active_revision_id in revisions:
224
+ return revisions[active_revision_id]
225
+
226
+ # Try simple camelCase match fallback since deep_snake_to_camel converts keys
227
+ camel_id = snake_to_camel(active_revision_id)
228
+ if camel_id in revisions:
229
+ return revisions[camel_id]
230
+
231
+ for r in revisions.values():
232
+ if isinstance(r, dict):
233
+ r_id = r.get("id") or r.get("key")
234
+ if r_id == active_revision_id or r_id == camel_id:
235
+ return r
236
+
237
+ if revisions:
238
+ first_val = next(iter(revisions.values()))
239
+ if isinstance(first_val, dict):
240
+ return first_val
241
+
242
+ return None
243
+
244
+
245
+ def _write_python_external_files(
246
+ project_dir: Path,
247
+ files_meta: Optional[Dict[str, Any]],
248
+ files_data: Optional[Dict[str, bytes]],
249
+ ) -> Dict[str, str]:
250
+ resolved_paths: Dict[str, str] = {}
251
+ if not files_meta or not files_data:
252
+ return resolved_paths
253
+
254
+ for file_id, meta in files_meta.items():
255
+ if not isinstance(meta, dict):
256
+ continue
257
+
258
+ active_revision_id = meta.get("activeRevisionId") or meta.get("active_revision_id")
259
+ revisions = meta.get("revisions") or {}
260
+ revision = _find_revision(revisions, active_revision_id) if active_revision_id else None
261
+ if not active_revision_id or not isinstance(revision, dict):
262
+ continue
263
+
264
+ data = files_data.get(active_revision_id)
265
+ if data is None:
266
+ continue
267
+
268
+ source_name = revision.get("sourceName") or revision.get("source_name") or "file"
269
+ safe_name = re.sub(r"[^a-zA-Z0-9._-]", "_", str(source_name).strip() or "file")
270
+ target = project_dir / "scopture-files" / str(file_id) / safe_name
271
+ target.parent.mkdir(parents=True, exist_ok=True)
272
+ target.write_bytes(bytes(data))
273
+ resolved_paths[file_id] = str(target.resolve())
274
+
275
+ return resolved_paths
276
+
277
+
278
+ def _run_python_validation(
279
+ code: str,
280
+ label: str,
281
+ timeout_seconds: Optional[float],
282
+ files_meta: Optional[Dict[str, Any]] = None,
283
+ files_data: Optional[Dict[str, bytes]] = None,
284
+ ) -> Optional[str]:
285
+ with tempfile.TemporaryDirectory(prefix="ducpy-model-") as tmpdir:
286
+ tmp_path = Path(tmpdir)
287
+ resolved_files = _write_python_external_files(tmp_path, files_meta, files_data)
288
+
289
+ import json
290
+ resolved_files_json = json.dumps(resolved_files)
291
+
292
+ header = f"""
293
+ import json
294
+ import os
295
+ import sys
296
+
297
+ _RESOLVED_EXTERNAL_FILES = json.loads({repr(resolved_files_json)})
298
+
299
+ def resolve_external_file(file_id):
300
+ if file_id in _RESOLVED_EXTERNAL_FILES:
301
+ return _RESOLVED_EXTERNAL_FILES[file_id]
302
+ raise FileNotFoundError(f"External file '{{file_id}}' not found in validation sandbox.")
303
+
304
+ globals()["resolve_external_file"] = resolve_external_file
305
+ import builtins
306
+ builtins.resolve_external_file = resolve_external_file
307
+ """
308
+ script_path = tmp_path / "model.py"
309
+ script_path.write_text(header + "\n" + code, encoding="utf-8")
310
+
311
+ run_kwargs = {
312
+ "cwd": tmpdir,
313
+ "text": True,
314
+ "stdout": subprocess.PIPE,
315
+ "stderr": subprocess.PIPE,
316
+ "check": False,
317
+ }
318
+ if timeout_seconds is not None:
319
+ run_kwargs["timeout"] = timeout_seconds
320
+
321
+ try:
322
+ completed = subprocess.run(
323
+ [sys.executable, str(script_path)],
324
+ **run_kwargs,
325
+ )
326
+ except subprocess.TimeoutExpired as exc:
327
+ return f"{label}: Python validation timed out after {timeout_seconds:g}s"
328
+ except OSError as exc:
329
+ return f"{label}: failed to start Python validation: {exc}"
330
+
331
+ if completed.returncode == 0:
332
+ return None
333
+
334
+ output = (completed.stderr or completed.stdout or "Python process exited with an error").strip()
335
+ return f"{label}: Python validation failed\n{output}"
336
+
337
+
338
+ def _write_typst_external_files(project_dir: Path, files_meta: Optional[Dict[str, Any]], files_data: Optional[Dict[str, bytes]]) -> None:
339
+ if not files_meta or not files_data:
340
+ return
341
+
342
+ for file_id, meta in files_meta.items():
343
+ if not isinstance(meta, dict):
344
+ continue
345
+
346
+ active_revision_id = meta.get("activeRevisionId") or meta.get("active_revision_id")
347
+ revisions = meta.get("revisions") or {}
348
+ revision = _find_revision(revisions, active_revision_id) if active_revision_id else None
349
+ if not active_revision_id or not isinstance(revision, dict):
350
+ continue
351
+
352
+ data = files_data.get(active_revision_id)
353
+ if data is None:
354
+ continue
355
+
356
+ source_name = revision.get("sourceName") or revision.get("source_name") or "file"
357
+ safe_name = re.sub(r"[^a-zA-Z0-9._-]", "_", str(source_name).strip() or "file")
358
+ target = project_dir / "scopture-files" / str(file_id) / safe_name
359
+ target.parent.mkdir(parents=True, exist_ok=True)
360
+ target.write_bytes(bytes(data))
361
+
362
+
363
+ def _run_typst_validation(
364
+ code: str,
365
+ label: str,
366
+ timeout_seconds: float,
367
+ files_meta: Optional[Dict[str, Any]],
368
+ files_data: Optional[Dict[str, bytes]],
369
+ ) -> Optional[str]:
370
+ try:
371
+ import typst # type: ignore[import-not-found]
372
+ except ModuleNotFoundError:
373
+ return f"{label}: Typst validation requires the 'typst' package. Install it with 'pip install typst'."
374
+
375
+ with tempfile.TemporaryDirectory(prefix="ducpy-typst-") as tmpdir:
376
+ project_dir = Path(tmpdir)
377
+ main_path = project_dir / "main.typ"
378
+ main_path.write_text(code.replace('"/scopture-files/', '"scopture-files/'), encoding="utf-8")
379
+ _write_typst_external_files(project_dir, files_meta, files_data)
380
+
381
+ try:
382
+ try:
383
+ typst.compile(str(main_path), format="pdf")
384
+ except TypeError:
385
+ typst.compile(str(main_path))
386
+ except Exception as exc:
387
+ return f"{label}: Typst validation failed\n{exc}"
388
+
389
+ return None
390
+
391
+
392
+ def _validate_embedded_code(
393
+ elements: List[Any],
394
+ files_meta: Optional[Dict[str, Any]],
395
+ files_data: Optional[Dict[str, bytes]],
396
+ timeout_seconds: float,
397
+ ) -> None:
398
+ failures: List[str] = []
399
+
400
+ for element in elements:
401
+ if not isinstance(element, dict):
402
+ continue
403
+
404
+ element_type = element.get("type")
405
+ label = _element_label(element)
406
+
407
+ if element_type == "model":
408
+ code = element.get("code")
409
+ if isinstance(code, str) and code.strip():
410
+ error = _run_python_validation(code, label, timeout_seconds, files_meta, files_data)
411
+ if error:
412
+ failures.append(error)
413
+ continue
414
+
415
+ if element_type == "doc":
416
+ text = element.get("text")
417
+ if isinstance(text, str) and text.strip():
418
+ error = _run_typst_validation(text, label, timeout_seconds, files_meta, files_data)
419
+ if error:
420
+ failures.append(error)
421
+
422
+ if failures:
423
+ raise DucSerializationValidationError(failures)
424
+
425
+
203
426
  def serialize_duc(
204
427
  name: str,
205
428
  thumbnail: Optional[bytes] = None,
@@ -215,6 +438,8 @@ def serialize_duc(
215
438
  regions: Optional[list] = None,
216
439
  layers: Optional[list] = None,
217
440
  external_files: Optional[list] = None,
441
+ validate_embedded_code: bool = True,
442
+ validation_timeout_seconds: Optional[float] = None,
218
443
  ) -> bytes:
219
444
  """Serialize elements and document state to raw ``.duc`` binary format.
220
445
 
@@ -253,6 +478,12 @@ def serialize_duc(
253
478
  List of document layers.
254
479
  external_files : Optional[list], default=None
255
480
  List of external files (e.g., embedded images or PDFs).
481
+ validate_embedded_code : bool, default=True
482
+ Validate model Python code and document source before native serialization.
483
+ This is intended for server-side CPython usage and raises
484
+ DucSerializationValidationError with per-element diagnostics on failure.
485
+ validation_timeout_seconds : float, optional, default=None
486
+ Timeout used for each embedded code validation step. If None, no timeout is applied.
256
487
 
257
488
  Returns
258
489
  -------
@@ -263,12 +494,22 @@ def serialize_duc(
263
494
 
264
495
  files_meta, files_data = _convert_external_files(external_files)
265
496
 
497
+ serialized_elements = [_element_to_camel(e) for e in (elements or [])]
498
+
499
+ if validate_embedded_code:
500
+ _validate_embedded_code(
501
+ serialized_elements,
502
+ files_meta,
503
+ files_data,
504
+ validation_timeout_seconds,
505
+ )
506
+
266
507
  data: Dict[str, Any] = {
267
508
  "type": "duc",
268
509
  "version": DUC_SCHEMA_VERSION,
269
510
  "source": f"ducpy_{name}",
270
511
  "thumbnail": thumb,
271
- "elements": [_element_to_camel(e) for e in (elements or [])],
512
+ "elements": serialized_elements,
272
513
  "blocks": _convert_list(blocks) or [],
273
514
  "blockInstances": _convert_list(block_instances) or [],
274
515
  "blockCollections": _convert_list(block_collections) or [],
@@ -1,4 +1,5 @@
1
1
  from .constants import *
2
2
  from .rand_utils import *
3
3
  from .io import *
4
- from .mutate_utils import *
4
+ from .mutate_utils import *
5
+ from .convert import *
@@ -60,7 +60,7 @@ def deep_snake_to_camel(obj: Any) -> Any:
60
60
  if is_dataclass(obj) and not isinstance(obj, type):
61
61
  return deep_snake_to_camel(asdict(obj))
62
62
  if isinstance(obj, bytes):
63
- return list(obj)
63
+ return obj
64
64
  return obj
65
65
 
66
66
 
@@ -80,7 +80,7 @@ def to_serializable(obj: Any) -> Any:
80
80
 
81
81
  Handles:
82
82
  - Dataclass instances → dict (recursively)
83
- - bytes → list of ints
83
+ - bytes → bytes (native serializer accepts PyBytes for binary fields)
84
84
  - Nested base/styles/linear_base/stack_element_base → flattened
85
85
  - snake_case keys → camelCase keys
86
86
  """
@@ -97,5 +97,49 @@ def to_serializable(obj: Any) -> Any:
97
97
  if isinstance(obj, list):
98
98
  return [to_serializable(item) for item in obj]
99
99
  if isinstance(obj, bytes):
100
- return list(obj)
100
+ return obj
101
101
  return obj
102
+
103
+
104
+ def extract_embedded_code(func) -> str:
105
+ """Extracts the body of a Python function as a clean standalone script.
106
+
107
+ This is highly useful for defining CAD/BIM model generation code as actual,
108
+ syntax-highlighted Python functions inside examples/tests, and then serializing
109
+ them to DUC.
110
+
111
+ Args:
112
+ func: The Python function/callable to extract the body from.
113
+
114
+ Returns:
115
+ The body of the function as a clean, dedented string.
116
+ """
117
+ import inspect
118
+ import textwrap
119
+
120
+ if not callable(func):
121
+ raise TypeError(f"extract_embedded_code expects a callable, got {type(func).__name__}")
122
+
123
+ try:
124
+ source = inspect.getsource(func)
125
+ except OSError as exc:
126
+ raise ValueError(
127
+ f"Could not retrieve source code for {func.__name__ if hasattr(func, '__name__') else func}. "
128
+ "Ensure the function is defined in a physical file rather than dynamically."
129
+ ) from exc
130
+
131
+ lines = source.splitlines()
132
+
133
+ # Locate the starting 'def ' statement (skipping decorators)
134
+ try:
135
+ def_idx = next(i for i, line in enumerate(lines) if line.strip().startswith("def "))
136
+ except StopIteration:
137
+ # If no 'def ' was found (e.g. lambda), return the whole source dedented
138
+ return textwrap.dedent(source)
139
+
140
+ body_lines = lines[def_idx + 1:]
141
+
142
+ # Reconstruct and dedent the body
143
+ body_content = "\n".join(body_lines)
144
+ return textwrap.dedent(body_content)
145
+
@@ -1,20 +0,0 @@
1
- -- Migration: 3000003 → 3000004
2
- -- Rename element_model.svg_path (TEXT) to thumbnail (BLOB).
3
- -- SQLite does not support ALTER COLUMN, so we recreate the table.
4
-
5
- CREATE TABLE element_model_new (
6
- element_id TEXT PRIMARY KEY REFERENCES elements(id) ON DELETE CASCADE,
7
- model_type TEXT,
8
- code TEXT,
9
- thumbnail BLOB
10
- );
11
-
12
- INSERT INTO element_model_new (element_id, model_type, code, thumbnail)
13
- SELECT element_id, model_type, code, CAST(svg_path AS BLOB)
14
- FROM element_model;
15
-
16
- DROP TABLE element_model;
17
-
18
- ALTER TABLE element_model_new RENAME TO element_model;
19
-
20
- CREATE INDEX idx_element_model_type ON element_model(model_type);
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes