edof 4.2.1__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.1 → edof-4.2.2}/PKG-INFO +8 -8
  2. {edof-4.2.1 → edof-4.2.2}/README.md +7 -7
  3. {edof-4.2.1 → 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.1 → edof-4.2.2}/edof/_apps/editor.py +26 -8
  19. {edof-4.2.1 → edof-4.2.2}/edof/_apps/edof_text_editor.py +22 -0
  20. {edof-4.2.1 → edof-4.2.2}/edof/_apps/file_assoc.py +142 -39
  21. {edof-4.2.1 → edof-4.2.2}/edof/_apps/viewer.py +66 -17
  22. {edof-4.2.1 → edof-4.2.2}/edof/engine/document_paginate.py +17 -2
  23. {edof-4.2.1 → edof-4.2.2}/edof/format/styles.py +3 -1
  24. {edof-4.2.1 → edof-4.2.2}/edof/version.py +1 -1
  25. {edof-4.2.1 → edof-4.2.2}/edof.egg-info/PKG-INFO +8 -8
  26. {edof-4.2.1 → edof-4.2.2}/edof.egg-info/SOURCES.txt +14 -0
  27. {edof-4.2.1 → edof-4.2.2}/pyproject.toml +2 -1
  28. {edof-4.2.1 → edof-4.2.2}/LICENSE +0 -0
  29. {edof-4.2.1 → edof-4.2.2}/edof/_apps/__init__.py +0 -0
  30. {edof-4.2.1 → edof-4.2.2}/edof/_apps/cli.py +0 -0
  31. {edof-4.2.1 → edof-4.2.2}/edof/_apps/shortcuts.py +0 -0
  32. {edof-4.2.1 → edof-4.2.2}/edof/api/__init__.py +0 -0
  33. {edof-4.2.1 → edof-4.2.2}/edof/api/commands.py +0 -0
  34. {edof-4.2.1 → edof-4.2.2}/edof/crypto/__init__.py +0 -0
  35. {edof-4.2.1 → edof-4.2.2}/edof/crypto/document_protection.py +0 -0
  36. {edof-4.2.1 → edof-4.2.2}/edof/crypto/encryption.py +0 -0
  37. {edof-4.2.1 → edof-4.2.2}/edof/crypto/permissions.py +0 -0
  38. {edof-4.2.1 → edof-4.2.2}/edof/editor_lang/en.json +0 -0
  39. {edof-4.2.1 → edof-4.2.2}/edof/engine/__init__.py +0 -0
  40. {edof-4.2.1 → edof-4.2.2}/edof/engine/color.py +0 -0
  41. {edof-4.2.1 → edof-4.2.2}/edof/engine/debug_log.py +0 -0
  42. {edof-4.2.1 → edof-4.2.2}/edof/engine/document_flow.py +0 -0
  43. {edof-4.2.1 → edof-4.2.2}/edof/engine/renderer.py +0 -0
  44. {edof-4.2.1 → edof-4.2.2}/edof/engine/text_engine.py +0 -0
  45. {edof-4.2.1 → edof-4.2.2}/edof/engine/text_layout.py +0 -0
  46. {edof-4.2.1 → edof-4.2.2}/edof/engine/textbox_flow.py +0 -0
  47. {edof-4.2.1 → edof-4.2.2}/edof/engine/transform.py +0 -0
  48. {edof-4.2.1 → edof-4.2.2}/edof/exceptions.py +0 -0
  49. {edof-4.2.1 → edof-4.2.2}/edof/export/__init__.py +0 -0
  50. {edof-4.2.1 → edof-4.2.2}/edof/export/bitmap.py +0 -0
  51. {edof-4.2.1 → edof-4.2.2}/edof/export/legacy_v3.py +0 -0
  52. {edof-4.2.1 → edof-4.2.2}/edof/export/pdf.py +0 -0
  53. {edof-4.2.1 → edof-4.2.2}/edof/export/pdf_writer.py +0 -0
  54. {edof-4.2.1 → edof-4.2.2}/edof/export/printer.py +0 -0
  55. {edof-4.2.1 → edof-4.2.2}/edof/export/svg.py +0 -0
  56. {edof-4.2.1 → edof-4.2.2}/edof/format/__init__.py +0 -0
  57. {edof-4.2.1 → edof-4.2.2}/edof/format/document.py +0 -0
  58. {edof-4.2.1 → edof-4.2.2}/edof/format/document_body.py +0 -0
  59. {edof-4.2.1 → edof-4.2.2}/edof/format/document_boxes.py +0 -0
  60. {edof-4.2.1 → edof-4.2.2}/edof/format/objects.py +0 -0
  61. {edof-4.2.1 → edof-4.2.2}/edof/format/serializer.py +0 -0
  62. {edof-4.2.1 → edof-4.2.2}/edof/format/svg_io.py +0 -0
  63. {edof-4.2.1 → edof-4.2.2}/edof/format/variables.py +0 -0
  64. {edof-4.2.1 → edof-4.2.2}/edof/gui/__init__.py +0 -0
  65. {edof-4.2.1 → edof-4.2.2}/edof/gui/pyqt6_widget.py +0 -0
  66. {edof-4.2.1 → edof-4.2.2}/edof/gui/tkinter_canvas.py +0 -0
  67. {edof-4.2.1 → edof-4.2.2}/edof/interop/__init__.py +0 -0
  68. {edof-4.2.1 → edof-4.2.2}/edof/interop/docx_io.py +0 -0
  69. {edof-4.2.1 → edof-4.2.2}/edof/py.typed +0 -0
  70. {edof-4.2.1 → edof-4.2.2}/edof/units.py +0 -0
  71. {edof-4.2.1 → edof-4.2.2}/edof/utils/__init__.py +0 -0
  72. {edof-4.2.1 → edof-4.2.2}/edof/utils/compat.py +0 -0
  73. {edof-4.2.1 → edof-4.2.2}/edof/utils/legacy_v2.py +0 -0
  74. {edof-4.2.1 → edof-4.2.2}/edof/utils/pdf_import.py +0 -0
  75. {edof-4.2.1 → edof-4.2.2}/edof/utils/qr.py +0 -0
  76. {edof-4.2.1 → edof-4.2.2}/edof/utils/rtf.py +0 -0
  77. {edof-4.2.1 → edof-4.2.2}/edof/utils/safe_eval.py +0 -0
  78. {edof-4.2.1 → edof-4.2.2}/edof.egg-info/dependency_links.txt +0 -0
  79. {edof-4.2.1 → edof-4.2.2}/edof.egg-info/entry_points.txt +0 -0
  80. {edof-4.2.1 → edof-4.2.2}/edof.egg-info/requires.txt +0 -0
  81. {edof-4.2.1 → edof-4.2.2}/edof.egg-info/top_level.txt +0 -0
  82. {edof-4.2.1 → edof-4.2.2}/setup.cfg +0 -0
  83. {edof-4.2.1 → edof-4.2.2}/tests/test_v310.py +0 -0
  84. {edof-4.2.1 → edof-4.2.2}/tests/test_v400.py +0 -0
  85. {edof-4.2.1 → edof-4.2.2}/tests/test_v401.py +0 -0
  86. {edof-4.2.1 → edof-4.2.2}/tests/test_v402.py +0 -0
  87. {edof-4.2.1 → edof-4.2.2}/tests/test_v403.py +0 -0
  88. {edof-4.2.1 → 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.1
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
 
@@ -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,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)
@@ -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__":
@@ -90,6 +90,14 @@ class EdofViewer(QMainWindow):
90
90
  def __init__(self, filepath: Optional[str] = None):
