edof 4.2.0__tar.gz → 4.2.2__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 (88) hide show
  1. {edof-4.2.0 → edof-4.2.2}/PKG-INFO +12 -10
  2. {edof-4.2.0 → edof-4.2.2}/README.md +9 -8
  3. {edof-4.2.0 → edof-4.2.2}/edof/__init__.py +2 -1
  4. edof-4.2.2/edof/_apps/assets/__init__.py +34 -0
  5. edof-4.2.2/edof/_apps/assets/icons/__init__.py +1 -0
  6. edof-4.2.2/edof/_apps/assets/icons/edof-document-512.png +0 -0
  7. edof-4.2.2/edof/_apps/assets/icons/edof-document.icns +0 -0
  8. edof-4.2.2/edof/_apps/assets/icons/edof-document.ico +0 -0
  9. edof-4.2.2/edof/_apps/assets/icons/edof-document.png +0 -0
  10. edof-4.2.2/edof/_apps/assets/icons/edof-editor-512.png +0 -0
  11. edof-4.2.2/edof/_apps/assets/icons/edof-editor.icns +0 -0
  12. edof-4.2.2/edof/_apps/assets/icons/edof-editor.ico +0 -0
  13. edof-4.2.2/edof/_apps/assets/icons/edof-editor.png +0 -0
  14. edof-4.2.2/edof/_apps/assets/icons/edof-viewer-512.png +0 -0
  15. edof-4.2.2/edof/_apps/assets/icons/edof-viewer.icns +0 -0
  16. edof-4.2.2/edof/_apps/assets/icons/edof-viewer.ico +0 -0
  17. edof-4.2.2/edof/_apps/assets/icons/edof-viewer.png +0 -0
  18. {edof-4.2.0 → edof-4.2.2}/edof/_apps/editor.py +32 -12
  19. {edof-4.2.0 → edof-4.2.2}/edof/_apps/edof_text_editor.py +22 -0
  20. {edof-4.2.0 → edof-4.2.2}/edof/_apps/file_assoc.py +142 -39
  21. {edof-4.2.0 → edof-4.2.2}/edof/_apps/viewer.py +70 -18
  22. {edof-4.2.0 → edof-4.2.2}/edof/engine/document_paginate.py +17 -2
  23. {edof-4.2.0 → edof-4.2.2}/edof/format/styles.py +3 -1
  24. {edof-4.2.0 → edof-4.2.2}/edof/version.py +1 -1
  25. {edof-4.2.0 → edof-4.2.2}/edof.egg-info/PKG-INFO +12 -10
  26. {edof-4.2.0 → edof-4.2.2}/edof.egg-info/SOURCES.txt +14 -0
  27. {edof-4.2.0 → edof-4.2.2}/pyproject.toml +4 -2
  28. {edof-4.2.0 → edof-4.2.2}/LICENSE +0 -0
  29. {edof-4.2.0 → edof-4.2.2}/edof/_apps/__init__.py +0 -0
  30. {edof-4.2.0 → edof-4.2.2}/edof/_apps/cli.py +0 -0
  31. {edof-4.2.0 → edof-4.2.2}/edof/_apps/shortcuts.py +0 -0
  32. {edof-4.2.0 → edof-4.2.2}/edof/api/__init__.py +0 -0
  33. {edof-4.2.0 → edof-4.2.2}/edof/api/commands.py +0 -0
  34. {edof-4.2.0 → edof-4.2.2}/edof/crypto/__init__.py +0 -0
  35. {edof-4.2.0 → edof-4.2.2}/edof/crypto/document_protection.py +0 -0
  36. {edof-4.2.0 → edof-4.2.2}/edof/crypto/encryption.py +0 -0
  37. {edof-4.2.0 → edof-4.2.2}/edof/crypto/permissions.py +0 -0
  38. {edof-4.2.0 → edof-4.2.2}/edof/editor_lang/en.json +0 -0
  39. {edof-4.2.0 → edof-4.2.2}/edof/engine/__init__.py +0 -0
  40. {edof-4.2.0 → edof-4.2.2}/edof/engine/color.py +0 -0
  41. {edof-4.2.0 → edof-4.2.2}/edof/engine/debug_log.py +0 -0
  42. {edof-4.2.0 → edof-4.2.2}/edof/engine/document_flow.py +0 -0
  43. {edof-4.2.0 → edof-4.2.2}/edof/engine/renderer.py +0 -0
  44. {edof-4.2.0 → edof-4.2.2}/edof/engine/text_engine.py +0 -0
  45. {edof-4.2.0 → edof-4.2.2}/edof/engine/text_layout.py +0 -0
  46. {edof-4.2.0 → edof-4.2.2}/edof/engine/textbox_flow.py +0 -0
  47. {edof-4.2.0 → edof-4.2.2}/edof/engine/transform.py +0 -0
  48. {edof-4.2.0 → edof-4.2.2}/edof/exceptions.py +0 -0
  49. {edof-4.2.0 → edof-4.2.2}/edof/export/__init__.py +0 -0
  50. {edof-4.2.0 → edof-4.2.2}/edof/export/bitmap.py +0 -0
  51. {edof-4.2.0 → edof-4.2.2}/edof/export/legacy_v3.py +0 -0
  52. {edof-4.2.0 → edof-4.2.2}/edof/export/pdf.py +0 -0
  53. {edof-4.2.0 → edof-4.2.2}/edof/export/pdf_writer.py +0 -0
  54. {edof-4.2.0 → edof-4.2.2}/edof/export/printer.py +0 -0
  55. {edof-4.2.0 → edof-4.2.2}/edof/export/svg.py +0 -0
  56. {edof-4.2.0 → edof-4.2.2}/edof/format/__init__.py +0 -0
  57. {edof-4.2.0 → edof-4.2.2}/edof/format/document.py +0 -0
  58. {edof-4.2.0 → edof-4.2.2}/edof/format/document_body.py +0 -0
  59. {edof-4.2.0 → edof-4.2.2}/edof/format/document_boxes.py +0 -0
  60. {edof-4.2.0 → edof-4.2.2}/edof/format/objects.py +0 -0
  61. {edof-4.2.0 → edof-4.2.2}/edof/format/serializer.py +0 -0
  62. {edof-4.2.0 → edof-4.2.2}/edof/format/svg_io.py +0 -0
  63. {edof-4.2.0 → edof-4.2.2}/edof/format/variables.py +0 -0
  64. {edof-4.2.0 → edof-4.2.2}/edof/gui/__init__.py +0 -0
  65. {edof-4.2.0 → edof-4.2.2}/edof/gui/pyqt6_widget.py +0 -0
  66. {edof-4.2.0 → edof-4.2.2}/edof/gui/tkinter_canvas.py +0 -0
  67. {edof-4.2.0 → edof-4.2.2}/edof/interop/__init__.py +0 -0
  68. {edof-4.2.0 → edof-4.2.2}/edof/interop/docx_io.py +0 -0
  69. {edof-4.2.0 → edof-4.2.2}/edof/py.typed +0 -0
  70. {edof-4.2.0 → edof-4.2.2}/edof/units.py +0 -0
  71. {edof-4.2.0 → edof-4.2.2}/edof/utils/__init__.py +0 -0
  72. {edof-4.2.0 → edof-4.2.2}/edof/utils/compat.py +0 -0
  73. {edof-4.2.0 → edof-4.2.2}/edof/utils/legacy_v2.py +0 -0
  74. {edof-4.2.0 → edof-4.2.2}/edof/utils/pdf_import.py +0 -0
  75. {edof-4.2.0 → edof-4.2.2}/edof/utils/qr.py +0 -0
  76. {edof-4.2.0 → edof-4.2.2}/edof/utils/rtf.py +0 -0
  77. {edof-4.2.0 → edof-4.2.2}/edof/utils/safe_eval.py +0 -0
  78. {edof-4.2.0 → edof-4.2.2}/edof.egg-info/dependency_links.txt +0 -0
  79. {edof-4.2.0 → edof-4.2.2}/edof.egg-info/entry_points.txt +0 -0
  80. {edof-4.2.0 → edof-4.2.2}/edof.egg-info/requires.txt +0 -0
  81. {edof-4.2.0 → edof-4.2.2}/edof.egg-info/top_level.txt +0 -0
  82. {edof-4.2.0 → edof-4.2.2}/setup.cfg +0 -0
  83. {edof-4.2.0 → edof-4.2.2}/tests/test_v310.py +0 -0
  84. {edof-4.2.0 → edof-4.2.2}/tests/test_v400.py +0 -0
  85. {edof-4.2.0 → edof-4.2.2}/tests/test_v401.py +0 -0
  86. {edof-4.2.0 → edof-4.2.2}/tests/test_v402.py +0 -0
  87. {edof-4.2.0 → edof-4.2.2}/tests/test_v403.py +0 -0
  88. {edof-4.2.0 → edof-4.2.2}/tests/test_v410.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edof
