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.
- miniword/__init__.py +1 -0
- miniword/__main__.py +76 -0
- miniword/core/__init__.py +0 -0
- miniword/core/config.py +34 -0
- miniword/core/document.py +90 -0
- miniword/core/documentnode.py +22 -0
- miniword/core/fontfinder.py +274 -0
- miniword/core/papersizes.py +13 -0
- miniword/core/styles.py +158 -0
- miniword/core/stylesheet.py +58 -0
- miniword/core/texels.py +9 -0
- miniword/core/units.py +4 -0
- miniword/core/utils.py +195 -0
- miniword/footnotes/footnotes.py +81 -0
- miniword/icons/border_all.svg +9 -0
- miniword/icons/border_bottom.svg +9 -0
- miniword/icons/border_inner.svg +9 -0
- miniword/icons/border_inner_h.svg +9 -0
- miniword/icons/border_inner_v.svg +9 -0
- miniword/icons/border_left.svg +9 -0
- miniword/icons/border_none.svg +9 -0
- miniword/icons/border_outer.svg +9 -0
- miniword/icons/border_right.svg +9 -0
- miniword/icons/border_top.svg +9 -0
- miniword/icons/format_align_center_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- miniword/icons/format_align_justify_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- miniword/icons/format_align_left_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- miniword/icons/format_align_right_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- miniword/icons/format_bold_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- miniword/icons/format_indent_decrease_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- miniword/icons/format_indent_increase_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- miniword/icons/format_italic_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- miniword/icons/format_underlined_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- miniword/icons/image.svg +4 -0
- miniword/icons/image_active.svg +5 -0
- miniword/icons/image_hover.svg +5 -0
- miniword/icons/links.svg +4 -0
- miniword/icons/miniword.svg +17 -0
- miniword/icons/outline.svg +4 -0
- miniword/icons/search.svg +4 -0
- miniword/icons/search_active.svg +1 -0
- miniword/icons/search_hover.svg +1 -0
- miniword/icons/settings.svg +4 -0
- miniword/icons/settings_active.svg +1 -0
- miniword/icons/settings_hover.svg +1 -0
- miniword/icons/style.svg +4 -0
- miniword/icons/style_active.svg +43 -0
- miniword/icons/style_hover.svg +43 -0
- miniword/icons/table.svg +4 -0
- miniword/icons/table_active.svg +5 -0
- miniword/icons/table_cursor.svg +7 -0
- miniword/icons/table_cursor_active.svg +6 -0
- miniword/icons/table_cursor_hover.svg +6 -0
- miniword/icons/table_hover.svg +5 -0
- miniword/icons/table_matrix.svg +9 -0
- miniword/icons/table_matrix_active.svg +7 -0
- miniword/icons/table_matrix_hover.svg +7 -0
- miniword/images/__init__.py +2 -0
- miniword/images/image_controllers.py +341 -0
- miniword/images/image_panel.py +381 -0
- miniword/images/imageio.py +45 -0
- miniword/images/images.py +269 -0
- miniword/io/__init__.py +0 -0
- miniword/io/importexport.py +259 -0
- miniword/io/texeltreeformat.py +803 -0
- miniword/io/txlio.py +286 -0
- miniword/layout/__init__.py +0 -0
- miniword/layout/annotation.py +227 -0
- miniword/layout/boxes.py +926 -0
- miniword/layout/builderbase.py +177 -0
- miniword/layout/cache.py +23 -0
- miniword/layout/cairodevice.py +862 -0
- miniword/layout/counters.py +65 -0
- miniword/layout/factory.py +98 -0
- miniword/layout/layoutbase.py +155 -0
- miniword/layout/linewrap.py +232 -0
- miniword/layout/page.py +119 -0
- miniword/layout/pagebuilder.py +708 -0
- miniword/layout/pagegen.py +675 -0
- miniword/layout/rect.py +44 -0
- miniword/layout/simplelayout.py +278 -0
- miniword/layout/stretchable.py +335 -0
- miniword/layout/testdevice.py +52 -0
- miniword/plugins/__init__.py +1 -0
- miniword/plugins/mdfilter.py +1482 -0
- miniword/tables/__init__.py +3 -0
- miniword/tables/table_boxes.py +447 -0
- miniword/tables/table_controllers.py +586 -0
- miniword/tables/table_factory.py +118 -0
- miniword/tables/table_panel.py +632 -0
- miniword/tables/tables.py +490 -0
- miniword/texteditor/__init__.py +0 -0
- miniword/texteditor/actions.py +371 -0
- miniword/texteditor/boxcontroller.py +220 -0
- miniword/texteditor/controller.py +81 -0
- miniword/texteditor/editor.py +870 -0
- miniword/texteditor/textcanvas.py +650 -0
- miniword/texteditor/undoredo.py +88 -0
- miniword/textmodel/__init__.py +2 -0
- miniword/textmodel/iterators.py +64 -0
- miniword/textmodel/modelbase.py +91 -0
- miniword/textmodel/properties.py +46 -0
- miniword/textmodel/styles.py +348 -0
- miniword/textmodel/submodel.py +147 -0
- miniword/textmodel/texeltree.py +1125 -0
- miniword/textmodel/textmodel.py +983 -0
- miniword/textmodel/utils.py +580 -0
- miniword/textmodel/viewbase.py +93 -0
- miniword/textmodel/weights.py +195 -0
- miniword/ui/__init__.py +0 -0
- miniword/ui/buttonbar.py +76 -0
- miniword/ui/colours.py +86 -0
- miniword/ui/design.py +151 -0
- miniword/ui/flatbutton.py +105 -0
- miniword/ui/fontctrl.py +249 -0
- miniword/ui/icons.py +19 -0
- miniword/ui/linkpanel.py +195 -0
- miniword/ui/mainwindow.py +1016 -0
- miniword/ui/outlinepanel.py +160 -0
- miniword/ui/searchtool.py +618 -0
- miniword/ui/settingsinspector.py +155 -0
- miniword/ui/sidepanel.py +160 -0
- miniword/ui/styleinspector.py +1137 -0
- miniword/ui/stylemenu.py +660 -0
- miniword/ui/testing.py +30 -0
- miniword/ui/threestate.py +157 -0
- miniword/ui/unitentry.py +221 -0
- miniword-0.1.0.dist-info/METADATA +167 -0
- miniword-0.1.0.dist-info/RECORD +133 -0
- miniword-0.1.0.dist-info/WHEEL +5 -0
- miniword-0.1.0.dist-info/entry_points.txt +2 -0
- miniword-0.1.0.dist-info/licenses/LICENSE +165 -0
- 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
|
miniword/core/config.py
ADDED
|
@@ -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
|
+
}
|
miniword/core/styles.py
ADDED
|
@@ -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
|