ducpy 3.3.8__tar.gz → 3.5.0__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 (64) hide show
  1. {ducpy-3.3.8 → ducpy-3.5.0}/Cargo.lock +1 -1
  2. ducpy-3.5.0/PKG-INFO +108 -0
  3. ducpy-3.5.0/README.md +87 -0
  4. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducpy/crate/Cargo.toml +1 -1
  5. ducpy-3.5.0/packages/ducrs/README.md +54 -0
  6. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/schema/duc.sql +1 -1
  7. ducpy-3.5.0/packages/ducrs/schema/migrations/3000004_to_3000005.sql +51 -0
  8. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/schema/search.sql +49 -1
  9. {ducpy-3.3.8 → ducpy-3.5.0}/pyproject.toml +13 -0
  10. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/builders/sql_builder.py +118 -14
  11. ducpy-3.5.0/src/ducpy/search/__init__.py +19 -0
  12. ducpy-3.5.0/src/ducpy/search/image_ocr.py +79 -0
  13. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/search/search_elements.py +398 -89
  14. ducpy-3.5.0/src/ducpy/search/search_external_files.py +608 -0
  15. ducpy-3.5.0/src/ducpy/search/search_pdf.py +95 -0
  16. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/serialize.py +45 -1
  17. ducpy-3.3.8/PKG-INFO +0 -83
  18. ducpy-3.3.8/README.md +0 -65
  19. ducpy-3.3.8/packages/ducrs/README.md +0 -52
  20. ducpy-3.3.8/src/ducpy/search/__init__.py +0 -13
  21. {ducpy-3.3.8 → ducpy-3.5.0}/Cargo.toml +0 -0
  22. {ducpy-3.3.8 → ducpy-3.5.0}/LICENSE +0 -0
  23. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducpy/crate/src/lib.rs +0 -0
  24. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/Cargo.toml +0 -0
  25. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/LICENSE +0 -0
  26. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/build.rs +0 -0
  27. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/schema/migrations/3000000_to_3000001.sql +0 -0
  28. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/schema/migrations/3000001_to_3000002.sql +0 -0
  29. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/schema/migrations/3000002_to_3000003.sql +0 -0
  30. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/schema/migrations/3000003_to_3000004.sql +0 -0
  31. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/schema/version_control.sql +0 -0
  32. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/src/api/document.rs +0 -0
  33. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/src/api/meta.rs +0 -0
  34. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/src/api/mod.rs +0 -0
  35. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/src/api/version_control.rs +0 -0
  36. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/src/db/bootstrap.rs +0 -0
  37. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/src/db/mod.rs +0 -0
  38. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/src/db/native.rs +0 -0
  39. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/src/db/wasm.rs +0 -0
  40. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/src/lib.rs +0 -0
  41. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/src/parse.rs +0 -0
  42. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/src/serde_utils.rs +0 -0
  43. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/src/serialize.rs +0 -0
  44. {ducpy-3.3.8 → ducpy-3.5.0}/packages/ducrs/src/types.rs +0 -0
  45. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/__init__.py +0 -0
  46. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/builders/__init__.py +0 -0
  47. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/builders/block_instance_builder.py +0 -0
  48. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/builders/block_utils.py +0 -0
  49. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/builders/element_builders.py +0 -0
  50. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/builders/mutate_builder.py +0 -0
  51. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/builders/state_builders.py +0 -0
  52. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/builders/style_builders.py +0 -0
  53. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/classes/DataStateClass.py +0 -0
  54. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/classes/ElementsClass.py +0 -0
  55. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/classes/__init__.py +0 -0
  56. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/enums.py +0 -0
  57. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/parse.py +0 -0
  58. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/utils/__init__.py +0 -0
  59. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/utils/constants.py +0 -0
  60. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/utils/convert.py +0 -0
  61. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/utils/io.py +0 -0
  62. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/utils/mutate_utils.py +0 -0
  63. {ducpy-3.3.8 → ducpy-3.5.0}/src/ducpy/utils/rand_utils.py +0 -0
  64. {ducpy-3.3.8 → ducpy-3.5.0}/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.8"