3
- Version: 4.2.0
3
+ Version: 4.2.2
4
4
  Summary: Easy Document Format – programmatic document creation, editing and export with vector PDF, rich text, tables and PDF import
5
5
  License: MIT License
6
6
 
@@ -29,7 +29,8 @@ Project-URL: Documentation, https://davidschobl.github.io/edof/
29
29
  Project-URL: Repository, https://github.com/DavidSchobl/edof
30
30
  Project-URL: Bug Tracker, https://github.com/DavidSchobl/edof/issues
31
31
  Project-URL: Changelog, https://github.com/DavidSchobl/edof/blob/main/CHANGELOG.md
32
- Project-URL: Funding, https://github.com/sponsors/DavidSchobl
32
+ Project-URL: Funding, https://ko-fi.com/davidschobl
33
+ Project-URL: Sponsor, https://github.com/sponsors/DavidSchobl
33
34
  Keywords: document,format,pdf,svg,template,edof,print,vector
34
35
  Classifier: Development Status :: 4 - Beta
35
36
  Classifier: Intended Audience :: Developers
@@ -87,9 +88,10 @@ Dynamic: license-file
87
88
  [![Build status](https://github.com/DavidSchobl/edof/actions/workflows/publish.yml/badge.svg)](https://github.com/DavidSchobl/edof/actions/workflows/publish.yml)
88
89
  [![Downloads](https://static.pepy.tech/badge/edof)](https://pepy.tech/project/edof)
89
90
  [![Sponsor](https://img.shields.io/badge/sponsor-%E2%9D%A4-ff69b4)](https://github.com/sponsors/DavidSchobl)
91
+ [![Ko-fi](https://img.shields.io/badge/Ko--fi-support-ff5e5b?logo=ko-fi&logoColor=white)](https://ko-fi.com/davidschobl)
90
92
 
91
93
  📚 **Documentation**: <https://davidschobl.github.io/edof/> &nbsp;|&nbsp;
92
- 💖 **Support development**: <https://github.com/sponsors/DavidSchobl>
94
+ 💖 **Support development**: <https://ko-fi.com/davidschobl> &nbsp;|&nbsp; <https://github.com/sponsors/DavidSchobl>
93
95
 
94
96
  A Python library and visual editor for programmatic document creation, template filling, and high-quality export. Documents are described in code or in a small ZIP-based file format, then rendered to PNG, JPEG, TIFF, BMP, PDF, RTF, or SVG. A PyQt6 desktop editor is included for visual editing with Photoshop-style layer effects, table cell editor, multi-blend compositing, and a path tool.
95
97
 
@@ -183,7 +185,7 @@ A `Document` contains pages; each `Page` contains objects. All measurements are
183
185
  | `Table` | Formatted table with per-cell styling | `cells`, `col_widths`, `row_heights`, `table_border` |
184
186
  | `Group` | Container with optional clip | `children` |
185
187
 
186
- Common fields on every object: `transform` (position/size/rotation/flip), `opacity`, `layer`, `visible`, `visible_if`, `blend_mode`, `lock_level`, `lock_text`, `lock_position`, `tags`, `shadow`, `effects` (v4.1.0+).
188
+ Common fields on every object: `transform` (position/size/rotation/flip), `opacity`, `layer`, `visible`, `visible_if`, `blend_mode`, `lock_level`, `lock_text`, `lock_position`, `tags`, `shadow`, `effects` (v4.2.0+).
187
189
 
188
190
  #### Transform fields
189
191
 
@@ -205,7 +207,7 @@ All color fields accept tuples in 0–255 range. Both RGB (3-tuple) and RGBA (4-
205
207
  obj.style.color = (74, 144, 226) # RGB → alpha 255 implied
206
208
  obj.fill.color = (245, 245, 248, 255) # RGBA — explicit
207
209
  obj.stroke.color = (0, 0, 0, 200) # RGBA — translucent black
208
- shape.fill.color = "#4a90e2" # v4.1.0+: hex string accepted
210
+ shape.fill.color = "#4a90e2" # v4.2.0+: hex string accepted
209
211
  ```
210
212
 
211
213
  When read back, colors come as tuples (`obj.style.color` returns `(74, 144, 226)`, `obj.fill.color` returns `(245, 245, 248, 255)`).
@@ -467,7 +469,7 @@ heading.lock_text = True # text never editable until lock_text is cleared
467
469
  ### Headings with auto-computed height (no more silent text loss)
468
470
 
469
471
  ```python
470
- # v4.1.0+: add_textbox_auto computes height from content and accepts style kwargs
472
+ # v4.2.0+: add_textbox_auto computes height from content and accepts style kwargs
471
473
  heading = page.add_textbox_auto(
472
474
  20, 20, 170, "MY HEADING",
473
475
  font_size=24, bold=True, alignment="center",
@@ -479,7 +481,7 @@ next_y = heading.transform.y + heading.transform.height + 5 # 5 mm gap
479
481
  ### Tables with explicit sizing
480
482
 
481
483
  ```python
482
- # v4.1.0+: make_table accepts position + size and computes total height
484
+ # v4.2.0+: make_table accepts position + size and computes total height
483
485
  tbl = edof.make_table(
484
486
  rows=[
485
487
  ["Product", "Qty", "Price"],
@@ -507,7 +509,7 @@ tb.style.color = edof.as_color("#4a90e2") # hex via helper
507
509
  tb.style.color = edof.as_color("#4a90e2cc") # 8-digit hex with alpha
508
510
  ```
509
511
 
510
- ### Per-side padding (v4.1.0+)
512
+ ### Per-side padding (v4.2.0+)
511
513
 
512
514
  ```python
513
515
  tb.style.padding = 2.0 # uniform 2 mm on all sides
@@ -554,7 +556,7 @@ A PyQt6 desktop editor (`edof-editor`) ships with the library. It is a working e
554
556
 
555
557
  ## Viewer
556
558
 
557
- A lightweight read-only viewer (`edof-viewer`) ships alongside the editor (v4.1.1+). It is designed for the case where someone receives a `.edof` file and just wants to view, print, or convert it — without launching the full editor.
559
+ A lightweight read-only viewer (`edof-viewer`) ships alongside the editor (v4.2.0+). It is designed for the case where someone receives a `.edof` file and just wants to view, print, or convert it — without launching the full editor.
558
560
 
559
561
  ```bash
560
562
  pip install 'edof[viewer]'
@@ -696,7 +698,7 @@ A small `.bat` makes switching painless:
696
698
  @echo off
697
699
  call D:\apps\Edof_V401\edof-python\.venv\Scripts\activate.bat
698
700
  cd /d D:\apps\Edof_V401\edof-python
699
- cmd /k prompt [edof v4.0.1] $P$G
701
+ cmd /k prompt [edof v4.2.2] $P$G
700
702
  ```
701
703
 
702
704
  Each version's venv is independent. Removing a version is `rmdir /s /q <folder>`; nothing else needs cleanup.
@@ -6,9 +6,10 @@
6
6
  [![Build status](https://github.com/DavidSchobl/edof/actions/workflows/publish.yml/badge.svg)](https://github.com/DavidSchobl/edof/actions/workflows/publish.yml)
7
7
  [![Downloads](https://static.pepy.tech/badge/edof)](https://pepy.tech/project/edof)
8
8
  [![Sponsor](https://img.shields.io/badge/sponsor-%E2%9D%A4-ff69b4)](https://github.com/sponsors/DavidSchobl)
9
+ [![Ko-fi](https://img.shields.io/badge/Ko--fi-support-ff5e5b?logo=ko-fi&logoColor=white)](https://ko-fi.com/davidschobl)
9
10
 
10
11
  📚 **Documentation**: <https://davidschobl.github.io/edof/> &nbsp;|&nbsp;
11
- 💖 **Support development**: <https://github.com/sponsors/DavidSchobl>
12
+ 💖 **Support development**: <https://ko-fi.com/davidschobl> &nbsp;|&nbsp; <https://github.com/sponsors/DavidSchobl>
12
13
 
13
14
  A Python library and visual editor for programmatic document creation, template filling, and high-quality export. Documents are described in code or in a small ZIP-based file format, then rendered to PNG, JPEG, TIFF, BMP, PDF, RTF, or SVG. A PyQt6 desktop editor is included for visual editing with Photoshop-style layer effects, table cell editor, multi-blend compositing, and a path tool.
14
15
 
@@ -102,7 +103,7 @@ A `Document` contains pages; each `Page` contains objects. All measurements are
102
103
  | `Table` | Formatted table with per-cell styling | `cells`, `col_widths`, `row_heights`, `table_border` |
103
104
  | `Group` | Container with optional clip | `children` |
104
105
 
105
- Common fields on every object: `transform` (position/size/rotation/flip), `opacity`, `layer`, `visible`, `visible_if`, `blend_mode`, `lock_level`, `lock_text`, `lock_position`, `tags`, `shadow`, `effects` (v4.1.0+).
106
+ Common fields on every object: `transform` (position/size/rotation/flip), `opacity`, `layer`, `visible`, `visible_if`, `blend_mode`, `lock_level`, `lock_text`, `lock_position`, `tags`, `shadow`, `effects` (v4.2.0+).
106
107
 
107
108
  #### Transform fields
108
109
 
@@ -124,7 +125,7 @@ All color fields accept tuples in 0–255 range. Both RGB (3-tuple) and RGBA (4-
124
125
  obj.style.color = (74, 144, 226) # RGB → alpha 255 implied
125
126
  obj.fill.color = (245, 245, 248, 255) # RGBA — explicit
126
127
  obj.stroke.color = (0, 0, 0, 200) # RGBA — translucent black
127
- shape.fill.color = "#4a90e2" # v4.1.0+: hex string accepted
128
+ shape.fill.color = "#4a90e2" # v4.2.0+: hex string accepted
128
129
  ```
129
130
 
130
131
  When read back, colors come as tuples (`obj.style.color` returns `(74, 144, 226)`, `obj.fill.color` returns `(245, 245, 248, 255)`).
@@ -386,7 +387,7 @@ heading.lock_text = True # text never editable until lock_text is cleared
386
387
  ### Headings with auto-computed height (no more silent text loss)
387
388
 
388
389
  ```python
389
- # v4.1.0+: add_textbox_auto computes height from content and accepts style kwargs
390
+ # v4.2.0+: add_textbox_auto computes height from content and accepts style kwargs
390
391
  heading = page.add_textbox_auto(
391
392
  20, 20, 170, "MY HEADING",
392
393
  font_size=24, bold=True, alignment="center",
@@ -398,7 +399,7 @@ next_y = heading.transform.y + heading.transform.height + 5 # 5 mm gap
398
399
  ### Tables with explicit sizing
399
400
 
400
401
  ```python
401
- # v4.1.0+: make_table accepts position + size and computes total height
402
+ # v4.2.0+: make_table accepts position + size and computes total height
402
403
  tbl = edof.make_table(
403
404
  rows=[
404
405
  ["Product", "Qty", "Price"],
@@ -426,7 +427,7 @@ tb.style.color = edof.as_color("#4a90e2") # hex via helper
426
427
  tb.style.color = edof.as_color("#4a90e2cc") # 8-digit hex with alpha
427
428
  ```
428
429
 
429
- ### Per-side padding (v4.1.0+)
430
+ ### Per-side padding (v4.2.0+)
430
431
 
431
432
  ```python
432
433
  tb.style.padding = 2.0 # uniform 2 mm on all sides
@@ -473,7 +474,7 @@ A PyQt6 desktop editor (`edof-editor`) ships with the library. It is a working e
473
474
 
474
475
  ## Viewer
475
476
 
476
- A lightweight read-only viewer (`edof-viewer`) ships alongside the editor (v4.1.1+). It is designed for the case where someone receives a `.edof` file and just wants to view, print, or convert it — without launching the full editor.
477
+ A lightweight read-only viewer (`edof-viewer`) ships alongside the editor (v4.2.0+). It is designed for the case where someone receives a `.edof` file and just wants to view, print, or convert it — without launching the full editor.
477
478
 
478
479
  ```bash
479
480
  pip install 'edof[viewer]'
@@ -615,7 +616,7 @@ A small `.bat` makes switching painless:
615
616
  @echo off
616
617
  call D:\apps\Edof_V401\edof-python\.venv\Scripts\activate.bat
617
618
  cd /d D:\apps\Edof_V401\edof-python
618
- cmd /k prompt [edof v4.0.1] $P$G
619
+ cmd /k prompt [edof v4.2.2] $P$G
619
620
  ```
620
621
 
621
622
  Each version's venv is independent. Removing a version is `rmdir /s /q <folder>`; nothing else needs cleanup.
@@ -85,7 +85,8 @@ def new(width: float = 210.0, height: float = 297.0, **kwargs) -> Document:
85
85
  Example:
86
86
  >>> import edof
87
87
  >>> doc = edof.new(210, 297, title="Hello", dpi=300)
88
- >>> page = doc.pages[0]
88
+ >>> page = doc.add_page()
89
+ >>> page.add_textbox(15, 15, 180, 12, "Hello world!")
89
90
  >>> doc.save("hello.edof")
90
91
  """
91
92
  return Document(width=width, height=height, **kwargs)
@@ -0,0 +1,34 @@
1
+ """Bundled application assets (icons).
2
+
3
+ `icon_path(name)` returns the absolute on-disk path to a packaged icon, working
4
+ both from a source checkout and from an installed wheel. Kept Qt-free so it can
5
+ also be used by the file-association registration code.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+
12
+ def icon_path(name: str):
13
+ """Return the absolute path to a bundled icon file, or None if missing.
14
+
15
+ `name` is the file name, e.g. "edof-editor.ico" or "edof-document.png".
16
+ """
17
+ # Preferred: importlib.resources (works for installed packages).
18
+ try:
19
+ from importlib.resources import files
20
+ p = files("edof._apps.assets.icons").joinpath(name)
21
+ sp = str(p)
22
+ if os.path.isfile(sp):
23
+ return sp
24
+ except Exception:
25
+ pass
26
+ # Fallback: relative to this file (source checkout).
27
+ here = os.path.join(os.path.dirname(__file__), "icons", name)
28
+ return here if os.path.isfile(here) else None
29
+
30
+
31
+ def app_icon_name(kind: str, ext: str = "ico") -> str:
32
+ """Map a logical kind to an icon file name. kind in {editor,viewer,document}."""
33
+ kind = kind if kind in ("editor", "viewer", "document") else "document"
34
+ return f"edof-{kind}.{ext}"
@@ -0,0 +1 @@
1
+ """Packaged icon files (.ico/.png/.icns)."""
@@ -23,7 +23,7 @@ try:
23
23
  from PyQt6.QtCore import Qt, QPointF, QRectF, QTimer, QSize, pyqtSignal, QObject, QSettings
24
24
  from PyQt6.QtGui import (
25
25
  QAction, QPainter, QPen, QBrush, QColor, QPixmap, QImage,
26
- QPolygonF, QFont, QTransform, QCursor, QKeySequence, QPalette,
26
+ QPolygonF, QFont, QTransform, QCursor, QKeySequence, QPalette, QIcon,
27
27
  )
28
28
  except ImportError:
29
29
  print("PyQt6 required: pip install PyQt6"); sys.exit(1)
@@ -233,7 +233,7 @@ QCheckBox::indicator:checked{{background:{ACC};border-color:{ACC}}}
233
233
  QRadioButton::indicator{{border-radius:8px}} QRadioButton::indicator:checked{{background:{ACC};border-color:{ACC}}}
234
234
  QComboBox::drop-down{{border:none;width:18px}}
235
235
  QComboBox QAbstractItemView{{background:{PBG2};color:{FG};selection-background-color:{ACC};font:10pt 'Segoe UI'}}
236
- QListWidget{{background:#1a1a2e;color:{FG};border:none;outline:none;font:10pt 'Segoe UI'}}
236
+ QListWidget{{background:#1a1a2e;color:{FG};border:none;font:10pt 'Segoe UI'}}
237
237
  QListWidget::item{{padding:4px 6px}}
238
238
  QListWidget::item:selected{{background:{ACC}}}
239
239
  QTableWidget{{background:#1a1a2e;color:{FG};gridline-color:#444;font:10pt 'Segoe UI'}}
@@ -1081,6 +1081,14 @@ class EdofCanvas(QGraphicsView):
1081
1081
  ed._host_undo = mw._undo
1082
1082
  ed._host_redo = mw._redo
1083
1083
  ed._on_body_edit = mw._on_body_touched
1084
+ # v4.2.1: route Ctrl+S / Ctrl+Shift+S to the window's save so
1085
+ # they work while the caret is inside the body text editor
1086
+ # (previously Ctrl+S did nothing and Ctrl+Shift+S toggled
1087
+ # strikethrough instead of Save As).
1088
+ if hasattr(mw, '_save'):
1089
+ ed._host_save = mw._save
1090
+ if hasattr(mw, '_save_as'):
1091
+ ed._host_save_as = mw._save_as
1084
1092
  # v4.1.16.4: signals trigger UI cleanup AFTER editor has written
1085
1093
  # data back. Editor.py drives commit/cancel directly; signals are
1086
1094
  # for after-the-fact cleanup only — no recursion possible.
@@ -8200,6 +8208,14 @@ class EdofEditor(QMainWindow):
8200
8208
  _patch_button_font(btn)
8201
8209
  except Exception: pass
8202
8210
  self.setWindowTitle(f"{t('app_title')} {edof.__version__}")
8211
+ # v4.2.2: window / taskbar icon
8212
+ try:
8213
+ from edof._apps.assets import icon_path
8214
+ _ip = icon_path("edof-editor.ico") or icon_path("edof-editor.png")
8215
+ if _ip:
8216
+ self.setWindowIcon(QIcon(_ip))
8217
+ except Exception:
8218
+ pass
8203
8219
  # v4.0.2/4.1.9.1: restore window geometry, default to maximized on first run
8204
8220
  geom = self._settings.value("geometry")
8205
8221
  if geom is not None:
@@ -8680,7 +8696,7 @@ class EdofEditor(QMainWindow):
8680
8696
  hm.addSeparator()
8681
8697
  # v4.1.0: donate
8682
8698
  # v4.1.1: file association management
8683
- assoc_act = QAction("Associate .edof files with viewer…", self)
8699
+ assoc_act = QAction("File association (.edof)…", self)
8684
8700
  assoc_act.triggered.connect(self._open_file_assoc_dialog)
8685
8701
  hm.addAction(assoc_act)
8686
8702
  hm.addSeparator()
@@ -10289,7 +10305,7 @@ class EdofEditor(QMainWindow):
10289
10305
  dlg.exec()
10290
10306
 
10291
10307
  def _open_file_assoc_dialog(self):
10292
- """v4.1.1: Manage .edof file association with edof-viewer."""
10308
+ """Manage the .edof file association (register / remove)."""
10293
10309
  try:
10294
10310
  from edof._apps.file_assoc import (
10295
10311
  associate_edof_files, unassociate_edof_files,
@@ -10303,10 +10319,12 @@ class EdofEditor(QMainWindow):
10303
10319
  is_assoc = "associated" in status.lower() and "not " not in status.lower()
10304
10320
  msg = (f"<h3>File association</h3>"
10305
10321
  f"<p>Current status: <b>{status}</b></p>"
10306
- f"<p>When .edof files are associated, double-clicking a "
10307
- f"<code>.edof</code> file in your file manager opens it in "
10308
- f"<b>edof-viewer</b> (read-only).</p>"
10309
- f"<p>{'<b>Currently associated.</b> Click Unassociate to remove.' if is_assoc else 'Click Associate to register .edof files.'}</p>")
10322
+ f"<p>When .edof files are registered, they show the EDOF icon in "
10323
+ f"your file manager and the first time you open one the system "
10324
+ f"lets you choose <b>EDOF Viewer</b> or <b>EDOF Editor</b>. No "
10325
+ f"default is forced, so you stay in control of which app opens "
10326
+ f"them.</p>"
10327
+ f"<p>{'<b>Currently registered.</b> Click OK to remove the association.' if is_assoc else 'Click OK to register .edof files.'}</p>")
10310
10328
  bb_text = "Unassociate" if is_assoc else "Associate"
10311
10329
  ret = QMessageBox.question(self, "File association", msg,
10312
10330
  QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel)
@@ -10534,13 +10552,14 @@ class EdofEditor(QMainWindow):
10534
10552
  self._canvas.schedule_render(100)
10535
10553
 
10536
10554
  def _open_donate(self):
10537
- """v4.1.0: Open the GitHub Sponsors page in the user's browser."""
10555
+ """v4.1.0: Open the donate page (Ko-fi) in the user's browser."""
10538
10556
  try:
10539
10557
  import webbrowser
10540
- webbrowser.open("https://github.com/sponsors/DavidSchobl")
10558
+ webbrowser.open("https://ko-fi.com/davidschobl")
10541
10559
  except Exception as e:
10542
10560
  QMessageBox.information(self, "Support",
10543
10561
  f"To support the developer, visit:\n"
10562
+ f"https://ko-fi.com/davidschobl\n"
10544
10563
  f"https://github.com/sponsors/DavidSchobl\n\n({e})")
10545
10564
 
10546
10565
  def _show_about(self):
@@ -10580,8 +10599,9 @@ class EdofEditor(QMainWindow):
10580
10599
  f"<a href='https://pypi.org/project/edof/'>pypi.org/project/edof</a></p>"
10581
10600
  f"<h3>Support the developer</h3>"
10582
10601
  f"<p>If edof saves you time and you'd like to support its development, "
10583
- f"<a href='https://github.com/sponsors/DavidSchobl'>"
10584
- f"GitHub Sponsors</a> lets you donate any amount. "
10602
+ f"you can donate any amount via "
10603
+ f"<a href='https://ko-fi.com/davidschobl'>Ko-fi</a> or "
10604
+ f"<a href='https://github.com/sponsors/DavidSchobl'>GitHub Sponsors</a>. "
10585
10605
  f"Every contribution helps keep edof maintained and free.</p>"
10586
10606
  f"<p>License: MIT &nbsp;|&nbsp; © 2025 DavidSchobl</p>")
10587
10607
 
@@ -532,6 +532,8 @@ class EdofTextEditor(QWidget):
532
532
  # local stacks. Set by the canvas in _start_inline for body editors.
533
533
  self._host_undo = None # callable() -> document-level undo
534
534
  self._host_redo = None # callable() -> document-level redo
535
+ self._host_save = None # callable() -> document-level Save (Ctrl+S)
536
+ self._host_save_as = None # callable() -> document-level Save As (Ctrl+Shift+S)
535
537
  self._on_body_edit = None # callable() -> checkpoint before an edit burst
536
538
  # Idle-overflow auto-commit timer (doc body only)
537
539
  self._idle_overflow_timer = QTimer(self)
@@ -1775,6 +1777,16 @@ class EdofTextEditor(QWidget):
1775
1777
  if k in claim and not ctrl:
1776
1778
  ev.accept()
1777
1779
  return True
1780
+ # v4.2.1: in the document body, claim Ctrl+S / Ctrl+Shift+S so
1781
+ # they reach our keyPressEvent (which forwards them to the
1782
+ # window's Save / Save As) instead of being swallowed or, for
1783
+ # Ctrl+Shift+S, triggering the editor's strikethrough action.
1784
+ if (getattr(self, '_is_doc_body', False)
1785
+ and ctrl and k == Qt.Key.Key_S):
1786
+ if (self._host_save is not None
1787
+ or self._host_save_as is not None):
1788
+ ev.accept()
1789
+ return True
1778
1790
  # v4.1.23.38: also claim any Ctrl/Alt combo that maps to a
1779
1791
  # configured editor action (undo/redo/copy/paste/format/…) so a
1780
1792
  # window-level QAction (e.g. global Undo on Ctrl+Z) can't eat it
@@ -1861,6 +1873,16 @@ class EdofTextEditor(QWidget):
1861
1873
  self._push_undo("type"); self._insert_text(' '); self._invalidate()
1862
1874
  return
1863
1875
 
1876
+ # v4.2.1: document-level Save / Save As take precedence inside the body
1877
+ # editor, before the configurable dispatch (which maps Ctrl+Shift+S to
1878
+ # strikethrough). Forward to the host window so the standard shortcuts
1879
+ # work while typing.
1880
+ if ctrl and key == Qt.Key.Key_S and getattr(self, '_is_doc_body', False):
1881
+ if shift and self._host_save_as is not None:
1882
+ self._host_save_as(); ev.accept(); return
1883
+ if not shift and self._host_save is not None:
1884
+ self._host_save(); ev.accept(); return
1885
+
1864
1886
  # v4.1.23.37: configurable shortcuts (formatting / clipboard /
1865
1887
  # alignment). Resolved via the user-editable map; unmapped combos
1866
1888
  # (e.g. ctrl+arrow word navigation) fall through to the handlers below.
@@ -31,6 +31,26 @@ def _find_viewer_executable():
31
31
  )
32
32
 
33
33
 
34
+ def _find_editor_executable():
35
+ """Locate the edof-editor command in PATH (fallback to module invocation)."""
36
+ exe = shutil.which("edof-editor")
37
+ if exe:
38
+ return exe
39
+ if sys.executable:
40
+ return f'"{sys.executable}" -m edof._apps.editor'
41
+ raise RuntimeError(
42
+ "Could not locate edof-editor executable. "
43
+ "Make sure edof[editor] is installed: pip install 'edof[all]'"
44
+ )
45
+
46
+
47
+ def _cmd_with_arg(exe: str) -> str:
48
+ """Build a 'command' value: quote the exe (unless already quoted) + "%1"."""
49
+ if exe.startswith('"') or exe.startswith("'"):
50
+ return f'{exe} "%1"'
51
+ return f'"{exe}" "%1"'
52
+
53
+
34
54
  def current_association_status() -> str:
35
55
  """Return human-readable description of current .edof file association."""
36
56
  if sys.platform == "win32":
@@ -38,10 +58,19 @@ def current_association_status() -> str:
38
58
  import winreg
39
59
  with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, ".edof") as k:
40
60
  cls, _ = winreg.QueryValueEx(k, "")
41
- with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT,
42
- f"{cls}\\shell\\open\\command") as k:
43
- cmd, _ = winreg.QueryValueEx(k, "")
44
- return f"associated with: {cmd}"
61
+ if cls and cls.startswith("edof.Document"):
62
+ # Our document type. With the v4.2.2 model the type has no
63
+ # default opener, so check whether the open-with choices exist.
64
+ return ("associated (EDOF icon set; Windows asks which app "
65
+ "to open with)")
66
+ # Some other progid claims .edof
67
+ try:
68
+ with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT,
69
+ f"{cls}\\shell\\open\\command") as k:
70
+ cmd, _ = winreg.QueryValueEx(k, "")
71
+ return f"associated with another app: {cmd}"
72
+ except OSError:
73
+ return f"associated with: {cls}"
45
74
  except OSError:
46
75
  return "not associated"
47
76
  elif sys.platform.startswith("linux"):
@@ -90,73 +119,145 @@ def unassociate_edof_files():
90
119
  # ─────────────────────────────────────────────────────────────────────────────
91
120
 
92
121
  def _associate_windows():
93
- """Register .edof in HKCU\\Software\\Classes (per-user, no admin needed)."""
122
+ """Register .edof in HKCU\\Software\\Classes (per-user, no admin needed).
123
+
124
+ v4.2.2: the association does NOT force a default opener. `.edof` is mapped
125
+ to a document *type* progid (icon + friendly name, no open command), and the
126
+ Viewer and Editor are registered as the two "Open with" choices. The first
127
+ time the user double-clicks a `.edof` file Windows asks which app to use, so
128
+ the user picks their own default (and can change it any time). Files still
129
+ show the document icon in Explorer.
130
+ """
94
131
  import winreg
95
132
 
96
- viewer = _find_viewer_executable()
97
- # Build command line. Use double-quotes around the executable path.
98
- if viewer.startswith('"') or viewer.startswith("'"):
99
- cmd = f'{viewer} "%1"'
100
- else:
101
- cmd = f'"{viewer}" "%1"'
102
-
103
- progid = "edof.Document.1"
104
133
  classes = r"Software\Classes"
134
+ progid_doc = "edof.Document" # file type: icon + name, no opener
135
+ progid_view = "edof.Document.Viewer" # Open-with choice: Viewer
136
+ progid_edit = "edof.Document.Editor" # Open-with choice: Editor
105
137
 
106
- # 1. .edof → progid
107
- with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER,
108
- f"{classes}\\.edof",
109
- 0, winreg.KEY_WRITE) as k:
110
- winreg.SetValueEx(k, "", 0, winreg.REG_SZ, progid)
111
- winreg.SetValueEx(k, "Content Type", 0, winreg.REG_SZ, "application/x-edof")
138
+ viewer = _find_viewer_executable()
139
+ cmd_view = _cmd_with_arg(viewer)
140
+ try:
141
+ editor = _find_editor_executable()
142
+ cmd_edit = _cmd_with_arg(editor)
143
+ except RuntimeError:
144
+ editor = None
145
+ cmd_edit = None
112
146
 
113
- # 2. progid metadata
147
+ try:
148
+ from edof._apps.assets import icon_path
149
+ except Exception:
150
+ icon_path = lambda *_a, **_k: None # noqa: E731
151
+ doc_icon = icon_path("edof-document.ico")
152
+ view_icon = icon_path("edof-viewer.ico")
153
+ edit_icon = icon_path("edof-editor.ico")
154
+
155
+ def _set_default(path, value):
156
+ with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, f"{classes}\\{path}",
157
+ 0, winreg.KEY_WRITE) as k:
158
+ winreg.SetValueEx(k, "", 0, winreg.REG_SZ, value)
159
+
160
+ # 1. .edof → the document TYPE progid (gives icon + name) and the open-with
161
+ # list. No "default opener" is set on the type, so Windows prompts the
162
+ # user to choose between the Viewer and the Editor.
163
+ with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, f"{classes}\\.edof",
164
+ 0, winreg.KEY_WRITE) as k:
165
+ winreg.SetValueEx(k, "", 0, winreg.REG_SZ, progid_doc)
166
+ winreg.SetValueEx(k, "Content Type", 0, winreg.REG_SZ, "application/x-edof")
114
167
  with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER,
115
- f"{classes}\\{progid}",
116
- 0, winreg.KEY_WRITE) as k:
168
+ f"{classes}\\.edof\\OpenWithProgids",
169
+ 0, winreg.KEY_WRITE) as k:
170
+ winreg.SetValueEx(k, progid_view, 0, winreg.REG_NONE, b"")
171
+ if editor:
172
+ winreg.SetValueEx(k, progid_edit, 0, winreg.REG_NONE, b"")
173
+
174
+ # 2. Document type: friendly name + icon, deliberately NO shell\open\command.
175
+ with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, f"{classes}\\{progid_doc}",
176
+ 0, winreg.KEY_WRITE) as k:
117
177
  winreg.SetValueEx(k, "", 0, winreg.REG_SZ, "EDOF Document")
118
178
  winreg.SetValueEx(k, "FriendlyTypeName", 0, winreg.REG_SZ,
119
- "Easy Document Format File")
120
-
121
- # 3. progid\shell\open\command → "edof-viewer" "%1"
122
- with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER,
123
- f"{classes}\\{progid}\\shell\\open\\command",
124
- 0, winreg.KEY_WRITE) as k:
125
- winreg.SetValueEx(k, "", 0, winreg.REG_SZ, cmd)
126
-
127
- # 4. DefaultIcon (use viewer exe as icon source if available)
128
- if "edof-viewer" in viewer and os.path.isfile(viewer.strip('"').split()[0]):
129
- with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER,
130
- f"{classes}\\{progid}\\DefaultIcon",
131
- 0, winreg.KEY_WRITE) as k:
132
- winreg.SetValueEx(k, "", 0, winreg.REG_SZ, f'"{viewer.strip(chr(34))}",0')
133
-
134
- # 5. Notify shell that associations changed
179
+ "Easy Document Format File")
180
+ if doc_icon and os.path.isfile(doc_icon):
181
+ _set_default(f"{progid_doc}\\DefaultIcon", f'"{doc_icon}",0')
182
+
183
+ # 3. Viewer open-with choice (own name + icon + open command).
184
+ with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, f"{classes}\\{progid_view}",
185
+ 0, winreg.KEY_WRITE) as k:
186
+ winreg.SetValueEx(k, "", 0, winreg.REG_SZ, "EDOF Viewer")
187
+ if view_icon and os.path.isfile(view_icon):
188
+ _set_default(f"{progid_view}\\DefaultIcon", f'"{view_icon}",0')
189
+ _set_default(f"{progid_view}\\shell\\open\\command", cmd_view)
190
+
191
+ # 4. Editor open-with choice.
192
+ if cmd_edit:
193
+ with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, f"{classes}\\{progid_edit}",
194
+ 0, winreg.KEY_WRITE) as k:
195
+ winreg.SetValueEx(k, "", 0, winreg.REG_SZ, "EDOF Editor")
196
+ if edit_icon and os.path.isfile(edit_icon):
197
+ _set_default(f"{progid_edit}\\DefaultIcon", f'"{edit_icon}",0')
198
+ _set_default(f"{progid_edit}\\shell\\open\\command", cmd_edit)
199
+
200
+ # 5. Notify the shell that associations changed
135
201
  try:
136
202
  import ctypes
137
203
  SHCNE_ASSOCCHANGED = 0x08000000
138
204
  SHCNF_IDLIST = 0x0000
139
205
  ctypes.windll.shell32.SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST,
140
- None, None)
206
+ None, None)
141
207
  except Exception:
