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.
@@ -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,3 @@
1
+ from .converter import SuperTiny2Docx
2
+
3
+ __all__ = ['SuperTiny2Docx']
@@ -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"