415
+ version = "3.5.0"
416
416
  dependencies = [
417
417
  "duc",
418
418
  "pyo3",
ducpy-3.5.0/PKG-INFO ADDED
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: ducpy
3
+ Version: 3.5.0
4
+ Classifier: License :: OSI Approved :: MIT License
5
+ Requires-Dist: nanoid>=2.0.0
6
+ Requires-Dist: pypdf>=4.2.0
7
+ Requires-Dist: rapidocr-onnxruntime>=1.3.24 ; extra == 'ocr'
8
+ Requires-Dist: numpy>=1.20.0 ; extra == 'ocr'
9
+ Provides-Extra: ocr
10
+ License-File: LICENSE
11
+ Summary: The library for the Duc 2D CAD file format
12
+ Author: Jorge Soares
13
+ Author-email: Ducflair <support@ducflair.com>
14
+ License: MIT
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
17
+ Project-URL: Documentation, https://ducflair.github.io/duc/reference/python/
18
+ Project-URL: Homepage, https://duc.ducflair.com
19
+ Project-URL: Package Index, https://ducflair.github.io/duc/simple/ducpy/
20
+ Project-URL: Source, https://github.com/ducflair/duc/tree/main/packages/ducpy
21
+
22
+ # ducpy
23
+
24
+ <p align="center">
25
+ <br/>
26
+ <a href="https://duc.ducflair.com" target="_blank"><img width="256px" src="https://cdn.jsdelivr.net/gh/ducflair/assets@main/src/duc/duc-extended.png" /></a>
27
+ <p align="center">2D CAD File Format</p>
28
+ <p align="center" style="align: center;">
29
+ <a href="https://pypi.org/project/ducpy/"><img src="https://shields.io/badge/Pip-blue?logo=Pypi&logoColor=white&style=round-square" alt="Pip" /></a>
30
+ <a href="https://github.com/ducflair/duc/releases"><img src="https://img.shields.io/pypi/v/ducpy?style=round-square&label=latest%20stable" alt="PyPI ducpy@latest release" /></a>
31
+ <a href="https://pypi.org/project/ducpy/"><img src="https://img.shields.io/pypi/dm/ducpy?style=round-square&color=salmon" alt="Downloads" /></a>
32
+ <img src="https://shields.io/badge/Python-ffde57?logo=Python&logoColor=646464&style=round-square" alt="Python" />
33
+ </p>
34
+ </p>
35
+
36
+ The `ducpy` package is the official Python implementation of the `.duc` 2D CAD file format. It is built on top of the [`duc`](../ducrs) Rust crate, exposed as the `ducpy_native` extension. A `.duc` file is a zlib-compressed SQLite database — `ducpy` gives you both a high-level builder DSL and direct low-level access to that schema, plus parsing, serialization, and search helpers.
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install ducpy
42
+ ```
43
+
44
+ or with [`uv`](https://docs.astral.sh/uv/):
45
+
46
+ ```bash
47
+ uv add ducpy
48
+ ```
49
+
50
+ ## Quick start
51
+
52
+ ```python
53
+ import ducpy as duc
54
+ ```
55
+
56
+ ## API overview
57
+
58
+ `ducpy` is organised around four conceptual entry points, matching the structure of the full [API reference](https://duc.ducflair.com):
59
+
60
+ ### Builders API (High-level)
61
+
62
+ The easy way to build and manage `.duc` files. Construct elements, apply styles, manage layers, build blocks, and handle document state through the `duc.builders` module.
63
+
64
+ See the worked examples:
65
+
66
+ - [Element creation](src/examples/element_creation_demo.py) — building rectangles, ellipses, polygons, lines, arrows, text, frames and plots with the fluent builder DSL.
67
+ - [Mutating elements](src/examples/mutation_demo.py) — updating element properties in place and observing version changes.
68
+ - [Document elements](src/examples/document_element_demo.py) and [model elements](src/examples/model_element_demo.py) — for the higher-level container / model element types.
69
+
70
+ ### SQL Builder (Low-level)
71
+
72
+ A `.duc` file is a zlib-compressed SQLite database. Use `duc.builders.sql_builder` (`DucSQL`) for direct schema access, bulk queries, and low-level manipulation.
73
+
74
+ See the worked example: [SQL Builder](src/examples/sql_builder_demo.py) — `DucSQL.new()` to create a `.duc` from scratch, `DucSQL(path)` to query an existing one.
75
+
76
+ ### Search
77
+
78
+ Query and search elements and files programmatically through the `duc.search` API.
79
+
80
+ ### File I/O
81
+
82
+ Read and write `.duc` files using the `duc.parse` and `duc.serialize` modules.
83
+
84
+ See the worked examples:
85
+
86
+ - [Parsing](src/examples/parsing_demo.py) — `parse_duc` / `parse_duc_lazy` / `get_external_file` / `list_external_files`.
87
+ - [Serialization](src/examples/serialization_demo.py) — `serialize_duc` from builder-created elements.
88
+ - [External files](src/examples/external_files_demo.py) — attaching binary blobs (images, PDFs) to a `.duc` document.
89
+
90
+ ## Documentation
91
+
92
+ For detailed documentation, including the full API reference, schema specification, and end-to-end examples, see:
93
+
94
+ - API reference & guides: [duc.ducflair.com](https://duc.ducflair.com)
95
+ - Source-level reference: [docs/](docs/index.rst)
96
+ - Full example index: [docs/examples.rst](docs/examples.rst)
97
+
98
+ ## Tools
99
+
100
+ - [Playground](https://scopture.com): Experiment with the `duc` format in a live environment.
101
+
102
+ ## Contributing
103
+
104
+ Feel free to open an issue or contact us at [Ducflair Support](https://www.ducflair.com/support).
105
+
106
+ ## License
107
+
108
+ This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for more details.
ducpy-3.5.0/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # ducpy
2
+
3
+ <p align="center">
4
+ <br/>
5
+ <a href="https://duc.ducflair.com" target="_blank"><img width="256px" src="https://cdn.jsdelivr.net/gh/ducflair/assets@main/src/duc/duc-extended.png" /></a>
6
+ <p align="center">2D CAD File Format</p>
7
+ <p align="center" style="align: center;">
8
+ <a href="https://pypi.org/project/ducpy/"><img src="https://shields.io/badge/Pip-blue?logo=Pypi&logoColor=white&style=round-square" alt="Pip" /></a>
9
+ <a href="https://github.com/ducflair/duc/releases"><img src="https://img.shields.io/pypi/v/ducpy?style=round-square&label=latest%20stable" alt="PyPI ducpy@latest release" /></a>
10
+ <a href="https://pypi.org/project/ducpy/"><img src="https://img.shields.io/pypi/dm/ducpy?style=round-square&color=salmon" alt="Downloads" /></a>
11
+ <img src="https://shields.io/badge/Python-ffde57?logo=Python&logoColor=646464&style=round-square" alt="Python" />
12
+ </p>
13
+ </p>
14
+
15
+ The `ducpy` package is the official Python implementation of the `.duc` 2D CAD file format. It is built on top of the [`duc`](../ducrs) Rust crate, exposed as the `ducpy_native` extension. A `.duc` file is a zlib-compressed SQLite database — `ducpy` gives you both a high-level builder DSL and direct low-level access to that schema, plus parsing, serialization, and search helpers.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pip install ducpy
21
+ ```
22
+
23
+ or with [`uv`](https://docs.astral.sh/uv/):
24
+
25
+ ```bash
26
+ uv add ducpy
27
+ ```
28
+
29
+ ## Quick start
30
+
31
+ ```python
32
+ import ducpy as duc
33
+ ```
34
+
35
+ ## API overview
36
+
37
+ `ducpy` is organised around four conceptual entry points, matching the structure of the full [API reference](https://duc.ducflair.com):
38
+
39
+ ### Builders API (High-level)
40
+
41
+ The easy way to build and manage `.duc` files. Construct elements, apply styles, manage layers, build blocks, and handle document state through the `duc.builders` module.
42
+
43
+ See the worked examples:
44
+
45
+ - [Element creation](src/examples/element_creation_demo.py) — building rectangles, ellipses, polygons, lines, arrows, text, frames and plots with the fluent builder DSL.
46
+ - [Mutating elements](src/examples/mutation_demo.py) — updating element properties in place and observing version changes.
47
+ - [Document elements](src/examples/document_element_demo.py) and [model elements](src/examples/model_element_demo.py) — for the higher-level container / model element types.
48
+
49
+ ### SQL Builder (Low-level)
50
+
51
+ A `.duc` file is a zlib-compressed SQLite database. Use `duc.builders.sql_builder` (`DucSQL`) for direct schema access, bulk queries, and low-level manipulation.
52
+
53
+ See the worked example: [SQL Builder](src/examples/sql_builder_demo.py) — `DucSQL.new()` to create a `.duc` from scratch, `DucSQL(path)` to query an existing one.
54
+
55
+ ### Search
56
+
57
+ Query and search elements and files programmatically through the `duc.search` API.
58
+
59
+ ### File I/O
60
+
61
+ Read and write `.duc` files using the `duc.parse` and `duc.serialize` modules.
62
+
63
+ See the worked examples:
64
+
65
+ - [Parsing](src/examples/parsing_demo.py) — `parse_duc` / `parse_duc_lazy` / `get_external_file` / `list_external_files`.
66
+ - [Serialization](src/examples/serialization_demo.py) — `serialize_duc` from builder-created elements.
67
+ - [External files](src/examples/external_files_demo.py) — attaching binary blobs (images, PDFs) to a `.duc` document.
68
+
69
+ ## Documentation
70
+
71
+ For detailed documentation, including the full API reference, schema specification, and end-to-end examples, see:
72
+
73
+ - API reference & guides: [duc.ducflair.com](https://duc.ducflair.com)
74
+ - Source-level reference: [docs/](docs/index.rst)
75
+ - Full example index: [docs/examples.rst](docs/examples.rst)
76
+
77
+ ## Tools
78
+
79
+ - [Playground](https://scopture.com): Experiment with the `duc` format in a live environment.
80
+
81
+ ## Contributing
82
+
83
+ Feel free to open an issue or contact us at [Ducflair Support](https://www.ducflair.com/support).
84
+
85
+ ## License
86
+
87
+ This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for more details.
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ducpy-native"
3
- version = "3.3.8"
3
+ version = "3.5.0"
4
4
  edition = "2021"
5
5
  description = "Python native extension for the duc file format (used by ducpy)"
6
6
  publish = false
@@ -0,0 +1,54 @@
1
+ # duc (Rust)
2
+
3
+ <p align="center">
4
+ <br/>
5
+ <a href="https://duc.ducflair.com" target="_blank"><img width="256px" src="https://cdn.jsdelivr.net/gh/ducflair/assets@main/src/duc/duc-extended.png" /></a>
6
+ <p align="center">2D CAD File Format</p>
7
+ <p align="center" style="align: center;">
8
+ <a href="https://crates.io/crates/duc"><img src="https://shields.io/badge/Crates-FFC933?logo=Rust&logoColor=646464&style=round-square" alt="Crates" /></a>
9
+ <a href="https://github.com/ducflair/duc/releases"><img src="https://img.shields.io/crates/v/duc?style=round-square&label=latest%20stable" alt="crates.io duc@latest release" /></a>
10
+ <a href="https://crates.io/crates/duc"><img src="https://img.shields.io/crates/d/duc?style=round-square&color=salmon" alt="Downloads" /></a>
11
+ <img src="https://shields.io/badge/Rust-CE412B?logo=Rust&logoColor=fff&style=round-square" alt="Rust" />
12
+ </p>
13
+ </p>
14
+
15
+ The `duc` crate is the canonical Rust implementation of the `.duc` 2D CAD file format and the **root of the entire Duc ecosystem**. [`ducpy`](https://pypi.org/project/ducpy/), [`ducjs`](https://www.npmjs.com/package/ducjs), and the rest of the toolchain are all built on top of the types, parsers and storage layer defined here.
16
+
17
+ A `.duc` file is a standard zlib compressed **SQLite database file** following the schema in `schema/duc.sql`. This crate wraps that database in a strongly-typed Rust API for reading, writing and manipulating `.duc` documents.
18
+
19
+
20
+ ----
21
+ <br/>
22
+ <br/>
23
+ <br/>
24
+
25
+ ## Schema and migrations
26
+
27
+ The canonical SQL lives at `schema/duc.sql` (plus `schema/version_control.sql` and `schema/search.sql`) and is **embedded at compile time** by [`build.rs`](build.rs)
28
+
29
+ Migrations are ran and handled **automatically** upon usage of the library. Each migration is a SQL file named `<from>_to_<to>.sql` in `schema/migrations/`. `build.rs` scans that directory, sorts by `from_version` and emits a static `MIGRATIONS` array. Adding a new migration requires **no Rust changes** — drop the file in and rebuild.
30
+
31
+ ## Features
32
+
33
+ | Feature | Default | Description |
34
+ |---------|---------|-------------|
35
+ | `default` | ✅ | Native build with bundled SQLite |
36
+ | `opfs` | ❌ | OPFS SAH-pool VFS for `wasm32-unknown-unknown` (browser persistence) |
37
+
38
+
39
+ ## Documentation
40
+
41
+ - Full API reference: [docs.rs/duc](https://docs.rs/duc)
42
+ - Format specification, concepts and guides: [duc.ducflair.com](https://duc.ducflair.com)
43
+
44
+ ## Tools
45
+
46
+ - [Playground](https://ducflair.com/core): Experiment with the `.duc` format in the browser.
47
+
48
+ ## Contributing
49
+
50
+ Feel free to open an issue or contact us at [Ducflair Support](https://www.ducflair.com/support).
51
+
52
+ ## License
53
+
54
+ This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for more details.
@@ -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 = 3000004;
4
+ PRAGMA user_version = 3000005;
5
5
  PRAGMA journal_mode = WAL;
6
6
  PRAGMA foreign_keys = ON;
7
7
  PRAGMA synchronous = NORMAL;
@@ -0,0 +1,51 @@
1
+ -- Migration: 3000003 → 3000004
2
+ -- Add searchable index for extracted external-file text (PDF content search).
3
+
4
+ CREATE TABLE IF NOT EXISTS external_file_text_index (
5
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6
+ file_id TEXT NOT NULL,
7
+ revision_id TEXT NOT NULL,
8
+ mime_type TEXT NOT NULL,
9
+ extracted_text TEXT NOT NULL DEFAULT '',
10
+ has_ocr INTEGER NOT NULL DEFAULT 0,
11
+ updated INTEGER NOT NULL,
12
+ UNIQUE (file_id, revision_id)
13
+ );
14
+
15
+ CREATE INDEX IF NOT EXISTS idx_external_file_text_index_file_id
16
+ ON external_file_text_index(file_id);
17
+
18
+ CREATE INDEX IF NOT EXISTS idx_external_file_text_index_revision_id
19
+ ON external_file_text_index(revision_id);
20
+
21
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_external_file_text USING fts5(
22
+ extracted_text,
23
+ content='external_file_text_index',
24
+ content_rowid='id',
25
+ tokenize='unicode61 remove_diacritics 2',
26
+ prefix='2 3 4 5 6 7 8 9 10'
27
+ );
28
+
29
+ CREATE TRIGGER IF NOT EXISTS trg_external_file_text_index_ai
30
+ AFTER INSERT ON external_file_text_index BEGIN
31
+ INSERT INTO search_external_file_text(rowid, extracted_text)
32
+ VALUES (NEW.id, NEW.extracted_text);
33
+ END;
34
+
35
+ CREATE TRIGGER IF NOT EXISTS trg_external_file_text_index_ad
36
+ AFTER DELETE ON external_file_text_index BEGIN
37
+ INSERT INTO search_external_file_text(search_external_file_text, rowid, extracted_text)
38
+ VALUES ('delete', OLD.id, OLD.extracted_text);
39
+ END;
40
+
41
+ CREATE TRIGGER IF NOT EXISTS trg_external_file_text_index_au
42
+ AFTER UPDATE OF extracted_text ON external_file_text_index BEGIN
43
+ INSERT INTO search_external_file_text(search_external_file_text, rowid, extracted_text)
44
+ VALUES ('delete', OLD.id, OLD.extracted_text);
45
+ INSERT INTO search_external_file_text(rowid, extracted_text)
46
+ VALUES (NEW.id, NEW.extracted_text);
47
+ END;
48
+
49
+ INSERT INTO search_external_file_text(search_external_file_text) VALUES ('rebuild');
50
+
51
+ PRAGMA user_version = 3000005;
@@ -5,7 +5,8 @@
5
5
  -- Triggers keep the FTS index automatically in sync on every INSERT/UPDATE/DELETE.
6
6
  -- No application-level sync needed.
7
7
  --
8
- -- Depends on: duc.sql (elements, element_text, element_doc, element_model, blocks)
8
+ -- Depends on: duc.sql (elements, element_text, element_doc, element_model, blocks,
9
+ -- external_files, external_file_revisions, external_file_revision_data, document_grid_config)
9
10
  --
10
11
  -- Query examples:
11
12
  -- SELECT rowid, rank FROM search_elements WHERE search_elements MATCH 'motor';
@@ -129,6 +130,52 @@ CREATE TRIGGER IF NOT EXISTS trg_blocks_ai AFTER INSERT ON blocks BEGIN
129
130
  INSERT INTO search_blocks(rowid, label, description)
130
131
  VALUES (NEW.rowid, NEW.label, NEW.description);
131
132
  END;
133
+
134
+ -- Extracted text index for external file revisions (used for PDF content search).
135
+ CREATE TABLE IF NOT EXISTS external_file_text_index (
136
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
137
+ file_id TEXT NOT NULL,
138
+ revision_id TEXT NOT NULL,
139
+ mime_type TEXT NOT NULL,
140
+ extracted_text TEXT NOT NULL DEFAULT '',
141
+ has_ocr INTEGER NOT NULL DEFAULT 0,
142
+ updated INTEGER NOT NULL,
143
+ UNIQUE (file_id, revision_id)
144
+ );
145
+
146
+ CREATE INDEX IF NOT EXISTS idx_external_file_text_index_file_id
147
+ ON external_file_text_index(file_id);
148
+
149
+ CREATE INDEX IF NOT EXISTS idx_external_file_text_index_revision_id
150
+ ON external_file_text_index(revision_id);
151
+
152
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_external_file_text USING fts5(
153
+ extracted_text,
154
+ content='external_file_text_index',
155
+ content_rowid='id',
156
+ tokenize='unicode61 remove_diacritics 2',
157
+ prefix='2 3 4 5 6 7 8 9 10'
158
+ );
159
+
160
+ CREATE TRIGGER IF NOT EXISTS trg_external_file_text_index_ai
161
+ AFTER INSERT ON external_file_text_index BEGIN
162
+ INSERT INTO search_external_file_text(rowid, extracted_text)
163
+ VALUES (NEW.id, NEW.extracted_text);
164
+ END;
165
+
166
+ CREATE TRIGGER IF NOT EXISTS trg_external_file_text_index_ad
167
+ AFTER DELETE ON external_file_text_index BEGIN
168
+ INSERT INTO search_external_file_text(search_external_file_text, rowid, extracted_text)
169
+ VALUES ('delete', OLD.id, OLD.extracted_text);
170
+ END;
171
+
172
+ CREATE TRIGGER IF NOT EXISTS trg_external_file_text_index_au
173
+ AFTER UPDATE OF extracted_text ON external_file_text_index BEGIN
174
+ INSERT INTO search_external_file_text(search_external_file_text, rowid, extracted_text)
175
+ VALUES ('delete', OLD.id, OLD.extracted_text);
176
+ INSERT INTO search_external_file_text(rowid, extracted_text)
177
+ VALUES (NEW.id, NEW.extracted_text);
178
+ END;
132
179
  CREATE TRIGGER IF NOT EXISTS trg_blocks_ad AFTER DELETE ON blocks BEGIN
133
180
  INSERT INTO search_blocks(search_blocks, rowid, label, description)
134
181
  VALUES ('delete', OLD.rowid, OLD.label, OLD.description);
@@ -146,3 +193,4 @@ INSERT INTO search_element_text(search_element_text) VALUES ('rebuild');
146
193
  INSERT INTO search_element_doc(search_element_doc) VALUES ('rebuild');
147
194
  INSERT INTO search_element_model(search_element_model) VALUES ('rebuild');
148
195
  INSERT INTO search_blocks(search_blocks) VALUES ('rebuild');
196
+ INSERT INTO search_external_file_text(search_external_file_text) VALUES ('rebuild');
@@ -6,6 +6,7 @@ readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  dependencies = [
8
8
  "nanoid>=2.0.0",
9
+ "pypdf>=4.2.0"
9
10
  ]
10
11
 
11
12
  license = { text = "MIT" }
@@ -19,6 +20,12 @@ classifiers = [
19
20
  "License :: OSI Approved :: MIT License",
20
21
  ]
21
22
 
23
+ [project.optional-dependencies]
24
+ ocr = [
25
+ "rapidocr-onnxruntime>=1.3.24",
26
+ "numpy>=1.20.0",
27
+ ]
28
+
22
29
  [project.urls]
23
30
  "Homepage" = "https://duc.ducflair.com"
24
31
  "Source" = "https://github.com/ducflair/duc/tree/main/packages/ducpy"
@@ -44,11 +51,14 @@ where = ["src"]
44
51
 
45
52
  [dependency-groups]
46
53
  dev = [
54
+ "ducpy[ocr]",
55
+ "maturin>=1.0,<2.0",
47
56
  "furo>=2024.8.6",
48
57
  "sphinx>=8.1.3",
49
58
  "sphinx-autoapi>=3.6.0",
50
59
  "pytest>=8.3.5",
51
60
  "rich>=13.0.0",
61
+ "numpy>=1.20.0",
52
62
  "typst>=0.14.9",
53
63
  "build123d>=0.10.0",
54
64
  "ocp-vscode>=3.4.0",
@@ -60,3 +70,6 @@ dev = [
60
70
  testpaths = ["src/tests"]
61
71
  python_files = "test_*.py"
62
72
  addopts = "-vv"
73
+ markers = [
74
+ "slow: marks tests as slow (OCR-heavy, etc.)",
75
+ ]
@@ -31,12 +31,71 @@ from __future__ import annotations
31
31
  import os
32
32
  import sqlite3
33
33
  import tempfile
34
+ import zlib
34
35
  from pathlib import Path
35
- from typing import Any, List, Optional, Union
36
+ from typing import Any, List, Optional, Sequence, Union
36
37
 
37
38
  import ducpy_native
38
39
 
39
- __all__ = ["DucSQL"]
40
+ __all__ = ["DucSQL", "quote_sql_identifier"]
41
+ SQLITE_HEADER_MAGIC = b"SQLite format 3\x00"
42
+
43
+
44
+ def quote_sql_identifier(value: str) -> str:
45
+ """Quote a SQLite identifier such as an attached database alias."""
46
+ if not value:
47
+ raise ValueError("SQLite identifier cannot be empty.")
48
+ return '"' + value.replace('"', '""') + '"'
49
+
50
+
51
+ def _is_sqlite_bytes(data: bytes) -> bool:
52
+ return data.startswith(SQLITE_HEADER_MAGIC)
53
+
54
+
55
+ def _normalize_sqlite_image(data: bytes) -> bytes:
56
+ image = bytearray(data)
57
+ if len(image) > 19:
58
+ if image[18] == 2:
59
+ image[18] = 1
60
+ if image[19] == 2:
61
+ image[19] = 1
62
+ return bytes(image)
63
+
64
+
65
+ def _inflate_duc_bytes(data: bytes) -> bytes:
66
+ if _is_sqlite_bytes(data):
67
+ return _normalize_sqlite_image(data)
68
+ inflated = zlib.decompress(data, -zlib.MAX_WBITS)
69
+ if not _is_sqlite_bytes(inflated):
70
+ raise ValueError("decompressed .duc payload does not start with a SQLite header")
71
+ return _normalize_sqlite_image(inflated)
72
+
73
+
74
+ def _deflate_duc_bytes(data: bytes) -> bytes:
75
+ compressor = zlib.compressobj(level=-1, wbits=-zlib.MAX_WBITS)
76
+ return compressor.compress(data) + compressor.flush()
77
+
78
+
79
+ def _write_temp_sqlite(data: bytes) -> str:
80
+ tmp = tempfile.NamedTemporaryFile(suffix=".duc", delete=False)
81
+ try:
82
+ tmp.write(_inflate_duc_bytes(data))
83
+ tmp.close()
84
+ return tmp.name
85
+ except Exception:
86
+ tmp.close()
87
+ os.unlink(tmp.name)
88
+ raise
89
+
90
+
91
+ def _sqlite_path_for_duc(path: Union[str, Path]) -> tuple[str, Optional[str]]:
92
+ path = str(path)
93
+ with open(path, "rb") as f:
94
+ data = f.read()
95
+ if _is_sqlite_bytes(data):
96
+ return path, None
97
+ temp_path = _write_temp_sqlite(data)
98
+ return temp_path, temp_path
40
99
 
41
100
 
42
101
  def _get_current_schema_version() -> int:
@@ -105,12 +164,14 @@ class DucSQL:
105
164
  path = str(path)
106
165
  if not os.path.exists(path):
107
166
  raise FileNotFoundError(f"File not found: {path}")
108
- self.conn: sqlite3.Connection = sqlite3.connect(path)
167
+ sqlite_path, temp_path = _sqlite_path_for_duc(path)
168
+ self.conn: sqlite3.Connection = sqlite3.connect(sqlite_path)
109
169
  self.conn.row_factory = sqlite3.Row
110
170
  _apply_pragmas(self.conn)
111
171
  _apply_migrations(self.conn)
112
- self._path: Optional[str] = path
113
- self._temp: Optional[str] = None
172
+ self._path: Optional[str] = sqlite_path
173
+ self._temp: Optional[str] = temp_path
174
+ self._attached_temps: list[str] = []
114
175
  self._closed = False
115
176
 
116
177
  @classmethod
@@ -126,29 +187,68 @@ class DucSQL:
126
187
  inst.conn.executescript(_read_schema_sql())
127
188
  inst._path = target if path else None
128
189
  inst._temp = None
190
+ inst._attached_temps = []
129
191
  inst._closed = False
130
192
  return inst
131
193
 
132
194
  @classmethod
133
195
  def from_bytes(cls, data: bytes) -> DucSQL:
134
196
  """Open a ``.duc`` from raw bytes (temp file, cleaned up on close)."""
135
- tmp = tempfile.NamedTemporaryFile(suffix=".duc", delete=False)
197
+ temp_path = _write_temp_sqlite(data)
136
198
  try:
137
- tmp.write(data)
138
- tmp.close()
139
199
  inst = object.__new__(cls)
140
- inst.conn = sqlite3.connect(tmp.name)
200
+ inst.conn = sqlite3.connect(temp_path)
141
201
  inst.conn.row_factory = sqlite3.Row
142
202
  _apply_pragmas(inst.conn)
143
203
  _apply_migrations(inst.conn)
144
- inst._path = tmp.name
145
- inst._temp = tmp.name
204
+ inst._path = temp_path
205
+ inst._temp = temp_path
206
+ inst._attached_temps = []
146
207
  inst._closed = False
147
208
  return inst
148
209
  except Exception:
149
- os.unlink(tmp.name)
210
+ os.unlink(temp_path)
150
211
  raise
151
212
 
213
+ @classmethod
214
+ def attach_many(
215
+ cls,
216
+ paths: Sequence[Union[str, Path]],
217
+ aliases: Optional[Sequence[str]] = None,
218
+ read_only: bool = True,
219
+ ) -> DucSQL:
220
+ """Open an in-memory SQLite connection with multiple ``.duc`` files attached.
221
+
222
+ This is useful for querying multiple drawings in one SQL statement. The
223
+ returned ``DucSQL`` owns only the in-memory connection; attached files are
224
+ not deleted on close.
225
+ """
226
+ if aliases is not None and len(aliases) != len(paths):
227
+ raise ValueError("aliases must have the same length as paths.")
228
+
229
+ inst = object.__new__(cls)
230
+ inst.conn = sqlite3.connect(":memory:")
231
+ inst.conn.row_factory = sqlite3.Row
232
+ inst._path = None
233
+ inst._temp = None
234
+ inst._attached_temps = []
235
+ inst._closed = False
236
+
237
+ for index, path in enumerate(paths):
238
+ alias = aliases[index] if aliases is not None else f"d{index}"
239
+ sqlite_path, temp_path = _sqlite_path_for_duc(path)
240
+ if temp_path:
241
+ inst._attached_temps.append(temp_path)
242
+ inst.conn.execute(
243
+ f"ATTACH DATABASE ? AS {quote_sql_identifier(alias)}",
244
+ (sqlite_path,),
245
+ )
246
+
247
+ if read_only:
248
+ inst.conn.execute("PRAGMA query_only = ON")
249
+
250
+ return inst
251
+
152
252
  # ------------------------------------------------------------------
153
253
  # SQL execution
154
254
  # ------------------------------------------------------------------
@@ -186,7 +286,7 @@ class DucSQL:
186
286
  finally:
187
287
  dst.close()
188
288
 
189
- def to_bytes(self) -> bytes:
289
+ def to_bytes(self, compressed: bool = False) -> bytes:
190
290
  """Export the database as raw bytes."""
191
291
  self.commit()
192
292
  tmp = tempfile.NamedTemporaryFile(suffix=".duc", delete=False)
@@ -196,7 +296,8 @@ class DucSQL:
196
296
  self.conn.backup(dst)
197
297
  dst.close()
198
298
  with open(tmp.name, "rb") as f:
199
- return f.read()
299
+ data = f.read()
300
+ return _deflate_duc_bytes(data) if compressed else data
200
301
  finally:
201
302
  os.unlink(tmp.name)
202
303
 
@@ -210,6 +311,9 @@ class DucSQL:
210
311
  self._closed = True
211
312
  if self._temp and os.path.exists(self._temp):
212
313
  os.unlink(self._temp)
314
+ for temp_path in getattr(self, "_attached_temps", []):
315
+ if os.path.exists(temp_path):
316
+ os.unlink(temp_path)
213
317
 
214
318
  def __enter__(self) -> DucSQL:
215
319
  return self
@@ -0,0 +1,19 @@
1
+ """Search helpers for DUC SQLite databases."""
2
+
3
+ from .search_elements import (
4
+ DucElementSearchResult,
5
+ DucFileSearchResult,
6
+ DucSearchResponse,
7
+ DucSearchResult,
8
+ ExternalFileSearchTarget,
9
+ search_duc_elements,
10
+ )
11
+
12
+ __all__ = [
13
+ "DucElementSearchResult",
14
+ "DucFileSearchResult",
15
+ "DucSearchResponse",
16
+ "DucSearchResult",
17
+ "ExternalFileSearchTarget",
18
+ "search_duc_elements",
19
+ ]