miniword 0.1.0__py3-none-any.whl

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 (133) hide show
  1. miniword/__init__.py +1 -0
  2. miniword/__main__.py +76 -0
  3. miniword/core/__init__.py +0 -0
  4. miniword/core/config.py +34 -0
  5. miniword/core/document.py +90 -0
  6. miniword/core/documentnode.py +22 -0
  7. miniword/core/fontfinder.py +274 -0
  8. miniword/core/papersizes.py +13 -0
  9. miniword/core/styles.py +158 -0
  10. miniword/core/stylesheet.py +58 -0
  11. miniword/core/texels.py +9 -0
  12. miniword/core/units.py +4 -0
  13. miniword/core/utils.py +195 -0
  14. miniword/footnotes/footnotes.py +81 -0
  15. miniword/icons/border_all.svg +9 -0
  16. miniword/icons/border_bottom.svg +9 -0
  17. miniword/icons/border_inner.svg +9 -0
  18. miniword/icons/border_inner_h.svg +9 -0
  19. miniword/icons/border_inner_v.svg +9 -0
  20. miniword/icons/border_left.svg +9 -0
  21. miniword/icons/border_none.svg +9 -0
  22. miniword/icons/border_outer.svg +9 -0
  23. miniword/icons/border_right.svg +9 -0
  24. miniword/icons/border_top.svg +9 -0
  25. miniword/icons/format_align_center_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  26. miniword/icons/format_align_justify_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  27. miniword/icons/format_align_left_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  28. miniword/icons/format_align_right_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  29. miniword/icons/format_bold_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  30. miniword/icons/format_indent_decrease_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  31. miniword/icons/format_indent_increase_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  32. miniword/icons/format_italic_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  33. miniword/icons/format_underlined_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  34. miniword/icons/image.svg +4 -0
  35. miniword/icons/image_active.svg +5 -0
  36. miniword/icons/image_hover.svg +5 -0
  37. miniword/icons/links.svg +4 -0
  38. miniword/icons/miniword.svg +17 -0
  39. miniword/icons/outline.svg +4 -0
  40. miniword/icons/search.svg +4 -0
  41. miniword/icons/search_active.svg +1 -0
  42. miniword/icons/search_hover.svg +1 -0
  43. miniword/icons/settings.svg +4 -0
  44. miniword/icons/settings_active.svg +1 -0
  45. miniword/icons/settings_hover.svg +1 -0
  46. miniword/icons/style.svg +4 -0
  47. miniword/icons/style_active.svg +43 -0
  48. miniword/icons/style_hover.svg +43 -0
  49. miniword/icons/table.svg +4 -0
  50. miniword/icons/table_active.svg +5 -0
  51. miniword/icons/table_cursor.svg +7 -0
  52. miniword/icons/table_cursor_active.svg +6 -0
  53. miniword/icons/table_cursor_hover.svg +6 -0
  54. miniword/icons/table_hover.svg +5 -0
  55. miniword/icons/table_matrix.svg +9 -0
  56. miniword/icons/table_matrix_active.svg +7 -0
  57. miniword/icons/table_matrix_hover.svg +7 -0
  58. miniword/images/__init__.py +2 -0
  59. miniword/images/image_controllers.py +341 -0
  60. miniword/images/image_panel.py +381 -0
  61. miniword/images/imageio.py +45 -0
  62. miniword/images/images.py +269 -0
  63. miniword/io/__init__.py +0 -0
  64. miniword/io/importexport.py +259 -0
  65. miniword/io/texeltreeformat.py +803 -0
  66. miniword/io/txlio.py +286 -0
  67. miniword/layout/__init__.py +0 -0
  68. miniword/layout/annotation.py +227 -0
  69. miniword/layout/boxes.py +926 -0
  70. miniword/layout/builderbase.py +177 -0
  71. miniword/layout/cache.py +23 -0
  72. miniword/layout/cairodevice.py +862 -0
  73. miniword/layout/counters.py +65 -0
  74. miniword/layout/factory.py +98 -0
  75. miniword/layout/layoutbase.py +155 -0
  76. miniword/layout/linewrap.py +232 -0
  77. miniword/layout/page.py +119 -0
  78. miniword/layout/pagebuilder.py +708 -0
  79. miniword/layout/pagegen.py +675 -0
  80. miniword/layout/rect.py +44 -0
  81. miniword/layout/simplelayout.py +278 -0
  82. miniword/layout/stretchable.py +335 -0
  83. miniword/layout/testdevice.py +52 -0
  84. miniword/plugins/__init__.py +1 -0
  85. miniword/plugins/mdfilter.py +1482 -0
  86. miniword/tables/__init__.py +3 -0
  87. miniword/tables/table_boxes.py +447 -0
  88. miniword/tables/table_controllers.py +586 -0
  89. miniword/tables/table_factory.py +118 -0
  90. miniword/tables/table_panel.py +632 -0
  91. miniword/tables/tables.py +490 -0
  92. miniword/texteditor/__init__.py +0 -0
  93. miniword/texteditor/actions.py +371 -0
  94. miniword/texteditor/boxcontroller.py +220 -0
  95. miniword/texteditor/controller.py +81 -0
  96. miniword/texteditor/editor.py +870 -0
  97. miniword/texteditor/textcanvas.py +650 -0
  98. miniword/texteditor/undoredo.py +88 -0
  99. miniword/textmodel/__init__.py +2 -0
  100. miniword/textmodel/iterators.py +64 -0
  101. miniword/textmodel/modelbase.py +91 -0
  102. miniword/textmodel/properties.py +46 -0
  103. miniword/textmodel/styles.py +348 -0
  104. miniword/textmodel/submodel.py +147 -0
  105. miniword/textmodel/texeltree.py +1125 -0
  106. miniword/textmodel/textmodel.py +983 -0
  107. miniword/textmodel/utils.py +580 -0
  108. miniword/textmodel/viewbase.py +93 -0
  109. miniword/textmodel/weights.py +195 -0
  110. miniword/ui/__init__.py +0 -0
  111. miniword/ui/buttonbar.py +76 -0
  112. miniword/ui/colours.py +86 -0
  113. miniword/ui/design.py +151 -0
  114. miniword/ui/flatbutton.py +105 -0
  115. miniword/ui/fontctrl.py +249 -0
  116. miniword/ui/icons.py +19 -0
  117. miniword/ui/linkpanel.py +195 -0
  118. miniword/ui/mainwindow.py +1016 -0
  119. miniword/ui/outlinepanel.py +160 -0
  120. miniword/ui/searchtool.py +618 -0
  121. miniword/ui/settingsinspector.py +155 -0
  122. miniword/ui/sidepanel.py +160 -0
  123. miniword/ui/styleinspector.py +1137 -0
  124. miniword/ui/stylemenu.py +660 -0
  125. miniword/ui/testing.py +30 -0
  126. miniword/ui/threestate.py +157 -0
  127. miniword/ui/unitentry.py +221 -0
  128. miniword-0.1.0.dist-info/METADATA +167 -0
  129. miniword-0.1.0.dist-info/RECORD +133 -0
  130. miniword-0.1.0.dist-info/WHEEL +5 -0
  131. miniword-0.1.0.dist-info/entry_points.txt +2 -0
  132. miniword-0.1.0.dist-info/licenses/LICENSE +165 -0
  133. miniword-0.1.0.dist-info/top_level.txt +1 -0
