edof 4.2.1__tar.gz → 4.2.3__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 (90) hide show
  1. {edof-4.2.1 → edof-4.2.3}/PKG-INFO +8 -8
  2. {edof-4.2.1 → edof-4.2.3}/README.md +7 -7
  3. {edof-4.2.1 → edof-4.2.3}/edof/__init__.py +2 -1
  4. edof-4.2.3/edof/_apps/_assoc_dialog.py +92 -0
  5. edof-4.2.3/edof/_apps/assets/__init__.py +34 -0
  6. edof-4.2.3/edof/_apps/assets/icons/__init__.py +1 -0
  7. edof-4.2.3/edof/_apps/assets/icons/edof-document-512.png +0 -0
  8. edof-4.2.3/edof/_apps/assets/icons/edof-document.icns +0 -0
  9. edof-4.2.3/edof/_apps/assets/icons/edof-document.ico +0 -0
  10. edof-4.2.3/edof/_apps/assets/icons/edof-document.png +0 -0
  11. edof-4.2.3/edof/_apps/assets/icons/edof-editor-512.png +0 -0
  12. edof-4.2.3/edof/_apps/assets/icons/edof-editor.icns +0 -0
  13. edof-4.2.3/edof/_apps/assets/icons/edof-editor.ico +0 -0
  14. edof-4.2.3/edof/_apps/assets/icons/edof-editor.png +0 -0
  15. edof-4.2.3/edof/_apps/assets/icons/edof-viewer-512.png +0 -0
  16. edof-4.2.3/edof/_apps/assets/icons/edof-viewer.icns +0 -0
  17. edof-4.2.3/edof/_apps/assets/icons/edof-viewer.ico +0 -0
  18. edof-4.2.3/edof/_apps/assets/icons/edof-viewer.png +0 -0
  19. {edof-4.2.1 → edof-4.2.3}/edof/_apps/cli.py +4 -3
  20. {edof-4.2.1 → edof-4.2.3}/edof/_apps/editor.py +22 -34
  21. {edof-4.2.1 → edof-4.2.3}/edof/_apps/edof_text_editor.py +22 -0
  22. edof-4.2.3/edof/_apps/file_assoc.py +389 -0
  23. {edof-4.2.1 → edof-4.2.3}/edof/_apps/viewer.py +40 -21
  24. {edof-4.2.1 → edof-4.2.3}/edof/engine/document_paginate.py +17 -2
  25. {edof-4.2.1 → edof-4.2.3}/edof/format/styles.py +3 -1
  26. {edof-4.2.1 → edof-4.2.3}/edof/version.py +1 -1
  27. {edof-4.2.1 → edof-4.2.3}/edof.egg-info/PKG-INFO +8 -8
  28. {edof-4.2.1 → edof-4.2.3}/edof.egg-info/SOURCES.txt +15 -0
  29. {edof-4.2.1 → edof-4.2.3}/pyproject.toml +2 -1
  30. edof-4.2.1/edof/_apps/file_assoc.py +0 -251
  31. {edof-4.2.1 → edof-4.2.3}/LICENSE +0 -0
  32. {edof-4.2.1 → edof-4.2.3}/edof/_apps/__init__.py +0 -0
  33. {edof-4.2.1 → edof-4.2.3}/edof/_apps/shortcuts.py +0 -0
  34. {edof-4.2.1 → edof-4.2.3}/edof/api/__init__.py +0 -0
  35. {edof-4.2.1 → edof-4.2.3}/edof/api/commands.py +0 -0
  36. {edof-4.2.1 → edof-4.2.3}/edof/crypto/__init__.py +0 -0
  37. {edof-4.2.1 → edof-4.2.3}/edof/crypto/document_protection.py +0 -0
  38. {edof-4.2.1 → edof-4.2.3}/edof/crypto/encryption.py +0 -0
  39. {edof-4.2.1 → edof-4.2.3}/edof/crypto/permissions.py +0 -0
  40. {edof-4.2.1 → edof-4.2.3}/edof/editor_lang/en.json +0 -0
  41. {edof-4.2.1 → edof-4.2.3}/edof/engine/__init__.py +0 -0
  42. {edof-4.2.1 → edof-4.2.3}/edof/engine/color.py +0 -0
  43. {edof-4.2.1 → edof-4.2.3}/edof/engine/debug_log.py +0 -0
  44. {edof-4.2.1 → edof-4.2.3}/edof/engine/document_flow.py +0 -0
  45. {edof-4.2.1 → edof-4.2.3}/edof/engine/renderer.py +0 -0
  46. {edof-4.2.1 → edof-4.2.3}/edof/engine/text_engine.py +0 -0
  47. {edof-4.2.1 → edof-4.2.3}/edof/engine/text_layout.py +0 -0
  48. {edof-4.2.1 → edof-4.2.3}/edof/engine/textbox_flow.py +0 -0
  49. {edof-4.2.1 → edof-4.2.3}/edof/engine/transform.py +0 -0
  50. {edof-4.2.1 → edof-4.2.3}/edof/exceptions.py +0 -0
  51. {edof-4.2.1 → edof-4.2.3}/edof/export/__init__.py +0 -0
  52. {edof-4.2.1 → edof-4.2.3}/edof/export/bitmap.py +0 -0
  53. {edof-4.2.1 → edof-4.2.3}/edof/export/legacy_v3.py +0 -0
  54. {edof-4.2.1 → edof-4.2.3}/edof/export/pdf.py +0 -0
  55. {edof-4.2.1 → edof-4.2.3}/edof/export/pdf_writer.py +0 -0
  56. {edof-4.2.1 → edof-4.2.3}/edof/export/printer.py +0 -0
  57. {edof-4.2.1 → edof-4.2.3}/edof/export/svg.py +0 -0
  58. {edof-4.2.1 → edof-4.2.3}/edof/format/__init__.py +0 -0
  59. {edof-4.2.1 → edof-4.2.3}/edof/format/document.py +0 -0
  60. {edof-4.2.1 → edof-4.2.3}/edof/format/document_body.py +0 -0
  61. {edof-4.2.1 → edof-4.2.3}/edof/format/document_boxes.py +0 -0
  62. {edof-4.2.1 → edof-4.2.3}/edof/format/objects.py +0 -0
  63. {edof-4.2.1 → edof-4.2.3}/edof/format/serializer.py +0 -0
  64. {edof-4.2.1 → edof-4.2.3}/edof/format/svg_io.py +0 -0
  65. {edof-4.2.1 → edof-4.2.3}/edof/format/variables.py +0 -0
  66. {edof-4.2.1 → edof-4.2.3}/edof/gui/__init__.py +0 -0
  67. {edof-4.2.1 → edof-4.2.3}/edof/gui/pyqt6_widget.py +0 -0
  68. {edof-4.2.1 → edof-4.2.3}/edof/gui/tkinter_canvas.py +0 -0
  69. {edof-4.2.1 → edof-4.2.3}/edof/interop/__init__.py +0 -0
  70. {edof-4.2.1 → edof-4.2.3}/edof/interop/docx_io.py +0 -0
  71. {edof-4.2.1 → edof-4.2.3}/edof/py.typed +0 -0
  72. {edof-4.2.1 → edof-4.2.3}/edof/units.py +0 -0
  73. {edof-4.2.1 → edof-4.2.3}/edof/utils/__init__.py +0 -0
  74. {edof-4.2.1 → edof-4.2.3}/edof/utils/compat.py +0 -0
  75. {edof-4.2.1 → edof-4.2.3}/edof/utils/legacy_v2.py +0 -0
  76. {edof-4.2.1 → edof-4.2.3}/edof/utils/pdf_import.py +0 -0
  77. {edof-4.2.1 → edof-4.2.3}/edof/utils/qr.py +0 -0
  78. {edof-4.2.1 → edof-4.2.3}/edof/utils/rtf.py +0 -0
  79. {edof-4.2.1 → edof-4.2.3}/edof/utils/safe_eval.py +0 -0
  80. {edof-4.2.1 → edof-4.2.3}/edof.egg-info/dependency_links.txt +0 -0
  81. {edof-4.2.1 → edof-4.2.3}/edof.egg-info/entry_points.txt +0 -0
  82. {edof-4.2.1 → edof-4.2.3}/edof.egg-info/requires.txt +0 -0
  83. {edof-4.2.1 → edof-4.2.3}/edof.egg-info/top_level.txt +0 -0
  84. {edof-4.2.1 → edof-4.2.3}/setup.cfg +0 -0
  85. {edof-4.2.1 → edof-4.2.3}/tests/test_v310.py +0 -0
  86. {edof-4.2.1 → edof-4.2.3}/tests/test_v400.py +0 -0
  87. {edof-4.2.1 → edof-4.2.3}/tests/test_v401.py +0 -0
  88. {edof-4.2.1 → edof-4.2.3}/tests/test_v402.py +0 -0
  89. {edof-4.2.1 → edof-4.2.3}/tests/test_v403.py +0 -0
  90. {edof-4.2.1 → edof-4.2.3}/tests/test_v410.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edof
