super-tiny2docx 0.1.0__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.
- super_tiny2docx-0.1.0/PKG-INFO +55 -0
- super_tiny2docx-0.1.0/README.md +23 -0
- super_tiny2docx-0.1.0/pyproject.toml +59 -0
- super_tiny2docx-0.1.0/src/super_tiny2docx/__init__.py +3 -0
- super_tiny2docx-0.1.0/src/super_tiny2docx/converter.py +577 -0
- super_tiny2docx-0.1.0/src/super_tiny2docx/doc_styles.py +247 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: super_tiny2docx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert HTML from TinyMCE (or other WYSIWYG editors) into .docx documents easily. Designed for developers looking to integrate rich text export functionality into their Python applications. Contributions welcome!
|
|
5
|
+
Keywords: html,docx,converter,tinymce,wysiwyg,document-generation,python-docx,html-to-docx
|
|
6
|
+
Author: Sekachev Maxim
|
|
7
|
+
Author-email: Sekachev Maxim <cement-fan@ya.ru>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: Text Processing :: Markup :: HTML
|
|
19
|
+
Classifier: Topic :: Office/Business :: Office Suites
|
|
20
|
+
Requires-Dist: python-docx>=0.8.11
|
|
21
|
+
Requires-Dist: beautifulsoup4>=4.10.0
|
|
22
|
+
Requires-Dist: pytest ; extra == 'dev'
|
|
23
|
+
Requires-Dist: black ; extra == 'dev'
|
|
24
|
+
Requires-Dist: flake8 ; extra == 'dev'
|
|
25
|
+
Requires-Dist: isort ; extra == 'dev'
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Project-URL: Homepage, https://github.com/cement-hools/super_tiny2docx
|
|
28
|
+
Project-URL: Repository, https://github.com/cement-hools/super_tiny2docx
|
|
29
|
+
Project-URL: Issues, https://github.com/cement-hools/super_tiny2docx
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# Super Tiny2Docx
|
|
34
|
+
|
|
35
|
+
Convert HTML from TinyMCE (or other WYSIWYG editors) into .docx documents easily.
|
|
36
|
+
Designed for developers looking to integrate rich text export functionality into their Python applications.
|
|
37
|
+
Contributions welcome!
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- Converts HTML generated by TinyMCE into a clean `.docx` document.
|
|
42
|
+
- Supports basic formatting like bold, italic, lists, links, images, etc.
|
|
43
|
+
- Easy to integrate into existing Python projects.
|
|
44
|
+
- Extensible and customizable.
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install super_tiny2docx
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Contributing
|
|
53
|
+
|
|
54
|
+
We welcome contributions!
|
|
55
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Super Tiny2Docx
|
|
2
|
+
|
|
3
|
+
Convert HTML from TinyMCE (or other WYSIWYG editors) into .docx documents easily.
|
|
4
|
+
Designed for developers looking to integrate rich text export functionality into their Python applications.
|
|
5
|
+
Contributions welcome!
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Converts HTML generated by TinyMCE into a clean `.docx` document.
|
|
10
|
+
- Supports basic formatting like bold, italic, lists, links, images, etc.
|
|
11
|
+
- Easy to integrate into existing Python projects.
|
|
12
|
+
- Extensible and customizable.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install super_tiny2docx
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Contributing
|
|
21
|
+
|
|
22
|
+
We welcome contributions!
|
|
23
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build >= 0.10.7, <0.11.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "super_tiny2docx"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Convert HTML from TinyMCE (or other WYSIWYG editors) into .docx documents easily. Designed for developers looking to integrate rich text export functionality into their Python applications. Contributions welcome!"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Sekachev Maxim", email = "cement-fan@ya.ru" },
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
"Topic :: Text Processing :: Markup :: HTML",
|
|
25
|
+
"Topic :: Office/Business :: Office Suites",
|
|
26
|
+
]
|
|
27
|
+
keywords = [
|
|
28
|
+
"html",
|
|
29
|
+
"docx",
|
|
30
|
+
"converter",
|
|
31
|
+
"tinymce",
|
|
32
|
+
"wysiwyg",
|
|
33
|
+
"document-generation",
|
|
34
|
+
"python-docx",
|
|
35
|
+
"html-to-docx",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
requires-python = ">=3.8"
|
|
39
|
+
dependencies = [
|
|
40
|
+
"python-docx>=0.8.11",
|
|
41
|
+
"beautifulsoup4>=4.10.0",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.optional-dependencies]
|
|
45
|
+
dev = [
|
|
46
|
+
"pytest",
|
|
47
|
+
"black",
|
|
48
|
+
"flake8",
|
|
49
|
+
"isort",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[project.urls]
|
|
53
|
+
Homepage = "https://github.com/cement-hools/super_tiny2docx"
|
|
54
|
+
Repository = "https://github.com/cement-hools/super_tiny2docx"
|
|
55
|
+
Issues = "https://github.com/cement-hools/super_tiny2docx"
|
|
56
|
+
|
|
57
|
+
[tool.setuptools.packages.find]
|
|
58
|
+
where = ["."]
|
|
59
|
+
include = ["super_tiny2docx"]
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from bs4 import BeautifulSoup, Comment, NavigableString
|
|
5
|
+
from docx import Document as Docx
|
|
6
|
+
from docx.document import Document
|
|
7
|
+
from docx.oxml import OxmlElement
|
|
8
|
+
from docx.oxml.ns import qn
|
|
9
|
+
from docx.shared import Pt, Cm
|
|
10
|
+
from docx.table import _Cell
|
|
11
|
+
|
|
12
|
+
from src.super_tiny2docx.doc_styles import ComputedStyle
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SuperTiny2Docx:
|
|
16
|
+
"""Convert to docx format with proper style inheritance."""
|
|
17
|
+
style_manager = ComputedStyle
|
|
18
|
+
|
|
19
|
+
def __init__(self, html_content: str):
|
|
20
|
+
"""
|
|
21
|
+
Конструктор класса SuperTiny2Docx.
|
|
22
|
+
:param html_content: Содержимое HTML.
|
|
23
|
+
"""
|
|
24
|
+
self.html_content = html_content
|
|
25
|
+
self.doc = None
|
|
26
|
+
self.soup = None
|
|
27
|
+
|
|
28
|
+
def convert(self, **kwargs) -> io.BytesIO:
|
|
29
|
+
"""Конвертирует HTML в DOCX"""
|
|
30
|
+
|
|
31
|
+
self.doc = Docx()
|
|
32
|
+
self._set_default_styles()
|
|
33
|
+
self.soup = BeautifulSoup(self.html_content, "html.parser")
|
|
34
|
+
|
|
35
|
+
main_content = self.soup.find("body")
|
|
36
|
+
if not main_content:
|
|
37
|
+
main_content = self.soup
|
|
38
|
+
|
|
39
|
+
# Обрабатываем контент рекурсивно с вычислением стилей
|
|
40
|
+
self._process_element(main_content, self.doc)
|
|
41
|
+
# Сохраняем результат
|
|
42
|
+
res_file = io.BytesIO()
|
|
43
|
+
self.doc.save(res_file)
|
|
44
|
+
res_file.seek(0)
|
|
45
|
+
|
|
46
|
+
return res_file
|
|
47
|
+
|
|
48
|
+
def _clear_document(self):
|
|
49
|
+
"""Очищает документ от содержимого, оставляя стили"""
|
|
50
|
+
# Удаляем все параграфы из основного тела
|
|
51
|
+
for paragraph in self.doc.paragraphs:
|
|
52
|
+
p_element = paragraph._element
|
|
53
|
+
p_element.getparent().remove(p_element)
|
|
54
|
+
|
|
55
|
+
# Удаляем все таблицы из основного тела
|
|
56
|
+
for table in self.doc.tables:
|
|
57
|
+
t_element = table._element
|
|
58
|
+
t_element.getparent().remove(t_element)
|
|
59
|
+
|
|
60
|
+
def _set_default_styles(self):
|
|
61
|
+
"""Устанавливает стили по умолчанию для документа"""
|
|
62
|
+
style = self.doc.styles["Normal"]
|
|
63
|
+
font = style.font
|
|
64
|
+
font.name = "Times New Roman"
|
|
65
|
+
font.size = Pt(14)
|
|
66
|
+
|
|
67
|
+
# Устанавливаем отступ после параграфа по умолчанию = 0
|
|
68
|
+
style.paragraph_format.space_after = Pt(0)
|
|
69
|
+
|
|
70
|
+
def _process_paragraph(self, element, parent_docx_element, computed_style):
|
|
71
|
+
"""Обрабатывает параграф"""
|
|
72
|
+
# Создаем новый параграф
|
|
73
|
+
if isinstance(parent_docx_element, (_Cell, Document)):
|
|
74
|
+
paragraph = parent_docx_element.add_paragraph()
|
|
75
|
+
else:
|
|
76
|
+
paragraph = parent_docx_element
|
|
77
|
+
|
|
78
|
+
self._apply_paragraph_styles(paragraph, computed_style)
|
|
79
|
+
|
|
80
|
+
# Обрабатываем дочерние элементы
|
|
81
|
+
self._process_children(element, paragraph, computed_style)
|
|
82
|
+
|
|
83
|
+
def _process_inline_container(self, element, parent_docx_element, computed_style):
|
|
84
|
+
"""Обрабатывает inline-контейнер (span, strong и т.д.) с сохранением пробелов"""
|
|
85
|
+
|
|
86
|
+
# Проверяем, есть ли у элемента дети
|
|
87
|
+
if not list(element.children):
|
|
88
|
+
# Если нет детей, это может быть сам текст
|
|
89
|
+
text = element.string
|
|
90
|
+
if element.string and element.string.strip():
|
|
91
|
+
self._process_text_with_context(
|
|
92
|
+
element.string, parent_docx_element, computed_style
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
# Обрабатываем детей с сохранением контекста
|
|
97
|
+
for child in element.children:
|
|
98
|
+
if isinstance(child, NavigableString):
|
|
99
|
+
# Это текст, обрабатываем его с текущими стилями
|
|
100
|
+
text = str(child)
|
|
101
|
+
if text: # Сохраняем даже пустые строки? Нет, только если есть текст
|
|
102
|
+
# Но нам нужно сохранить пробелы, поэтому не делаем strip()
|
|
103
|
+
self._process_text_with_context(
|
|
104
|
+
text, parent_docx_element, computed_style
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
# Это элемент, обрабатываем рекурсивно
|
|
108
|
+
self._process_element(child, parent_docx_element, computed_style)
|
|
109
|
+
|
|
110
|
+
def _process_text_with_context(self, text, parent_docx_element, computed_style):
|
|
111
|
+
"""Обрабатывает текст с учетом контекста и сохранением пробелов"""
|
|
112
|
+
if not text:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
# Находим или создаем параграф для текста
|
|
116
|
+
if isinstance(parent_docx_element, _Cell):
|
|
117
|
+
# Для ячеек таблицы создаем новый параграф, если нужно
|
|
118
|
+
if not parent_docx_element.paragraphs:
|
|
119
|
+
paragraph = parent_docx_element.add_paragraph()
|
|
120
|
+
else:
|
|
121
|
+
paragraph = parent_docx_element.paragraphs[-1]
|
|
122
|
+
elif isinstance(parent_docx_element, Document):
|
|
123
|
+
# Для корневого элемента
|
|
124
|
+
paragraph = parent_docx_element.add_paragraph()
|
|
125
|
+
else:
|
|
126
|
+
# Для других случаев (уже параграф)
|
|
127
|
+
paragraph = parent_docx_element
|
|
128
|
+
|
|
129
|
+
# Применяем стили к параграфу (только если это новый параграф)
|
|
130
|
+
if not paragraph.runs:
|
|
131
|
+
self._apply_paragraph_styles(paragraph, computed_style)
|
|
132
|
+
|
|
133
|
+
# Добавляем текст с сохранением пробелов и применяем стили
|
|
134
|
+
run = paragraph.add_run(text)
|
|
135
|
+
self._apply_run_styles(run, computed_style)
|
|
136
|
+
|
|
137
|
+
def _process_line_break(self, parent_docx_element, computed_style):
|
|
138
|
+
"""Обрабатывает перенос строки"""
|
|
139
|
+
if isinstance(parent_docx_element, _Cell):
|
|
140
|
+
# Для ячеек таблицы добавляем новый параграф
|
|
141
|
+
parent_docx_element.add_paragraph()
|
|
142
|
+
elif isinstance(parent_docx_element, Document):
|
|
143
|
+
parent_docx_element.add_paragraph()
|
|
144
|
+
|
|
145
|
+
def _process_table(self, element, parent_docx_element, computed_style):
|
|
146
|
+
"""Обрабатывает таблицу"""
|
|
147
|
+
# Ищем tbody
|
|
148
|
+
tbody = element.find("tbody", recursive=False)
|
|
149
|
+
if not tbody:
|
|
150
|
+
# Если нет tbody, ищем строки непосредственно в таблице
|
|
151
|
+
rows = element.find_all("tr", recursive=False)
|
|
152
|
+
if not rows:
|
|
153
|
+
return
|
|
154
|
+
# Создаем фиктивный tbody для единообразия
|
|
155
|
+
tbody = element
|
|
156
|
+
|
|
157
|
+
rows = tbody.find_all("tr", recursive=False)
|
|
158
|
+
if not rows:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# Определяем количество столбцов по первой строке
|
|
162
|
+
first_row_cells = rows[0].find_all(["td", "th"], recursive=False)
|
|
163
|
+
cols_count = len(first_row_cells)
|
|
164
|
+
|
|
165
|
+
# Создаем таблицу в docx
|
|
166
|
+
if isinstance(parent_docx_element, (_Cell, Document)):
|
|
167
|
+
docx_table = parent_docx_element.add_table(rows=len(rows), cols=cols_count)
|
|
168
|
+
else:
|
|
169
|
+
docx_table = self.doc.add_table(rows=len(rows), cols=cols_count)
|
|
170
|
+
|
|
171
|
+
# Применяем стили таблицы
|
|
172
|
+
self._apply_table_styles(docx_table, computed_style)
|
|
173
|
+
|
|
174
|
+
# Обрабатываем строки с передачей индексов через kwargs
|
|
175
|
+
for row_index, row in enumerate(rows):
|
|
176
|
+
# Передаем row_index в kwargs для обработки строки
|
|
177
|
+
self._process_element(
|
|
178
|
+
row,
|
|
179
|
+
docx_table,
|
|
180
|
+
computed_style,
|
|
181
|
+
row_index=row_index, # передаем индекс строки
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def _process_row(
|
|
185
|
+
self, element, parent_docx_element, computed_style, row_index=None
|
|
186
|
+
):
|
|
187
|
+
"""
|
|
188
|
+
Обрабатывает строку таблицы с известным индексом
|
|
189
|
+
:param element: HTML элемент строки (tr)
|
|
190
|
+
:param parent_docx_element: DOCX таблица
|
|
191
|
+
:param computed_style: вычисленные стили для строки
|
|
192
|
+
:param row_index: индекс строки в таблице
|
|
193
|
+
"""
|
|
194
|
+
# Находим все ячейки в строке
|
|
195
|
+
cells = element.find_all(["td", "th"], recursive=False)
|
|
196
|
+
|
|
197
|
+
# Обрабатываем каждую ячейку с передачей индексов
|
|
198
|
+
for col_index, cell in enumerate(cells):
|
|
199
|
+
# Передаем индексы строки и столбца в kwargs
|
|
200
|
+
self._process_element(
|
|
201
|
+
cell,
|
|
202
|
+
parent_docx_element,
|
|
203
|
+
computed_style,
|
|
204
|
+
row_index=row_index,
|
|
205
|
+
col_index=col_index,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _process_cell(
|
|
209
|
+
self,
|
|
210
|
+
element,
|
|
211
|
+
parent_docx_element,
|
|
212
|
+
computed_style,
|
|
213
|
+
row_index=None,
|
|
214
|
+
col_index=None,
|
|
215
|
+
):
|
|
216
|
+
"""
|
|
217
|
+
Обрабатывает ячейку таблицы с известными индексами
|
|
218
|
+
:param element: HTML элемент ячейки (td или th)
|
|
219
|
+
:param parent_docx_element: DOCX таблица
|
|
220
|
+
:param computed_style: вычисленные стили для ячейки
|
|
221
|
+
:param row_index: индекс строки
|
|
222
|
+
:param col_index: индекс столбца
|
|
223
|
+
"""
|
|
224
|
+
# if row_index is None or col_index is None:
|
|
225
|
+
# logger.warning(f"Cell indices not provided for {element.name}, skipping")
|
|
226
|
+
# return
|
|
227
|
+
|
|
228
|
+
# Проверяем, что индексы в пределах таблицы
|
|
229
|
+
if row_index >= len(parent_docx_element.rows) or col_index >= len(
|
|
230
|
+
parent_docx_element.rows[row_index].cells
|
|
231
|
+
):
|
|
232
|
+
raise IndexError(
|
|
233
|
+
f"Cell indices out of range: row={row_index}, col={col_index}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Получаем ячейку в docx таблице
|
|
237
|
+
docx_cell = parent_docx_element.cell(row_index, col_index)
|
|
238
|
+
|
|
239
|
+
if list(element.children):
|
|
240
|
+
# Очищаем ячейку от параграфов по умолчанию
|
|
241
|
+
for paragraph in docx_cell.paragraphs:
|
|
242
|
+
p_element = paragraph._element
|
|
243
|
+
p_element.getparent().remove(p_element)
|
|
244
|
+
|
|
245
|
+
# Применяем стили ячейки
|
|
246
|
+
self._apply_cell_styles(docx_cell, computed_style)
|
|
247
|
+
|
|
248
|
+
# Обрабатываем содержимое ячейки (без передачи индексов дальше)
|
|
249
|
+
self._process_children(element, docx_cell, computed_style)
|
|
250
|
+
|
|
251
|
+
def _process_list(self, element, parent_docx_element, computed_style):
|
|
252
|
+
"""Обрабатывает список"""
|
|
253
|
+
items = element.find_all("li", recursive=False)
|
|
254
|
+
|
|
255
|
+
for i, item in enumerate(items):
|
|
256
|
+
# Создаем параграф для элемента списка
|
|
257
|
+
if isinstance(parent_docx_element, (_Cell, Document)):
|
|
258
|
+
paragraph = parent_docx_element.add_paragraph()
|
|
259
|
+
else:
|
|
260
|
+
paragraph = parent_docx_element
|
|
261
|
+
|
|
262
|
+
if element.name == "ul":
|
|
263
|
+
p_style = "List Bullet"
|
|
264
|
+
else:
|
|
265
|
+
p_style = "List Number"
|
|
266
|
+
paragraph.style = p_style
|
|
267
|
+
# run = paragraph.add_run(element.text.strip())
|
|
268
|
+
# Добавляем маркер или номер
|
|
269
|
+
# if element.name == "ol":
|
|
270
|
+
# prefix = f"{i + 1}. "
|
|
271
|
+
# else:
|
|
272
|
+
# prefix = "• "
|
|
273
|
+
|
|
274
|
+
# Обрабатываем содержимое элемента списка
|
|
275
|
+
self._process_children(item, paragraph, computed_style)
|
|
276
|
+
|
|
277
|
+
def _process_children(self, element, parent_docx_element, computed_style):
|
|
278
|
+
"""Обрабатывает дочерние элементы"""
|
|
279
|
+
for child in element.children:
|
|
280
|
+
self._process_element(child, parent_docx_element, computed_style)
|
|
281
|
+
|
|
282
|
+
def _apply_paragraph_styles(self, paragraph, computed_style):
|
|
283
|
+
"""Применяет стили к параграфу"""
|
|
284
|
+
# Устанавливаем отступ после по умолчанию = 0
|
|
285
|
+
paragraph.paragraph_format.space_after = Pt(0)
|
|
286
|
+
|
|
287
|
+
# Выравнивание
|
|
288
|
+
paragraph.alignment = computed_style.get_text_align()
|
|
289
|
+
|
|
290
|
+
# Отступы (margin)
|
|
291
|
+
margin_top = computed_style.get("margin-top")
|
|
292
|
+
if margin_top:
|
|
293
|
+
num, unit = computed_style.get_numeric_value("margin-top", default_value=0)
|
|
294
|
+
if unit == "pt":
|
|
295
|
+
paragraph.paragraph_format.space_before = Pt(num)
|
|
296
|
+
elif unit == "px":
|
|
297
|
+
paragraph.paragraph_format.space_before = Pt(num * 0.75)
|
|
298
|
+
|
|
299
|
+
margin_bottom = computed_style.get("margin-bottom")
|
|
300
|
+
if margin_bottom:
|
|
301
|
+
num, unit = computed_style.get_numeric_value(
|
|
302
|
+
"margin-bottom", default_value=0
|
|
303
|
+
)
|
|
304
|
+
if unit == "pt":
|
|
305
|
+
paragraph.paragraph_format.space_after = Pt(num)
|
|
306
|
+
elif unit == "px":
|
|
307
|
+
paragraph.paragraph_format.space_after = Pt(num * 0.75)
|
|
308
|
+
|
|
309
|
+
margin_left = computed_style.get("margin-left")
|
|
310
|
+
if margin_left:
|
|
311
|
+
num, unit = computed_style.get_numeric_value("margin-left", default_value=0)
|
|
312
|
+
if unit == "pt":
|
|
313
|
+
paragraph.paragraph_format.left_indent = Pt(num)
|
|
314
|
+
elif unit == "px":
|
|
315
|
+
paragraph.paragraph_format.left_indent = Pt(num * 0.75)
|
|
316
|
+
|
|
317
|
+
margin_right = computed_style.get("margin-right")
|
|
318
|
+
if margin_right:
|
|
319
|
+
num, unit = computed_style.get_numeric_value(
|
|
320
|
+
"margin-right", default_value=0
|
|
321
|
+
)
|
|
322
|
+
if unit == "pt":
|
|
323
|
+
paragraph.paragraph_format.right_indent = Pt(num)
|
|
324
|
+
elif unit == "px":
|
|
325
|
+
paragraph.paragraph_format.right_indent = Pt(num * 0.75)
|
|
326
|
+
|
|
327
|
+
# Отступ первой строки
|
|
328
|
+
text_indent = computed_style.get("text-indent")
|
|
329
|
+
if text_indent:
|
|
330
|
+
num, unit = computed_style.get_numeric_value("text-indent", default_value=0)
|
|
331
|
+
if unit == "pt":
|
|
332
|
+
paragraph.paragraph_format.first_line_indent = Pt(num)
|
|
333
|
+
elif unit == "cm":
|
|
334
|
+
paragraph.paragraph_format.first_line_indent = Cm(num)
|
|
335
|
+
elif unit == "px":
|
|
336
|
+
paragraph.paragraph_format.first_line_indent = Pt(num * 0.75)
|
|
337
|
+
|
|
338
|
+
def _apply_run_styles(self, run, computed_style, parent_computed_style=None):
|
|
339
|
+
"""Применяет стили к run (тексту) с учетом наследования"""
|
|
340
|
+
# Шрифт
|
|
341
|
+
font_family = computed_style.get_font_family()
|
|
342
|
+
run.font.name = (
|
|
343
|
+
font_family.split(",")[0].strip().strip("'\"")
|
|
344
|
+
if font_family
|
|
345
|
+
else "Times New Roman"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# Размер шрифта - передаем родительский стиль для корректного вычисления процентов и em
|
|
349
|
+
run.font.size = computed_style.get_font_size(parent_computed_style)
|
|
350
|
+
|
|
351
|
+
# Начертание
|
|
352
|
+
run.bold = computed_style.is_bold()
|
|
353
|
+
run.italic = computed_style.is_italic()
|
|
354
|
+
run.underline = computed_style.is_underlined()
|
|
355
|
+
|
|
356
|
+
# Цвет текста
|
|
357
|
+
color = computed_style.get_color()
|
|
358
|
+
if color:
|
|
359
|
+
try:
|
|
360
|
+
run.font.color.rgb = self._parse_color(color)
|
|
361
|
+
except:
|
|
362
|
+
pass # Игнорируем ошибки парсинга цвета
|
|
363
|
+
|
|
364
|
+
def _apply_table_styles(self, table, computed_style):
|
|
365
|
+
"""Применяет стили к таблице"""
|
|
366
|
+
# Границы
|
|
367
|
+
if computed_style.get("border"):
|
|
368
|
+
table.style = "Table Grid"
|
|
369
|
+
|
|
370
|
+
# # Ширина таблицы
|
|
371
|
+
# width = computed_style.get('width')
|
|
372
|
+
# if width:
|
|
373
|
+
# if '%' in width:
|
|
374
|
+
# # Процентная ширина
|
|
375
|
+
# num, _ = computed_style.get_numeric_value('width',
|
|
376
|
+
# default_value=100)
|
|
377
|
+
# self._set_table_width(table, width_type='pct',
|
|
378
|
+
# value=int(num * 50)) # 100% = 5000
|
|
379
|
+
# else:
|
|
380
|
+
# # Абсолютная ширина
|
|
381
|
+
# num, unit = computed_style.get_numeric_value('width',
|
|
382
|
+
# default_value=100)
|
|
383
|
+
# if unit == 'px':
|
|
384
|
+
# # Конвертируем px в twips (1px = 15 twips примерно)
|
|
385
|
+
# self._set_table_width(table, width_type='dxa',
|
|
386
|
+
# value=int(num * 15))
|
|
387
|
+
# elif unit == 'pt':
|
|
388
|
+
# self._set_table_width(table, width_type='dxa',
|
|
389
|
+
# value=int(
|
|
390
|
+
# num * 20)) # 1pt = 20 twips
|
|
391
|
+
|
|
392
|
+
# Ширина таблицы
|
|
393
|
+
self._set_table_width(table, width_type="pct", value=5000)
|
|
394
|
+
table.autofit = False
|
|
395
|
+
|
|
396
|
+
def _apply_cell_styles(self, cell, computed_style):
|
|
397
|
+
"""Применяет стили к ячейке"""
|
|
398
|
+
# Вертикальное выравнивание
|
|
399
|
+
valign = computed_style.get_vertical_align()
|
|
400
|
+
self._set_cell_vertical_alignment(cell, valign)
|
|
401
|
+
|
|
402
|
+
# Цвет фона
|
|
403
|
+
bgcolor = computed_style.get_background_color()
|
|
404
|
+
if bgcolor:
|
|
405
|
+
self._set_cell_background_color(cell, bgcolor)
|
|
406
|
+
|
|
407
|
+
# # Высота ячейки
|
|
408
|
+
# height = computed_style.get('height')
|
|
409
|
+
# if height:
|
|
410
|
+
# num, unit = computed_style.get_numeric_value('height', default_value=0)
|
|
411
|
+
# if unit == 'px':
|
|
412
|
+
# cell.height = Inches(num / 96) # Приблизительно
|
|
413
|
+
# elif unit == 'pt':
|
|
414
|
+
# cell.height = Pt(num)
|
|
415
|
+
|
|
416
|
+
def _set_table_width(self, table, width_type="pct", value=5000):
|
|
417
|
+
"""Устанавливает ширину таблицы"""
|
|
418
|
+
tbl = table._tbl
|
|
419
|
+
tblPr = tbl.tblPr
|
|
420
|
+
|
|
421
|
+
tblW = OxmlElement("w:tblW")
|
|
422
|
+
tblW.set(qn("w:w"), str(value))
|
|
423
|
+
tblW.set(qn("w:type"), width_type)
|
|
424
|
+
|
|
425
|
+
# Удаляем существующий элемент ширины, если есть
|
|
426
|
+
for elem in tblPr:
|
|
427
|
+
if elem.tag == qn("w:tblW"):
|
|
428
|
+
tblPr.remove(elem)
|
|
429
|
+
|
|
430
|
+
tblPr.append(tblW)
|
|
431
|
+
|
|
432
|
+
def _set_cell_vertical_alignment(self, cell, alignment):
|
|
433
|
+
"""Устанавливает вертикальное выравнивание для ячейки"""
|
|
434
|
+
tc = cell._tc
|
|
435
|
+
tcPr = tc.get_or_add_tcPr()
|
|
436
|
+
|
|
437
|
+
vAlign = OxmlElement("w:vAlign")
|
|
438
|
+
align_map = {"top": "top", "middle": "center", "bottom": "bottom"}
|
|
439
|
+
vAlign.set(qn("w:val"), align_map.get(alignment, "top"))
|
|
440
|
+
|
|
441
|
+
# Удаляем существующий элемент, если есть
|
|
442
|
+
for elem in tcPr:
|
|
443
|
+
if elem.tag == qn("w:vAlign"):
|
|
444
|
+
tcPr.remove(elem)
|
|
445
|
+
|
|
446
|
+
tcPr.append(vAlign)
|
|
447
|
+
|
|
448
|
+
def _set_cell_background_color(self, cell, color):
|
|
449
|
+
"""Устанавливает цвет фона ячейки"""
|
|
450
|
+
tc = cell._tc
|
|
451
|
+
tcPr = tc.get_or_add_tcPr()
|
|
452
|
+
|
|
453
|
+
shd = OxmlElement("w:shd")
|
|
454
|
+
shd.set(qn("w:val"), "clear")
|
|
455
|
+
shd.set(qn("w:color"), "auto")
|
|
456
|
+
shd.set(qn("w:fill"), self._parse_color(color))
|
|
457
|
+
|
|
458
|
+
# Удаляем существующий элемент, если есть
|
|
459
|
+
for elem in tcPr:
|
|
460
|
+
if elem.tag == qn("w:shd"):
|
|
461
|
+
tcPr.remove(elem)
|
|
462
|
+
|
|
463
|
+
tcPr.append(shd)
|
|
464
|
+
|
|
465
|
+
def _parse_color(self, color_string):
|
|
466
|
+
"""Парсит цвет в hex формат"""
|
|
467
|
+
color_string = str(color_string).lower().strip()
|
|
468
|
+
|
|
469
|
+
# Если это hex цвет
|
|
470
|
+
if color_string.startswith("#"):
|
|
471
|
+
return color_string[1:7]
|
|
472
|
+
|
|
473
|
+
# Если это rgb
|
|
474
|
+
rgb_match = re.search(r"rgb\((\d+),\s*(\d+),\s*(\d+)\)", color_string)
|
|
475
|
+
if rgb_match:
|
|
476
|
+
r, g, b = map(int, rgb_match.groups())
|
|
477
|
+
return f"{r:02x}{g:02x}{b:02x}"
|
|
478
|
+
|
|
479
|
+
# Словарь базовых цветов
|
|
480
|
+
color_map = {
|
|
481
|
+
"red": "FF0000",
|
|
482
|
+
"green": "00FF00",
|
|
483
|
+
"blue": "0000FF",
|
|
484
|
+
"black": "000000",
|
|
485
|
+
"white": "FFFFFF",
|
|
486
|
+
"gray": "808080",
|
|
487
|
+
"yellow": "FFFF00",
|
|
488
|
+
"cyan": "00FFFF",
|
|
489
|
+
"magenta": "FF00FF",
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return color_map.get(color_string, "auto")
|
|
493
|
+
|
|
494
|
+
def _process_element(
|
|
495
|
+
self, element, parent_docx_element, parent_computed_style=None, **kwargs
|
|
496
|
+
):
|
|
497
|
+
"""
|
|
498
|
+
Основной метод обработки элементов с вычислением стилей
|
|
499
|
+
Теперь parent_computed_style содержит стили родителя с учетом всей цепочки наследования
|
|
500
|
+
"""
|
|
501
|
+
element_name = element.name
|
|
502
|
+
if isinstance(element, Comment):
|
|
503
|
+
return
|
|
504
|
+
if element.name is None:
|
|
505
|
+
# Обрабатываем текст
|
|
506
|
+
self._process_text(element, parent_docx_element, parent_computed_style)
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
# Вычисляем стили для текущего элемента с учетом родительских
|
|
510
|
+
computed_style = self.style_manager(element, parent_computed_style)
|
|
511
|
+
|
|
512
|
+
# Обрабатываем в зависимости от типа элемента
|
|
513
|
+
if element.name == "table":
|
|
514
|
+
self._process_table(element, parent_docx_element, computed_style)
|
|
515
|
+
elif element.name == "tbody":
|
|
516
|
+
# Для tbody просто обрабатываем детей, передавая стили от таблицы
|
|
517
|
+
self._process_children(element, parent_docx_element, parent_computed_style)
|
|
518
|
+
elif element.name == "tr":
|
|
519
|
+
# Для строк таблицы получаем индекс из kwargs
|
|
520
|
+
row_index = kwargs.get("row_index")
|
|
521
|
+
self._process_row(element, parent_docx_element, computed_style, row_index)
|
|
522
|
+
elif element.name in ["td", "th"]:
|
|
523
|
+
# Для ячеек таблицы ожидаем, что индексы переданы через kwargs
|
|
524
|
+
row_index = kwargs.get("row_index")
|
|
525
|
+
col_index = kwargs.get("col_index")
|
|
526
|
+
self._process_cell(
|
|
527
|
+
element, parent_docx_element, computed_style, row_index, col_index
|
|
528
|
+
)
|
|
529
|
+
elif element.name in ["p", "div"]:
|
|
530
|
+
self._process_paragraph(element, parent_docx_element, computed_style)
|
|
531
|
+
elif element.name == "br":
|
|
532
|
+
self._process_line_break(parent_docx_element, computed_style)
|
|
533
|
+
elif element.name in ["ol", "ul"]:
|
|
534
|
+
self._process_list(element, parent_docx_element, computed_style)
|
|
535
|
+
elif element.name in ["span", "strong", "em", "b", "i", "u"]:
|
|
536
|
+
self._process_inline_container(element, parent_docx_element, computed_style)
|
|
537
|
+
else:
|
|
538
|
+
# Для остальных элементов просто обрабатываем детей
|
|
539
|
+
self._process_children(element, parent_docx_element, computed_style)
|
|
540
|
+
|
|
541
|
+
def _process_text(self, text_node, parent_docx_element, computed_style):
|
|
542
|
+
"""Обрабатывает текстовый узел"""
|
|
543
|
+
text = text_node.string
|
|
544
|
+
if not text or not text.strip():
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
# Находим или создаем параграф для текста
|
|
548
|
+
if isinstance(parent_docx_element, _Cell):
|
|
549
|
+
# Для ячеек таблицы
|
|
550
|
+
if not parent_docx_element.paragraphs:
|
|
551
|
+
paragraph = parent_docx_element.add_paragraph()
|
|
552
|
+
else:
|
|
553
|
+
paragraph = parent_docx_element.paragraphs[-1]
|
|
554
|
+
elif isinstance(parent_docx_element, Document):
|
|
555
|
+
# Для корневого элемента
|
|
556
|
+
paragraph = parent_docx_element.add_paragraph()
|
|
557
|
+
else:
|
|
558
|
+
# Для других случаев
|
|
559
|
+
paragraph = parent_docx_element
|
|
560
|
+
|
|
561
|
+
# Применяем стили к параграфу
|
|
562
|
+
self._apply_paragraph_styles(paragraph, computed_style)
|
|
563
|
+
|
|
564
|
+
# Добавляем текст и применяем стили текста
|
|
565
|
+
run = paragraph.add_run(text.strip())
|
|
566
|
+
self._apply_run_styles(
|
|
567
|
+
run, computed_style, parent_computed_style=computed_style
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
if __name__ == "__main__":
|
|
572
|
+
html_content = """<p>Hello, world!</p>"""
|
|
573
|
+
super_converter = SuperTiny2Docx(html_content)
|
|
574
|
+
docx_file = super_converter.convert()
|
|
575
|
+
|
|
576
|
+
with open("../../super_document.docx", "wb") as f:
|
|
577
|
+
f.write(docx_file.read())
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
|
4
|
+
from docx.shared import Pt
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ComputedStyle:
|
|
9
|
+
"""Класс для хранения вычисленных стилей элемента с учетом всей цепочки наследования"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, element, parent_computed_style=None):
|
|
12
|
+
self.element = element
|
|
13
|
+
self.styles = {}
|
|
14
|
+
|
|
15
|
+
# Начинаем с наследуемых стилей от родителя (которые уже включают всю цепочку)
|
|
16
|
+
if parent_computed_style:
|
|
17
|
+
# Копируем только наследуемые свойства от родителя
|
|
18
|
+
self._inherit_from_parent(parent_computed_style)
|
|
19
|
+
|
|
20
|
+
# Применяем собственные стили элемента (переопределяют родительские)
|
|
21
|
+
self._parse_inline_styles()
|
|
22
|
+
# Парсим атрибуты HTML в стили
|
|
23
|
+
self._parsse_attribute_styles()
|
|
24
|
+
|
|
25
|
+
# Применяем стили от тега (например, h1, h2 и т.д.)
|
|
26
|
+
self._apply_tag_defaults()
|
|
27
|
+
|
|
28
|
+
def _inherit_from_parent(self, parent_style):
|
|
29
|
+
"""Наследует соответствующие свойства от родителя"""
|
|
30
|
+
# Список свойств, которые наследуются в CSS
|
|
31
|
+
inheritable_properties = [
|
|
32
|
+
"font-size",
|
|
33
|
+
"font-family",
|
|
34
|
+
"font-weight",
|
|
35
|
+
"font-style",
|
|
36
|
+
"color",
|
|
37
|
+
"text-align",
|
|
38
|
+
"text-indent",
|
|
39
|
+
"text-decoration",
|
|
40
|
+
"line-height",
|
|
41
|
+
"letter-spacing",
|
|
42
|
+
"word-spacing",
|
|
43
|
+
"visibility",
|
|
44
|
+
"white-space",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
for prop in inheritable_properties:
|
|
48
|
+
if prop in parent_style.styles:
|
|
49
|
+
self.styles[prop] = parent_style.styles[prop]
|
|
50
|
+
|
|
51
|
+
def _parse_inline_styles(self):
|
|
52
|
+
"""Парсит inline-стили элемента"""
|
|
53
|
+
style_attr = self.element.get("style", "")
|
|
54
|
+
|
|
55
|
+
if not style_attr:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
for item in style_attr.split(";"):
|
|
59
|
+
item = item.strip()
|
|
60
|
+
if not item:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
if ":" in item:
|
|
64
|
+
name, value = [part.strip() for part in item.split(":", 1)]
|
|
65
|
+
self.styles[name] = value
|
|
66
|
+
|
|
67
|
+
# Логируем для отладки
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parsse_attribute_styles(self):
|
|
71
|
+
for attr, value in self.element.attrs.items():
|
|
72
|
+
if attr == "border":
|
|
73
|
+
self.styles["border"] = value
|
|
74
|
+
elif attr == "cellpadding":
|
|
75
|
+
self.styles["padding"] = f"{value}px"
|
|
76
|
+
elif attr == "cellspacing":
|
|
77
|
+
self.styles["border-spacing"] = f"{value}px"
|
|
78
|
+
elif attr == "bgcolor":
|
|
79
|
+
self.styles["background-color"] = value
|
|
80
|
+
elif attr == "width":
|
|
81
|
+
if str(value).endswith("%"):
|
|
82
|
+
self.styles["width"] = value
|
|
83
|
+
else:
|
|
84
|
+
self.styles["width"] = f"{value}px"
|
|
85
|
+
elif attr == "height":
|
|
86
|
+
if str(value).endswith("%"):
|
|
87
|
+
self.styles["height"] = value
|
|
88
|
+
else:
|
|
89
|
+
self.styles["height"] = f"{value}px"
|
|
90
|
+
elif attr == "align":
|
|
91
|
+
self.styles["align"] = value
|
|
92
|
+
elif attr == "valign":
|
|
93
|
+
self.styles["vertical-align"] = value
|
|
94
|
+
|
|
95
|
+
def _apply_tag_defaults(self):
|
|
96
|
+
"""Применяет стили по умолчанию для тегов"""
|
|
97
|
+
tag_defaults = {
|
|
98
|
+
"h1": {"font-size": "24pt", "font-weight": "bold", "margin-bottom": "12pt"},
|
|
99
|
+
"h2": {"font-size": "18pt", "font-weight": "bold", "margin-bottom": "10pt"},
|
|
100
|
+
"h3": {"font-size": "16pt", "font-weight": "bold", "margin-bottom": "8pt"},
|
|
101
|
+
"h4": {"font-size": "14pt", "font-weight": "bold", "margin-bottom": "6pt"},
|
|
102
|
+
"h5": {"font-size": "12pt", "font-weight": "bold", "margin-bottom": "4pt"},
|
|
103
|
+
"h6": {"font-size": "10pt", "font-weight": "bold", "margin-bottom": "2pt"},
|
|
104
|
+
"strong": {"font-weight": "bold"},
|
|
105
|
+
"b": {"font-weight": "bold"},
|
|
106
|
+
"em": {"font-style": "italic"},
|
|
107
|
+
"i": {"font-style": "italic"},
|
|
108
|
+
"u": {"text-decoration": "underline"},
|
|
109
|
+
"p": {"margin-bottom": "10pt"},
|
|
110
|
+
"td": {"vertical-align": "top"},
|
|
111
|
+
"th": {"vertical-align": "middle", "font-weight": "bold"},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
my_name = self.element.name
|
|
115
|
+
if self.element.name in tag_defaults:
|
|
116
|
+
for prop, value in tag_defaults[self.element.name].items():
|
|
117
|
+
if prop not in self.styles: # Не переопределяем уже заданные стили
|
|
118
|
+
self.styles[prop] = value
|
|
119
|
+
|
|
120
|
+
def get(self, property_name, default=None):
|
|
121
|
+
"""Получает значение свойства стиля"""
|
|
122
|
+
return self.styles.get(property_name, default)
|
|
123
|
+
|
|
124
|
+
def get_numeric_value(self, property_name, default_value=None, default_unit="pt"):
|
|
125
|
+
"""Получает числовое значение свойства с единицами измерения"""
|
|
126
|
+
value = self.get(property_name)
|
|
127
|
+
if not value:
|
|
128
|
+
return default_value, default_unit
|
|
129
|
+
|
|
130
|
+
# Извлекаем число и единицы измерения
|
|
131
|
+
match = re.search(r"([\d.]+)\s*([a-z%]*)", value)
|
|
132
|
+
if not match:
|
|
133
|
+
return default_value, default_unit
|
|
134
|
+
|
|
135
|
+
num = float(match.group(1))
|
|
136
|
+
unit = match.group(2) or default_unit
|
|
137
|
+
|
|
138
|
+
return num, unit
|
|
139
|
+
|
|
140
|
+
def get_font_size(self, parent_style=None):
|
|
141
|
+
"""Получает размер шрифта в пунктах с учетом наследования"""
|
|
142
|
+
value = self.get("font-size")
|
|
143
|
+
|
|
144
|
+
# Если нет своего размера, используем родительский (уже должен быть в стилях)
|
|
145
|
+
if not value:
|
|
146
|
+
# Пробуем найти в унаследованных стилях
|
|
147
|
+
if "font-size" in self.styles:
|
|
148
|
+
value = self.styles["font-size"]
|
|
149
|
+
else:
|
|
150
|
+
return Pt(11) # Размер по умолчанию
|
|
151
|
+
|
|
152
|
+
match = re.search(r"([\d.]+)\s*([a-z%]*)", value)
|
|
153
|
+
if not match:
|
|
154
|
+
return Pt(11)
|
|
155
|
+
|
|
156
|
+
num = float(match.group(1))
|
|
157
|
+
unit = match.group(2)
|
|
158
|
+
|
|
159
|
+
if unit == "px":
|
|
160
|
+
return Pt(num * 0.75) # Примерное преобразование
|
|
161
|
+
elif unit == "pt":
|
|
162
|
+
return Pt(num)
|
|
163
|
+
elif unit == "%":
|
|
164
|
+
# Проценты от родительского размера
|
|
165
|
+
# Нужно получить родительский размер
|
|
166
|
+
if parent_style and "font-size" in parent_style.styles:
|
|
167
|
+
parent_value = parent_style.styles["font-size"]
|
|
168
|
+
parent_match = re.search(r"([\d.]+)\s*([a-z%]*)", parent_value)
|
|
169
|
+
if parent_match:
|
|
170
|
+
parent_num = float(parent_match.group(1))
|
|
171
|
+
parent_unit = parent_match.group(2)
|
|
172
|
+
|
|
173
|
+
if parent_unit == "pt":
|
|
174
|
+
return Pt(parent_num * num / 100)
|
|
175
|
+
elif parent_unit == "px":
|
|
176
|
+
return Pt(parent_num * 0.75 * num / 100)
|
|
177
|
+
|
|
178
|
+
return Pt(11 * num / 100)
|
|
179
|
+
elif unit == "em":
|
|
180
|
+
# em относительно родительского размера
|
|
181
|
+
if parent_style and "font-size" in parent_style.styles:
|
|
182
|
+
parent_value = parent_style.styles["font-size"]
|
|
183
|
+
parent_match = re.search(r"([\d.]+)\s*([a-z%]*)", parent_value)
|
|
184
|
+
if parent_match:
|
|
185
|
+
parent_num = float(parent_match.group(1))
|
|
186
|
+
parent_unit = parent_match.group(2)
|
|
187
|
+
|
|
188
|
+
if parent_unit == "pt":
|
|
189
|
+
return Pt(parent_num * num)
|
|
190
|
+
elif parent_unit == "px":
|
|
191
|
+
return Pt(parent_num * 0.75 * num)
|
|
192
|
+
|
|
193
|
+
return Pt(11 * num)
|
|
194
|
+
else:
|
|
195
|
+
return Pt(num)
|
|
196
|
+
|
|
197
|
+
def get_text_align(self):
|
|
198
|
+
"""Получает выравнивание текста"""
|
|
199
|
+
align_map = {
|
|
200
|
+
"left": WD_PARAGRAPH_ALIGNMENT.LEFT,
|
|
201
|
+
"center": WD_PARAGRAPH_ALIGNMENT.CENTER,
|
|
202
|
+
"right": WD_PARAGRAPH_ALIGNMENT.RIGHT,
|
|
203
|
+
"justify": WD_PARAGRAPH_ALIGNMENT.JUSTIFY,
|
|
204
|
+
}
|
|
205
|
+
value = self.get("text-align")
|
|
206
|
+
return align_map.get(value, WD_PARAGRAPH_ALIGNMENT.LEFT)
|
|
207
|
+
|
|
208
|
+
def get_vertical_align(self):
|
|
209
|
+
"""Получает вертикальное выравнивание"""
|
|
210
|
+
value = self.get("vertical-align")
|
|
211
|
+
return value if value else "top"
|
|
212
|
+
|
|
213
|
+
def get_background_color(self):
|
|
214
|
+
"""Получает цвет фона"""
|
|
215
|
+
return self.get("background-color")
|
|
216
|
+
|
|
217
|
+
def get_color(self):
|
|
218
|
+
"""Получает цвет текста"""
|
|
219
|
+
return self.get("color")
|
|
220
|
+
|
|
221
|
+
def get_font_family(self):
|
|
222
|
+
"""Получает семейство шрифта"""
|
|
223
|
+
return self.get("font-family", "Times New Roman")
|
|
224
|
+
|
|
225
|
+
def get_border(self):
|
|
226
|
+
"""Получает границы ячейки"""
|
|
227
|
+
border = self.get("border", "0")
|
|
228
|
+
if "px" in border:
|
|
229
|
+
border = border.replace("px", "")
|
|
230
|
+
return int(border)
|
|
231
|
+
|
|
232
|
+
def is_bold(self):
|
|
233
|
+
"""Проверяет, должен ли текст быть жирным"""
|
|
234
|
+
weight = self.get("font-weight")
|
|
235
|
+
return weight in ("bold", "bolder", "700", "800", "900") or (
|
|
236
|
+
weight and weight.isdigit() and int(weight) >= 700
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def is_italic(self):
|
|
240
|
+
"""Проверяет, должен ли текст быть курсивным"""
|
|
241
|
+
style = self.get("font-style")
|
|
242
|
+
return style == "italic"
|
|
243
|
+
|
|
244
|
+
def is_underlined(self):
|
|
245
|
+
"""Проверяет, должен ли текст быть подчеркнутым"""
|
|
246
|
+
decor = self.get("text-decoration")
|
|
247
|
+
return decor == "underline"
|