miniword/__init__.py ADDED
@@ -0,0 +1 @@
1
+ import wx.lib.wxcairo as wxcairo
miniword/__main__.py ADDED
@@ -0,0 +1,76 @@
1
+ import sys
2
+ import wx
3
+ from .core.document import Document
4
+ from .core.config import get_config
5
+ from .ui.mainwindow import MainFrame
6
+ from .ui.unitentry import LengthInput, UnitPrefs
7
+ from .layout import pagebuilder
8
+
9
+
10
+ def _enable_dpi_awareness():
11
+ """Enable Per-Monitor DPI awareness on Windows to avoid blurry rendering."""
12
+ if sys.platform != 'win32':
13
+ return
14
+ try:
15
+ import ctypes
16
+ ctypes.windll.shcore.SetProcessDpiAwareness(2) # Per-Monitor DPI aware
17
+ except Exception:
18
+ try:
19
+ ctypes.windll.user32.SetProcessDPIAware()
20
+ except Exception:
21
+ pass
22
+
23
+ def _enable_dpi_awareness():
24
+ """Enable Per-Monitor DPI awareness v2 on Windows."""
25
+ if sys.platform != 'win32':
26
+ return
27
+ try:
28
+ # Windows 10 v1703+: Using SetProcessDpiAwarenessContext mit v2
29
+ # -4 entspricht DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
30
+ ctypes.windll.user32.SetProcessDpiAwarenessContext(-4)
31
+ except Exception:
32
+ try:
33
+ # Fallback for Windows 8.1 / early Win 10 (2 = Process_Per_Monitor_DPI_Aware)
34
+ ctypes.windll.shcore.SetProcessDpiAwareness(2)
35
+ except Exception:
36
+ try:
37
+ # Fallback for Windows Vista / 7
38
+ ctypes.windll.user32.SetProcessDPIAware()
39
+ except Exception:
40
+ pass
41
+
42
+ def main():
43
+ args = sys.argv[1:]
44
+ if '--debug' in args:
45
+ pagebuilder.DEBUG = True
46
+ args = [a for a in args if a != '--debug']
47
+ path = args[0] if args else None
48
+
49
+ _enable_dpi_awareness()
50
+ config = get_config()
51
+ LengthInput.prefs = UnitPrefs(
52
+ layout=config.get("layout_unit"),
53
+ typographic=config.get("typographic_unit"),
54
+ )
55
+ app = wx.App(redirect=False)
56
+ app.SetAppName("miniword")
57
+ from .ui.mainwindow import load_plugins
58
+ load_plugins()
59
+ if path:
60
+ from .io import importexport
61
+ try:
62
+ doc = importexport.open_file(path)
63
+ except Exception as e:
64
+ print("Error opening '%s': %s" % (path, e))
65
+ return
66
+ frame = MainFrame(doc)
67
+ frame._current_path = path
68
+ frame._update_title()
69
+ else:
70
+ frame = MainFrame(Document())
71
+ frame.Show()
72
+ app.MainLoop()
73
+
74
+
75
+ if __name__ == '__main__':
76
+ main()
File without changes
@@ -0,0 +1,34 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ _path = Path.home() / ".config" / "miniword" / "config.json"
5
+ _defaults = {"layout_unit": "mm", "typographic_unit": "mm"}
6
+
7
+ _instance = None
8
+
9
+
10
+ def get_config():
11
+ global _instance
12
+ if _instance is None:
13
+ _instance = Config()
14
+ return _instance
15
+
16
+
17
+ class Config:
18
+ """Persistent user preferences, stored in ~/.config/miniword/config.json."""
19
+
20
+ def __init__(self):
21
+ self._data = {**_defaults}
22
+ if _path.exists():
23
+ try:
24
+ self._data.update(json.loads(_path.read_text()))
25
+ except Exception:
26
+ pass
27
+
28
+ def get(self, key):
29
+ return self._data.get(key, _defaults.get(key))
30
+
31
+ def set(self, key, value):
32
+ self._data[key] = value
33
+ _path.parent.mkdir(parents=True, exist_ok=True)
34
+ _path.write_text(json.dumps(self._data, indent=2))
@@ -0,0 +1,90 @@
1
+ from ..textmodel.modelbase import Model
2
+ from ..textmodel.textmodel import TextModel
3
+ from .stylesheet import StyleSheet
4
+ from .units import cm, mm
5
+ from .styles import normal as _normal_style
6
+
7
+
8
+ settings_default = {
9
+ "title": "",
10
+ "author": "",
11
+ "paper": "A4", # "A4" / "Letter" / "custom"
12
+ "paper_width": 210 * mm, # used when paper == "custom"
13
+ "paper_height": 297 * mm,
14
+ "margin_top": 2.5 * cm,
15
+ "margin_right": 2.5 * cm,
16
+ "margin_bottom": 2.5 * cm,
17
+ "margin_left": 2.5 * cm,
18
+ }
19
+
20
+
21
+ class Document(Model):
22
+ def __init__(self):
23
+ self.basestyles = StyleSheet()
24
+ self.basestyles.set_owner(self, 'basestyles')
25
+ self.basestyles.set('normal', _normal_style)
26
+ self.textmodel = TextModel()
27
+ self.settings = {}
28
+ self.blobs = {} # {blob_id: bytes}
29
+ self.home_format = 'txl' # native format; set to ext on import
30
+
31
+ def set_setting(self, name, value):
32
+ if name in self.settings:
33
+ old = self.settings[name]
34
+ else:
35
+ old = settings_default[name]
36
+ if value != old:
37
+ if settings_default[name] == value:
38
+ del self.settings[name]
39
+ else:
40
+ self.settings[name] = value
41
+ self.notify_views('setting_changed', name, old)
42
+ return old
43
+
44
+ def save(self, path):
45
+ from ..io import txlio
46
+ txlio.save(self, path)
47
+
48
+ @classmethod
49
+ def load(cls, path):
50
+ from ..io.importexport import open_file
51
+ return open_file(path)
52
+
53
+ def basestyles_changed(self, *args, **kwds):
54
+ self.notify_views('basestyles_changed')
55
+
56
+
57
+
58
+ class TestDocument(Document):
59
+ def attribute_changed(self, *args, **kwds):
60
+ self.msg = args, kwds
61
+
62
+
63
+ def test_00():
64
+ "receiving child messages via fallback handler"
65
+ doc = TestDocument()
66
+ doc.xyzstyles = StyleSheet()
67
+ doc.xyzstyles.set_owner(doc, 'xyzstyles')
68
+ doc.msg = None
69
+ doc.xyzstyles.set("x", {})
70
+ assert doc.msg == ((doc.xyzstyles,), {})
71
+
72
+
73
+ def test_01():
74
+ "forwarding basestyles changes to views"
75
+ from ..textmodel.viewbase import ViewBase
76
+
77
+ class TestView(ViewBase):
78
+ def basestyles_changed(self, *args, **kwds):
79
+ self.msg = "basestyles_changed", args, kwds
80
+
81
+ def model_changed(self, *args, **kwds):
82
+ self.msg = "model_changed", args, kwds
83
+
84
+ doc = Document()
85
+ view = TestView()
86
+ view.model = doc
87
+
88
+ doc.basestyles.set('normal', dict(fontsize=9, dolor="black"))
89
+ assert view.msg == ('basestyles_changed', (doc,), {})
90
+
@@ -0,0 +1,22 @@
1
+ from ..textmodel.modelbase import Model
2
+
3
+
4
+ class DocumentNode(Model):
5
+ owner = None
6
+ name = None
7
+ def set_owner(self, owner, name):
8
+ """Set the attribute owner and the attribute name."""
9
+ self.owner = owner
10
+ self.name = name
11
+
12
+ def notify(self, message='model_changed', *args, **kwds):
13
+ """Inform all observers and the owner about the change."""
14
+ self.notify_views(message, *args, **kwds)
15
+ owner = self.owner
16
+ if owner is None:
17
+ return
18
+ if not self._call_if_present(owner, self.name+'_changed', self,
19
+ *args, **kwds):
20
+ self._call_if_present(owner, 'attribute_changed', self)
21
+
22
+
@@ -0,0 +1,274 @@
1
+ """Platform-independent font discovery.
2
+
3
+ Linux/macOS: delegates to fc-match (fontconfig CLI, fast).
4
+ Windows: uses the FontLink registry for script fallback, fonttools for
5
+ codepoint verification. Font files are opened on-demand and
6
+ cached so each file is read at most once per session.
7
+ """
8
+ import sys
9
+ import os
10
+ import subprocess
11
+
12
+ _path_cache = {} # (family, bold, italic) -> path | None
13
+ _fallback_cache = {} # codepoint -> (path, family) | (None, None)
14
+
15
+
16
+ # ── Windows backend ──────────────────────────────────────────────────────────
17
+
18
+ if sys.platform == 'win32':
19
+ import winreg
20
+ import threading
21
+
22
+ try:
23
+ from fontTools.ttLib import TTFont as _TTFont
24
+ except ImportError:
25
+ try:
26
+ from fonttools.ttLib import TTFont as _TTFont
27
+ except ImportError:
28
+ _TTFont = None
29
+ print(
30
+ 'Warning: fonttools not found — font fallback for non-Latin '
31
+ 'scripts disabled on Windows. Install with: pip install fonttools',
32
+ file=sys.stderr,
33
+ )
34
+
35
+ _FONTS_DIR = os.path.join(os.environ.get('WINDIR', r'C:\Windows'), 'Fonts')
36
+ _win_index = None # {family_lower: {(bold, italic): path}}
37
+ _win_font_link = None # family_lower -> [(path, display_name), ...]
38
+ _win_coverage = {} # path -> [(codepoint_set, display_name)] — on-demand
39
+ _preload_thread = None
40
+
41
+ def _build_win_index():
42
+ global _win_index
43
+ _win_index = {}
44
+ key_path = r'SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts'
45
+ try:
46
+ key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path)
47
+ except OSError:
48
+ return
49
+ i = 0
50
+ while True:
51
+ try:
52
+ name, value, _ = winreg.EnumValue(key, i)
53
+ except OSError:
54
+ break
55
+ i += 1
56
+ name = name.split('(')[0].strip()
57
+ bold = italic = False
58
+ for suffix, b, it in [
59
+ (' Bold Italic', True, True),
60
+ (' Bold', True, False),
61
+ (' Italic', False, True),
62
+ ]:
63
+ if name.endswith(suffix):
64
+ name = name[:-len(suffix)]
65
+ bold, italic = b, it
66
+ break
67
+ path = value if os.path.isabs(value) else os.path.join(_FONTS_DIR, value)
68
+ # Registry entries like "MS Gothic & MS UI Gothic & MS PGothic" list
69
+ # several family names for one file — register each part individually.
70
+ for part in name.split('&'):
71
+ family = part.strip().lower()
72
+ if family:
73
+ _win_index.setdefault(family, {})[(bold, italic)] = path
74
+ winreg.CloseKey(key)
75
+
76
+ def _build_font_link_index():
77
+ r"""Read FontLink\SystemLink into _win_font_link.
78
+
79
+ Each entry is a REG_MULTI_SZ of strings like
80
+ 'MSGOTHIC.TTC,MS UI Gothic' or 'MALGUN.TTF,Malgun Gothic,128,96'.
81
+ """
82
+ global _win_font_link
83
+ _win_font_link = {}
84
+ key_path = r'SOFTWARE\Microsoft\Windows NT\CurrentVersion\FontLink\SystemLink'
85
+ try:
86
+ key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path)
87
+ except OSError:
88
+ return
89
+ i = 0
90
+ while True:
91
+ try:
92
+ name, entries, _ = winreg.EnumValue(key, i)
93
+ except OSError:
94
+ break
95
+ i += 1
96
+ chain = []
97
+ for entry in entries:
98
+ parts = entry.split(',')
99
+ if len(parts) < 2:
100
+ continue
101
+ filename = parts[0].strip()
102
+ display = parts[1].strip()
103
+ path = (filename if os.path.isabs(filename)
104
+ else os.path.join(_FONTS_DIR, filename))
105
+ chain.append((path, display))
106
+ _win_font_link[name.lower()] = chain
107
+ winreg.CloseKey(key)
108
+ _start_preload()
109
+
110
+ def _preload_font_link_coverage():
111
+ if _TTFont is None:
112
+ return
113
+ for path, _ in _font_link_candidates():
114
+ if path not in _win_coverage:
115
+ _coverage_for_path(path)
116
+
117
+ def _start_preload():
118
+ global _preload_thread
119
+ if _preload_thread is not None:
120
+ return
121
+ _preload_thread = threading.Thread(
122
+ target=_preload_font_link_coverage,
123
+ daemon=True,
124
+ name='fontlink-preload',
125
+ )
126
+ _preload_thread.start()
127
+
128
+ def _coverage_for_path(path):
129
+ """Return cached [(codepoint_set, display_name)] for path.
130
+
131
+ Reads and caches the cmap of every sub-font in the file the first
132
+ time it is requested; subsequent calls are a dict lookup.
133
+ """
134
+ if path in _win_coverage:
135
+ return _win_coverage[path]
136
+ entries = []
137
+ if path.lower().endswith('.ttc'):
138
+ n = 0
139
+ while True:
140
+ try:
141
+ font = _TTFont(path, fontNumber=n, lazy=True)
142
+ cmap = font.getBestCmap()
143
+ name = font['name'].getDebugName(1) or ''
144
+ entries.append((set(cmap.keys()) if cmap else set(), name))
145
+ n += 1
146
+ except Exception:
147
+ break
148
+ else:
149
+ try:
150
+ font = _TTFont(path, lazy=True)
151
+ cmap = font.getBestCmap()
152
+ name = font['name'].getDebugName(1) or ''
153
+ entries.append((set(cmap.keys()) if cmap else set(), name))
154
+ except Exception:
155
+ pass
156
+ _win_coverage[path] = entries
157
+ return entries
158
+
159
+ def _font_link_candidates():
160
+ """Ordered list of (path, display_name) from the FontLink registry.
161
+
162
+ Uses the chains of 'Tahoma' and 'Segoe UI' as representative
163
+ references — they together cover all common scripts on Windows.
164
+ Duplicates are removed while preserving order.
165
+ """
166
+ seen = set()
167
+ candidates = []
168
+ for ref in ('tahoma', 'segoe ui', 'microsoft sans serif'):
169
+ for path, name in _win_font_link.get(ref, []):
170
+ if path not in seen:
171
+ seen.add(path)
172
+ candidates.append((path, name))
173
+ return candidates
174
+
175
+ def _resolve_windows(family, bold, italic):
176
+ if _win_index is None:
177
+ _build_win_index()
178
+ styles = _win_index.get(family.lower(), {})
179
+ for b, i in [(bold, italic), (bold, False), (False, False)]:
180
+ if (b, i) in styles:
181
+ return styles[(b, i)]
182
+ # prefix fallback
183
+ prefix = family.lower()
184
+ for fam, styles in _win_index.items():
185
+ if fam.startswith(prefix) and styles:
186
+ return next(iter(styles.values()))
187
+ return None
188
+
189
+ def _fallback_windows(codepoint):
190
+ if _TTFont is None:
191
+ return None, None
192
+
193
+ if _win_index is None:
194
+ _build_win_index()
195
+ if _win_font_link is None:
196
+ _build_font_link_index()
197
+
198
+ # Fast path: check FontLink candidates (typically ~9 fonts, no pre-scan needed)
199
+ for path, _ in _font_link_candidates():
200
+ for codepoint_set, display_name in _coverage_for_path(path):
201
+ if codepoint in codepoint_set:
202
+ return path, display_name
203
+
204
+ # Slow path: check every installed font (exotic scripts, user-installed fonts)
205
+ link_paths = {p for p, _ in _font_link_candidates()}
206
+ for styles in _win_index.values():
207
+ path = next(iter(styles.values()))
208
+ if path in link_paths:
209
+ continue
210
+ for codepoint_set, display_name in _coverage_for_path(path):
211
+ if codepoint in codepoint_set:
212
+ return path, display_name
213
+
214
+ return None, None
215
+
216
+
217
+ # ── Public API ────────────────────────────────────────────────────────────────
218
+
219
+ def init_preload():
220
+ """Start background font-index preload. Call once at startup when HarfBuzz is available."""
221
+ if sys.platform == 'win32':
222
+ _build_font_link_index()
223
+
224
+ def resolve_font_path(family, bold, italic):
225
+ """Return the file path for the best matching font, or None."""
226
+ key = (family, bold, italic)
227
+ if key in _path_cache:
228
+ return _path_cache[key]
229
+
230
+ if sys.platform == 'win32':
231
+ path = _resolve_windows(family, bold, italic)
232
+ else:
233
+ pattern = family
234
+ if bold and italic:
235
+ pattern += ':bold:italic'
236
+ elif bold:
237
+ pattern += ':bold'
238
+ elif italic:
239
+ pattern += ':italic'
240
+ try:
241
+ result = subprocess.run(
242
+ ['fc-match', pattern, '--format=%{file}'],
243
+ capture_output=True, text=True, timeout=2)
244
+ path = result.stdout.strip() or None
245
+ except (FileNotFoundError, subprocess.TimeoutExpired):
246
+ path = None
247
+
248
+ _path_cache[key] = path
249
+ return path
250
+
251
+
252
+ def find_fallback_info(codepoint):
253
+ """Return (path, family) of a font covering codepoint, or (None, None)."""
254
+ if codepoint in _fallback_cache:
255
+ return _fallback_cache[codepoint]
256
+
257
+ if sys.platform == 'win32':
258
+ info = _fallback_windows(codepoint)
259
+ else:
260
+ try:
261
+ result = subprocess.run(
262
+ ['fc-match', f':charset={codepoint:04x}', '--format=%{file}\t%{family}'],
263
+ capture_output=True, text=True, timeout=2)
264
+ line = result.stdout.strip()
265
+ if '\t' in line:
266
+ path, fam = line.split('\t', 1)
267
+ info = (path or None, fam.split(',')[0].strip() or None)
268
+ else:
269
+ info = (None, None)
270
+ except (FileNotFoundError, subprocess.TimeoutExpired):
271
+ info = (None, None)
272
+
273
+ _fallback_cache[codepoint] = info
274
+ return info
@@ -0,0 +1,13 @@
1
+ from .units import mm
2
+
3
+ PAPER_SIZES = {
4
+ 'A3': (297 * mm, 420 * mm),
5
+ 'A4': (210 * mm, 297 * mm),
6
+ 'A5': (148 * mm, 210 * mm),
7
+ 'A6': (105 * mm, 148 * mm),
8
+ 'B4': (250 * mm, 353 * mm),
9
+ 'B5': (176 * mm, 250 * mm),
10
+ 'Letter': (215.9 * mm, 279.4 * mm),
11
+ 'Legal': (215.9 * mm, 355.6 * mm),
12
+ 'Tabloid': (279.4 * mm, 431.8 * mm),
13
+ }
@@ -0,0 +1,158 @@
1
+ # -*- coding. utf-8 -*-
2
+
3
+ """
4
+ Conceptual notes and open questions:
5
+
6
+ List values (i.e. indent_levels) are still conceptually problematic.
7
+
8
+ - Should they be treated as if there were 10 distinct values
9
+ like indent_01, indent_02, ...?
10
+ - What constitutes an override — a single value or the entire list?
11
+
12
+ This issue arises for:
13
+ - offsets
14
+ - marker
15
+ - marker_pos
16
+ - marker_alignment
17
+ - numbering_style
18
+
19
+
20
+ This module consolidates style computation. The goal is to decouple
21
+ other modules from implementation details so the style model can be
22
+ changed easily.
23
+
24
+ A known issue with the current implementation is that text formatting
25
+ (styling) is not distinguished from paragraph layout parameters
26
+ (alignment, indent, ...). As a result, every box ends up with its own
27
+ dict containing many entries.
28
+
29
+ A cleaner approach might be to store only text formatting attributes
30
+ directly in ParStyle, and keep layout parameters in a separate
31
+ attribute.
32
+ """
33
+
34
+ from collections import OrderedDict
35
+ from .units import pt, inch, cm, mm
36
+
37
+ n_levels = 9 # 0–8, displayed as 1–9
38
+ defaultbullets = ("▪", "•", "◦", "–")
39
+
40
+
41
+ text_default = {
42
+ "font_family": "Arial",
43
+ "font_size": 12,
44
+ "bold": False,
45
+ "italic": False,
46
+ "underline": False,
47
+ "strike": False,
48
+ "href": "",
49
+ "color": "black",
50
+ "bgcolor": "white",
51
+ # "letter_spacing": 0, # normal
52
+ "vertical_position": "normal", # normal / superscript / subscript
53
+ }
54
+
55
+ structure_default = {
56
+ "paragraph_type": "normal", # normal / list / numbered
57
+ "fixed_indent": None, # None = free, 0–8 = fixed at that level
58
+ "indent_levels": tuple(i * cm for i in range(n_levels)),
59
+ "first_line_indent": 0, # hanging indent: use negative values
60
+ "list_indent": 1 * cm, # extra indent applied to list/numbered paragraphs
61
+ "marker": defaultbullets + ("–",) * (n_levels-len(defaultbullets)),
62
+ "marker_pos": (-0.8 * cm,) * n_levels,
63
+ # "marker_alignment": ('right',) * n_levels,
64
+ "marker_size": (1,) * n_levels,
65
+ "marker_color": ("black",) * n_levels,
66
+ "numbering_style": ("1.",) * n_levels, # e.g. "1.", "1.1", "a."
67
+ "start_number": None, # None or int (for numbered lists)
68
+ "counter": "item", # item / section
69
+ }
70
+
71
+ layout_default = {
72
+ "alignment": "left", # left / center / right / justify
73
+ "space_before": 0, # in pt
74
+ "space_after": 0, # in pt
75
+ "line_spacing": 1.0, # 1 = single spacing
76
+ # "min_line_height": None,
77
+ # "keep_together": False,
78
+ # "tab_stops": ..., # TODO
79
+
80
+ "page_break_before": False, # start on a new page
81
+ # "keep_with_next": False, # keep together with next paragraph
82
+ # "keep_lines_together": False, # prevent paragraph from breaking across pages
83
+ # "widow_control": True, # avoid widows
84
+ # "orphan_control": True, # avoid orphans
85
+ # "border_top": None, # top border
86
+ # "border_bottom": None, # bottom border
87
+ "block_color": None, # paragraph background color
88
+ "block_padding": 0, # padding around block_color rect
89
+ }
90
+
91
+ other_default = {
92
+ "next_style": "standard",
93
+ "role": None, # semantic role for export (e.g. markdown)
94
+ }
95
+
96
+
97
+ # Keys that live exclusively on individual paragraphs and must never be
98
+ # promoted to base styles or treated as style overrides (no red triangle).
99
+ PARAGRAPH_ONLY_KEYS = frozenset({'start_number'})
100
+
101
+
102
+ style_default = {
103
+ **text_default,
104
+ **structure_default,
105
+ **layout_default,
106
+ **other_default,
107
+ }
108
+
109
+
110
+ def updated(default, *styles):
111
+ """Merges dicts by updating from left to right."""
112
+ r = default.copy()
113
+ for s in styles:
114
+ r.update(s)
115
+ return r
116
+
117
+
118
+ # This stylesheet is intended for internal testing:
119
+ normal = updated(
120
+ text_default,
121
+ structure_default,
122
+ layout_default,
123
+ other_default,
124
+ dict(name="Normal"),
125
+ )
126
+
127
+ h0 = updated(normal, dict(name="Heading 1", role="h1", font_size=18, bold=True,
128
+ page_break_before=True))
129
+ h1 = updated(normal, dict(name="Heading 2", role="h2", font_size=16, bold=True,
130
+ color="red"))
131
+ h2 = updated(normal, dict(name="Heading 3", role="h3", font_size=14, bold=True))
132
+ h3 = updated(normal, dict(name="Heading 4", role="h4", font_size=12, bold=True))
133
+
134
+ from .stylesheet import StyleSheet
135
+
136
+ testsheet = StyleSheet()
137
+ testsheet.set('normal', normal)
138
+ testsheet.set('h0', h0)
139
+ testsheet.set('h1', h1)
140
+ testsheet.set('h2', h2)
141
+ testsheet.set('h3', h3)
142
+
143
+
144
+ def mk_style(stylesheet, parstyle, style):
145
+ basestyle = stylesheet[parstyle.get("base", "normal")]
146
+ return updated(basestyle, parstyle, style)
147
+
148
+
149
+ def mk_parstyle(stylesheet, parstyle):
150
+ basestyle = stylesheet[parstyle.get("base", "normal")]
151
+ return updated(basestyle, parstyle)
152
+
153
+
154
+ def test_00():
155
+ assert (21*cm - 595.27) < 0.1
156
+
157
+ def test_01():
158
+ testsheet.get('normal')['font_size'] == 12