142
208
  pass
143
209
 
210
+ if editor:
211
+ return True, (".edof registered. Files now show the EDOF icon, and the "
212
+ "first time you open one Windows lets you choose the "
213
+ "Viewer or the Editor (no default is forced).")
214
+ return True, (".edof registered with the Viewer as an open-with choice. "
215
+ "(Editor not found, so only the Viewer was registered.)")
216
+
144
217
 
145
218
  def _unassociate_windows():
146
219
  import winreg
147
220
  classes = r"Software\Classes"
148
221
  for sub in [
222
+ r".edof\OpenWithProgids",
149
223
  r".edof",
224
+ # new (v4.2.2) keys
225
+ r"edof.Document.Viewer\shell\open\command",
226
+ r"edof.Document.Viewer\shell\open",
227
+ r"edof.Document.Viewer\shell",
228
+ r"edof.Document.Viewer\DefaultIcon",
229
+ r"edof.Document.Viewer",
230
+ r"edof.Document.Editor\shell\open\command",
231
+ r"edof.Document.Editor\shell\open",
232
+ r"edof.Document.Editor\shell",
233
+ r"edof.Document.Editor\DefaultIcon",
234
+ r"edof.Document.Editor",
235
+ r"edof.Document\DefaultIcon",
236
+ r"edof.Document",
237
+ # old (<=4.2.1) keys, cleaned up for migration
238
+ r"edof.Document.1\shell\edit\command",
239
+ r"edof.Document.1\shell\edit",
150
240
  r"edof.Document.1\shell\open\command",
151
241
  r"edof.Document.1\shell\open",
152
242
  r"edof.Document.1\shell",
153
243
  r"edof.Document.1\DefaultIcon",
154
244
  r"edof.Document.1",
245
+ r"edof.Document.Editor.1\shell\open\command",
246
+ r"edof.Document.Editor.1\shell\open",
247
+ r"edof.Document.Editor.1\shell",
248
+ r"edof.Document.Editor.1\DefaultIcon",
249
+ r"edof.Document.Editor.1",
155
250
  ]:
156
251
  try:
157
252
  winreg.DeleteKey(winreg.HKEY_CURRENT_USER, f"{classes}\\{sub}")
158
253
  except OSError:
159
254
  pass
255
+ try:
256
+ import ctypes
257
+ ctypes.windll.shell32.SHChangeNotify(0x08000000, 0x0000, None, None)
258
+ except Exception:
259
+ pass
260
+ return True, ".edof association removed."
160
261
 
161
262
 
162
263
  # ─────────────────────────────────────────────────────────────────────────────
@@ -213,6 +314,7 @@ def _associate_linux():
213
314
  subprocess.run(cmd, check=False, capture_output=True, timeout=10)
214
315
  except (FileNotFoundError, subprocess.TimeoutExpired):
215
316
  pass
317
+ return True, ".edof associated with edof-viewer (Linux desktop entry installed)."
216
318
 
217
319
 
218
320
  def _unassociate_linux():
@@ -230,6 +332,7 @@ def _unassociate_linux():
230
332
  )
231
333
  except (FileNotFoundError, subprocess.TimeoutExpired):
232
334
  pass
335
+ return True, ".edof association removed."
233
336
 
234
337
 
235
338
  if __name__ == "__main__":