3
- Version: 4.2.1
3
+ Version: 4.2.3
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
 
@@ -185,7 +185,7 @@ A `Document` contains pages; each `Page` contains objects. All measurements are
185
185
  | `Table` | Formatted table with per-cell styling | `cells`, `col_widths`, `row_heights`, `table_border` |
186
186
  | `Group` | Container with optional clip | `children` |
187
187
 
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.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+).
189
189
 
190
190
  #### Transform fields
191
191
 
@@ -207,7 +207,7 @@ All color fields accept tuples in 0–255 range. Both RGB (3-tuple) and RGBA (4-
207
207
  obj.style.color = (74, 144, 226) # RGB → alpha 255 implied
208
208
  obj.fill.color = (245, 245, 248, 255) # RGBA — explicit
209
209
  obj.stroke.color = (0, 0, 0, 200) # RGBA — translucent black
210
- shape.fill.color = "#4a90e2" # v4.1.0+: hex string accepted
210
+ shape.fill.color = "#4a90e2" # v4.2.0+: hex string accepted
211
211
  ```
212
212
 
213
213
  When read back, colors come as tuples (`obj.style.color` returns `(74, 144, 226)`, `obj.fill.color` returns `(245, 245, 248, 255)`).
@@ -469,7 +469,7 @@ heading.lock_text = True # text never editable until lock_text is cleared
469
469
  ### Headings with auto-computed height (no more silent text loss)
470
470
 
471
471
  ```python
472
- # 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
473
473
  heading = page.add_textbox_auto(
474
474
  20, 20, 170, "MY HEADING",
475
475
  font_size=24, bold=True, alignment="center",
@@ -481,7 +481,7 @@ next_y = heading.transform.y + heading.transform.height + 5 # 5 mm gap
481
481
  ### Tables with explicit sizing
482
482
 
483
483
  ```python
484
- # 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
485
485
  tbl = edof.make_table(
486
486
  rows=[
487
487
  ["Product", "Qty", "Price"],
@@ -509,7 +509,7 @@ tb.style.color = edof.as_color("#4a90e2") # hex via helper
509
509
  tb.style.color = edof.as_color("#4a90e2cc") # 8-digit hex with alpha
510
510
  ```
511
511
 
512
- ### Per-side padding (v4.1.0+)
512
+ ### Per-side padding (v4.2.0+)
513
513
 
514
514
  ```python
515
515
  tb.style.padding = 2.0 # uniform 2 mm on all sides
@@ -556,7 +556,7 @@ A PyQt6 desktop editor (`edof-editor`) ships with the library. It is a working e
556
556
 
557
557
  ## Viewer
558
558
 
559
- 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.
560
560
 
561
561
  ```bash
562
562
  pip install 'edof[viewer]'
@@ -698,7 +698,7 @@ A small `.bat` makes switching painless:
698
698
  @echo off
699
699
  call D:\apps\Edof_V401\edof-python\.venv\Scripts\activate.bat
700
700
  cd /d D:\apps\Edof_V401\edof-python
701
- cmd /k prompt [edof v4.0.1] $P$G
701
+ cmd /k prompt [edof v4.2.2] $P$G
702
702
  ```
703
703
 
704
704
  Each version's venv is independent. Removing a version is `rmdir /s /q <folder>`; nothing else needs cleanup.
@@ -103,7 +103,7 @@ A `Document` contains pages; each `Page` contains objects. All measurements are
103
103
  | `Table` | Formatted table with per-cell styling | `cells`, `col_widths`, `row_heights`, `table_border` |
104
104
  | `Group` | Container with optional clip | `children` |
105
105
 
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.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+).
107
107
 
108
108
  #### Transform fields
109
109
 
@@ -125,7 +125,7 @@ All color fields accept tuples in 0–255 range. Both RGB (3-tuple) and RGBA (4-
125
125
  obj.style.color = (74, 144, 226) # RGB → alpha 255 implied
126
126
  obj.fill.color = (245, 245, 248, 255) # RGBA — explicit
127
127
  obj.stroke.color = (0, 0, 0, 200) # RGBA — translucent black
128
- shape.fill.color = "#4a90e2" # v4.1.0+: hex string accepted
128
+ shape.fill.color = "#4a90e2" # v4.2.0+: hex string accepted
129
129
  ```
130
130
 
131
131
  When read back, colors come as tuples (`obj.style.color` returns `(74, 144, 226)`, `obj.fill.color` returns `(245, 245, 248, 255)`).
@@ -387,7 +387,7 @@ heading.lock_text = True # text never editable until lock_text is cleared
387
387
  ### Headings with auto-computed height (no more silent text loss)
388
388
 
389
389
  ```python
390
- # 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
391
391
  heading = page.add_textbox_auto(
392
392
  20, 20, 170, "MY HEADING",
393
393
  font_size=24, bold=True, alignment="center",
@@ -399,7 +399,7 @@ next_y = heading.transform.y + heading.transform.height + 5 # 5 mm gap
399
399
  ### Tables with explicit sizing
400
400
 
401
401
  ```python
402
- # 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
403
403
  tbl = edof.make_table(
404
404
  rows=[
405
405
  ["Product", "Qty", "Price"],
@@ -427,7 +427,7 @@ tb.style.color = edof.as_color("#4a90e2") # hex via helper
427
427
  tb.style.color = edof.as_color("#4a90e2cc") # 8-digit hex with alpha
428
428
  ```
429
429
 
430
- ### Per-side padding (v4.1.0+)
430
+ ### Per-side padding (v4.2.0+)
431
431
 
432
432
  ```python
433
433
  tb.style.padding = 2.0 # uniform 2 mm on all sides
@@ -474,7 +474,7 @@ A PyQt6 desktop editor (`edof-editor`) ships with the library. It is a working e
474
474
 
475
475
  ## Viewer
476
476
 
477
- 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.
478
478
 
479
479
  ```bash
480
480
  pip install 'edof[viewer]'
@@ -616,7 +616,7 @@ A small `.bat` makes switching painless:
616
616
  @echo off
617
617
  call D:\apps\Edof_V401\edof-python\.venv\Scripts\activate.bat
618
618
  cd /d D:\apps\Edof_V401\edof-python
619
- cmd /k prompt [edof v4.0.1] $P$G
619
+ cmd /k prompt [edof v4.2.2] $P$G
620
620
  ```
621
621
 
622
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,92 @@
1
+ """Shared .edof file-association dialog.
2
+
3
+ The user chooses *here* (inside the EDOF app) which app opens .edof files on
4
+ double-click; the choice is then registered with the OS. Used by both the
5
+ editor and the viewer so the experience is identical.
6
+ """
7
+ from __future__ import annotations
8
+
9
+
10
+ def manage_association(parent=None):
11
+ from PyQt6.QtWidgets import (
12
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QRadioButton, QButtonGroup,
13
+ QPushButton, QMessageBox, QFrame,
14
+ )
15
+ from edof._apps.file_assoc import (
16
+ associate_edof_files, unassociate_edof_files, current_association_status,
17
+ )
18
+
19
+ status = current_association_status()
20
+ is_assoc = "associated" in status.lower() and "not " not in status.lower()
21
+ editor_default = "editor" in status.lower()
22
+
23
+ dlg = QDialog(parent)
24
+ dlg.setWindowTitle("File association (.edof)")
25
+ dlg.setMinimumWidth(420)
26
+ v = QVBoxLayout(dlg)
27
+
28
+ v.addWidget(QLabel(f"<b>Current status:</b> {status}"))
29
+ info = QLabel(
30
+ "Choose which app opens a <code>.edof</code> file when you double-click "
31
+ "it. You decide here, inside EDOF; the other app stays available via "
32
+ "right-click \u2192 Open With. Files show the EDOF icon either way."
33
+ )
34
+ info.setWordWrap(True)
35
+ v.addWidget(info)
36
+
37
+ rb_view = QRadioButton("Open with EDOF Viewer (read-only)")
38
+ rb_edit = QRadioButton("Open with EDOF Editor")
39
+ grp = QButtonGroup(dlg)
40
+ grp.addButton(rb_view)
41
+ grp.addButton(rb_edit)
42
+ (rb_edit if editor_default else rb_view).setChecked(True)
43
+ v.addWidget(rb_view)
44
+ v.addWidget(rb_edit)
45
+
46
+ line = QFrame()
47
+ line.setFrameShape(QFrame.Shape.HLine)
48
+ v.addWidget(line)
49
+
50
+ row = QHBoxLayout()
51
+ btn_reg = QPushButton("Register / Update")
52
+ btn_rm = QPushButton("Remove association")
53
+ btn_rm.setEnabled(is_assoc)
54
+ btn_close = QPushButton("Close")
55
+ row.addWidget(btn_reg)
56
+ row.addWidget(btn_rm)
57
+ row.addStretch(1)
58
+ row.addWidget(btn_close)
59
+ v.addLayout(row)
60
+
61
+ note = QLabel(
62
+ "<small>On Windows this is per-user (no admin needed); you may need to "
63
+ "log out and back in for the Explorer icon to refresh.</small>"
64
+ )
65
+ note.setWordWrap(True)
66
+ v.addWidget(note)
67
+
68
+ def _do_register():
69
+ app = "editor" if rb_edit.isChecked() else "viewer"
70
+ try:
71
+ ok, msg = associate_edof_files(default_app=app)
72
+ (QMessageBox.information if ok else QMessageBox.warning)(
73
+ dlg, "File association", msg)
74
+ if ok:
75
+ dlg.accept()
76
+ except Exception as e: # noqa: BLE001
77
+ QMessageBox.critical(dlg, "File association", str(e))
78
+
79
+ def _do_remove():
80
+ try:
81
+ ok, msg = unassociate_edof_files()
82
+ (QMessageBox.information if ok else QMessageBox.warning)(
83
+ dlg, "File association", msg)
84
+ if ok:
85
+ dlg.accept()
86
+ except Exception as e: # noqa: BLE001
87
+ QMessageBox.critical(dlg, "File association", str(e))
88
+
89
+ btn_reg.clicked.connect(_do_register)
90
+ btn_rm.clicked.connect(_do_remove)
91
+ btn_close.clicked.connect(dlg.reject)
92
+ dlg.exec()
@@ -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)."""
@@ -796,6 +796,8 @@ Examples:
796
796
  help="Remove the .edof file association instead of adding it")
797
797
  pa.add_argument("--status", action="store_true",
798
798
  help="Show current association status and exit")
799
+ pa.add_argument("--app", choices=["viewer", "editor"], default="viewer",
800
+ help="Which app opens .edof on double-click (default: viewer)")
799
801
 
800
802
  return p
801
803
 
@@ -817,9 +819,8 @@ def cmd_associate_files(args):
817
819
  sys.exit(1)
818
820
  else:
819
821
  try:
820
- associate_edof_files()
821
- print("OK: .edof files now associated with edof-viewer.")
822
- print(" Double-clicking a .edof file will now open it in edof-viewer.")
822
+ ok, info = associate_edof_files(default_app=getattr(args, "app", "viewer"))
823
+ print(("OK: " if ok else "WARN: ") + info)
823
824
  print(" On Windows, you may need to log out and back in for icons to refresh.")
824
825
  except Exception as e:
825
826
  print(f"ERROR: {e}")
@@ -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,42 +10305,14 @@ 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 (choose app in-app, register/remove)."""
10293
10309
  try:
10294
- from edof._apps.file_assoc import (
10295
- associate_edof_files, unassociate_edof_files,
10296
- current_association_status,
10297
- )
10310
+ from edof._apps._assoc_dialog import manage_association
10298
10311
  except Exception as e:
10299
10312
  QMessageBox.warning(self, "File association",
10300
10313
  f"Could not load file association module: {e}")
10301
10314
  return
10302
- status = current_association_status()
10303
- is_assoc = "associated" in status.lower() and "not " not in status.lower()
10304
- msg = (f"<h3>File association</h3>"
10305
- 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>")
10310
- bb_text = "Unassociate" if is_assoc else "Associate"
10311
- ret = QMessageBox.question(self, "File association", msg,
10312
- QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel)
10313
- if ret != QMessageBox.StandardButton.Ok: return
10314
- try:
10315
- if is_assoc:
10316
- ok, info = unassociate_edof_files()
10317
- else:
10318
- ok, info = associate_edof_files()
10319
- if ok:
10320
- QMessageBox.information(self, "File association",
10321
- f"<p><b>Success.</b></p><p>{info}</p>")
10322
- else:
10323
- QMessageBox.warning(self, "File association",
10324
- f"<p><b>Failed.</b></p><p>{info}</p>")
10325
- except Exception as e:
10326
- QMessageBox.critical(self, "File association",
10327
- f"Error: {e}")
10315
+ manage_association(self)
10328
10316
 
10329
10317
  # ─────────────────────────────────────────────────────────────────────
10330
10318
  # v4.1.2: Multi-window support for embedded sub-documents
@@ -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.