91
91
  super().__init__()
92
92
  self.setWindowTitle(f"EDOF Viewer {edof.__version__}")
93
+ # v4.2.2: window / taskbar icon
94
+ try:
95
+ from edof._apps.assets import icon_path
96
+ _ip = icon_path("edof-viewer.ico") or icon_path("edof-viewer.png")
97
+ if _ip:
98
+ self.setWindowIcon(QIcon(_ip))
99
+ except Exception:
100
+ pass
93
101
  self.resize(1100, 800)
94
102
  self.setStyleSheet(VIEWER_QSS)
95
103
 
@@ -143,6 +151,12 @@ class EdofViewer(QMainWindow):
143
151
  self._act_open.triggered.connect(self._on_open)
144
152
  fm.addAction(self._act_open)
145
153
 
154
+ # v4.2.1: open the current document in the editor
155
+ self._act_edit = QAction("Open in &Editor", self)
156
+ self._act_edit.setShortcut(QKeySequence("Ctrl+E"))
157
+ self._act_edit.triggered.connect(self._open_in_editor)
158
+ fm.addAction(self._act_edit)
159
+
146
160
  self._act_export_pdf = QAction("Export as &PDF…", self)
147
161
  self._act_export_pdf.triggered.connect(self._on_export_pdf)
148
162
  fm.addAction(self._act_export_pdf)
@@ -214,7 +228,7 @@ class EdofViewer(QMainWindow):
214
228
  act_about.triggered.connect(self._on_about)
215
229
  hm.addAction(act_about)
216
230
 
217
- act_associate = QAction("Associate .edof files with this viewer…", self)
231
+ act_associate = QAction("File association (.edof)…", self)
218
232
  act_associate.triggered.connect(self._on_associate)
219
233
  hm.addAction(act_associate)
220
234
 
@@ -520,28 +534,43 @@ class EdofViewer(QMainWindow):
520
534
  )
521
535
 
522
536
  def _on_associate(self):
523
- from edof._apps.file_assoc import associate_edof_files, current_association_status
524
- status = current_association_status()
525
- msg = (
526
- "Associate .edof files with edof Viewer?\n\n"
527
- "After this, double-clicking any .edof file in your file manager\n"
528
- "will open it directly in this viewer.\n\n"
529
- f"Current status: {status}"
537
+ from edof._apps.file_assoc import (
538
+ associate_edof_files, unassociate_edof_files,
539
+ current_association_status,
530
540
  )
531
- reply = QMessageBox.question(self, "Associate .edof files", msg,
541
+ status = current_association_status()
542
+ is_assoc = "associated" in status.lower() and "not " not in status.lower()
543
+ if is_assoc:
544
+ msg = ("Remove the .edof file association?\n\n"
545
+ "Explorer will stop showing the EDOF icon for .edof files "
546
+ "and will no longer offer EDOF apps to open them.\n\n"
547
+ f"Current status: {status}")
548
+ title, verb = "Remove association", "remove"
549
+ else:
550
+ msg = ("Register .edof files?\n\n"
551
+ "Files will show the EDOF icon, and the first time you open "
552
+ "one your system will let you choose the Viewer or the "
553
+ "Editor (no default is forced).\n\n"
554
+ f"Current status: {status}")
555
+ title, verb = "Register .edof files", "register"
556
+ reply = QMessageBox.question(self, title, msg,
532
557
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
533
558
  if reply != QMessageBox.StandardButton.Yes:
534
559
  return
535
560
  try:
536
- associate_edof_files()
537
- QMessageBox.information(self, "Done",
538
- "Successfully associated .edof files with edof Viewer.\n\n"
539
- "On Windows, you may need to log out and back in for the\n"
540
- "icons in Explorer to refresh.")
561
+ ok, info = (unassociate_edof_files() if is_assoc
562
+ else associate_edof_files())
563
+ if ok:
564
+ QMessageBox.information(self, "Done",
565
+ f"{info}\n\n"
566
+ "On Windows you may need to log out and back in for the "
567
+ "icons in Explorer to refresh.")
568
+ else:
569
+ QMessageBox.warning(self, "Could not " + verb, info)
541
570
  except Exception as e:
542
- QMessageBox.warning(self, "Could not associate",
543
- f"Failed to associate file types:\n\n{e}\n\n"
544
- f"You may need to run the viewer as Administrator on Windows.")
571
+ QMessageBox.warning(self, "Could not " + verb,
572
+ f"Failed to {verb} file types:\n\n{e}\n\n"
573
+ f"On Windows this is per-user and should not need admin rights.")
545
574
 
546
575
  def _on_donate(self):
547
576
  try:
@@ -550,6 +579,26 @@ class EdofViewer(QMainWindow):
550
579
  except Exception:
551
580
  pass
552
581
 
582
+ def _open_in_editor(self):
583
+ """v4.2.1: launch edof-editor, opening the currently viewed file."""
584
+ import shutil, subprocess
585
+ from PyQt6.QtWidgets import QMessageBox
586
+ exe = shutil.which("edof-editor")
587
+ if exe:
588
+ args = [exe]
589
+ else:
590
+ args = [sys.executable, "-m", "edof._apps.editor"]
591
+ if self._filepath:
592
+ args.append(self._filepath)
593
+ try:
594
+ subprocess.Popen(args, close_fds=True)
595
+ except Exception as e:
596
+ QMessageBox.warning(
597
+ self, "Open in Editor",
598
+ "Could not launch the editor.\n"
599
+ "Make sure it is installed: pip install \"edof[all]\"\n\n"
600
+ f"({e})")
601
+
553
602
 
554
603
  # ─────────────────────────────────────────────────────────────────────────────
555
604
  # Entry point
@@ -230,13 +230,28 @@ def _sync_body_from_textboxes(doc) -> None:
230
230
  para_runs = _split_runs_at_newlines(all_runs)
231
231
  old_paragraphs = doc.body.paragraphs or []
232
232
  new_paragraphs: List[Paragraph] = []
233
+
234
+ def _runs_align(runs):
235
+ # v4.2.1: the editor stamps the chosen alignment onto the runs of a
236
+ # paragraph (set_alignment). Promote that onto Paragraph.alignment so
237
+ # it is the durable, canonical home: it survives serialization and is
238
+ # read by the renderer, the .docx exporter and style resolution. Without
239
+ # this the alignment lived only on the runs / the page-local map and was
240
+ # lost on save and on export.
241
+ for _r in runs:
242
+ a = getattr(_r, 'alignment', None)
243
+ if a:
244
+ return a
245
+ return None
246
+
233
247
  for i, runs in enumerate(para_runs):
248
+ run_al = _runs_align(runs)
234
249
  if i < len(old_paragraphs):
235
250
  old = old_paragraphs[i]
236
251
  new_p = Paragraph(
237
252
  runs=runs,
238
253
  style_id=old.style_id,
239
- alignment=old.alignment,
254
+ alignment=(run_al if run_al is not None else old.alignment),
240
255
  line_height=old.line_height,
241
256
  space_before_mm=old.space_before_mm,
242
257
  space_after_mm=old.space_after_mm,
@@ -251,7 +266,7 @@ def _sync_body_from_textboxes(doc) -> None:
251
266
  widow_orphan_control=old.widow_orphan_control,
252
267
  )
253
268
  else:
254
- new_p = Paragraph(runs=runs)
269
+ new_p = Paragraph(runs=runs, alignment=run_al)
255
270
  new_paragraphs.append(new_p)
256
271
  # v4.1.23.52: once pagination has run, the page bodies' hard-break flags are
257
272
  # the authoritative source of truth for page_break_before. Re-apply it so a
@@ -330,13 +330,15 @@ class TextRun:
330
330
  if self.background is not None: d["background"] = _rgba_to_hex(self.background)
331
331
  if self.line_height is not None: d["line_height"] = self.line_height
332
332
  if self.letter_spacing is not None: d["letter_spacing"] = self.letter_spacing
333
+ if self.alignment is not None: d["alignment"] = self.alignment
333
334
  return d
334
335
 
335
336
  @classmethod
336
337
  def from_dict(cls, d: dict) -> "TextRun":
337
338
  r = cls(text=d.get("text", ""))
338
339
  for k in ("font_family", "font_size", "bold", "italic",
339
- "underline", "strikethrough", "line_height", "letter_spacing"):
340
+ "underline", "strikethrough", "line_height", "letter_spacing",
341
+ "alignment"):
340
342
  if k in d: setattr(r, k, d[k])
341
343
  for k in ("color", "background"):
342
344
  if k in d:
@@ -9,7 +9,7 @@ typography values in pt and are migrated on load.
9
9
  """
10
10
 
11
11
  from __future__ import annotations
12
- __version__ = "4.2.1"
12
+ __version__ = "4.2.2"
13
13
  FORMAT_MAJOR = 4
14
14
  FORMAT_MINOR = 2
15
15
  FORMAT_PATCH = 0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edof
3
- Version: 4.2.1
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
 
@@ -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.
@@ -19,6 +19,20 @@ edof/_apps/edof_text_editor.py
19
19
  edof/_apps/file_assoc.py
20
20
  edof/_apps/shortcuts.py
21
21
  edof/_apps/viewer.py
22
+ edof/_apps/assets/__init__.py
23
+ edof/_apps/assets/icons/__init__.py
24
+ edof/_apps/assets/icons/edof-document-512.png
25
+ edof/_apps/assets/icons/edof-document.icns
26
+ edof/_apps/assets/icons/edof-document.ico
27
+ edof/_apps/assets/icons/edof-document.png
28
+ edof/_apps/assets/icons/edof-editor-512.png
29
+ edof/_apps/assets/icons/edof-editor.icns
30
+ edof/_apps/assets/icons/edof-editor.ico
31
+ edof/_apps/assets/icons/edof-editor.png
32
+ edof/_apps/assets/icons/edof-viewer-512.png
33
+ edof/_apps/assets/icons/edof-viewer.icns
34
+ edof/_apps/assets/icons/edof-viewer.ico
35
+ edof/_apps/assets/icons/edof-viewer.png
22
36
  edof/api/__init__.py
23
37
  edof/api/commands.py
24
38
  edof/crypto/__init__.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "edof"
7
- version = "4.2.1"
7
+ version = "4.2.2"
8
8
  description = "Easy Document Format – programmatic document creation, editing and export with vector PDF, rich text, tables and PDF import"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -66,6 +66,7 @@ include = ["edof*"]
66
66
 
67
67
  [tool.setuptools.package-data]
68
68
  edof = ["py.typed", "editor_lang/*.json"]
69
+ "edof._apps.assets.icons" = ["*.ico", "*.png", "*.icns"]
69
70
 
70
71
  [tool.black]
71
72
  line-length = 100
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes