licos-dev-sdk 0.3.0__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/PKG-INFO +1 -1
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/pyproject.toml +1 -1
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/document.py +36 -34
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/document_docx.py +263 -66
- licos_dev_sdk-0.3.2/src/licos_dev_sdk/document_docx_layout.py +141 -0
- licos_dev_sdk-0.3.2/tests/test_document_spreadsheet.py +149 -0
- licos_dev_sdk-0.3.0/tests/test_document_spreadsheet.py +0 -881
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/.gitignore +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/__init__.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/_utils.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/archive.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/chart.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/data.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/diagram.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/document_common.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/image.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/model.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/observability.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/pdf_inspect.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/presentation.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/spreadsheet.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/spreadsheet_inspect.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/web.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/tests/test_model.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/tests/test_observability.py +0 -0
- {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/tests/test_output_paths.py +0 -0
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
"""Document generation — PDF, DOCX."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from
|
|
6
|
-
|
|
1
|
+
"""Document generation — PDF, DOCX."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
7
8
|
from ._utils import resolve_output_path
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
|
|
10
11
|
def create_pdf(content: str, filename: str, *, content_type: str = "markdown",
|
|
11
12
|
output_dir: str | None = None, page_size: str = "A4",
|
|
12
13
|
theme: str = "formal-blue", title: str | None = None,
|
|
13
14
|
margin: str = "20mm") -> str:
|
|
14
|
-
"""Generate a PDF file from Markdown or HTML. Returns absolute path."""
|
|
15
|
+
"""Generate a PDF file from Markdown or HTML. Returns absolute path."""
|
|
15
16
|
from weasyprint import HTML
|
|
16
17
|
from .document_common import build_pdf_html
|
|
17
18
|
|
|
@@ -25,14 +26,15 @@ def create_pdf(content: str, filename: str, *, content_type: str = "markdown",
|
|
|
25
26
|
title=title,
|
|
26
27
|
)
|
|
27
28
|
HTML(string=full_html, base_url=str(path.parent)).write_pdf(str(path))
|
|
28
|
-
return str(path.resolve())
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
return str(path.resolve())
|
|
30
|
+
|
|
31
|
+
|
|
31
32
|
def create_docx(content: str, filename: str, *, content_type: str = "markdown",
|
|
32
33
|
output_dir: str | None = None, font_name: str = "Microsoft YaHei",
|
|
33
34
|
font_size: int = 11, theme: str = "formal-blue",
|
|
34
35
|
title: str | None = None, header: str | None = None,
|
|
35
|
-
footer: str | None = None, include_toc: bool = False
|
|
36
|
+
footer: str | None = None, include_toc: bool = False,
|
|
37
|
+
asset_base_path: str | None = None) -> str:
|
|
36
38
|
"""Generate a styled DOCX from Markdown or HTML. Returns absolute path."""
|
|
37
39
|
from .document_docx import render_docx
|
|
38
40
|
|
|
@@ -48,25 +50,25 @@ def create_docx(content: str, filename: str, *, content_type: str = "markdown",
|
|
|
48
50
|
header=header,
|
|
49
51
|
footer=footer,
|
|
50
52
|
include_toc=include_toc,
|
|
51
|
-
base_path=path.parent,
|
|
53
|
+
base_path=Path(asset_base_path) if asset_base_path else path.parent,
|
|
52
54
|
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def create_docx_from_template(template_path: str, data: dict[str, Any], filename: str, *,
|
|
56
|
-
output_dir: str | None = None) -> str:
|
|
57
|
-
"""Render a DOCX template with Jinja-style placeholders.
|
|
58
|
-
|
|
59
|
-
The template must be a `.docx` file. Placeholders use docxtpl syntax, for example
|
|
60
|
-
`{{ customer_name }}` or `{% for item in items %}...{% endfor %}`.
|
|
61
|
-
Returns the absolute path of the generated document.
|
|
62
|
-
"""
|
|
63
|
-
if not isinstance(data, dict):
|
|
64
|
-
raise TypeError("data must be a dict")
|
|
65
|
-
|
|
66
|
-
from docxtpl import DocxTemplate
|
|
67
|
-
|
|
68
|
-
path = resolve_output_path(filename, ".docx", output_dir)
|
|
69
|
-
doc = DocxTemplate(template_path)
|
|
70
|
-
doc.render(data)
|
|
71
|
-
doc.save(str(path))
|
|
72
|
-
return str(path.resolve())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def create_docx_from_template(template_path: str, data: dict[str, Any], filename: str, *,
|
|
58
|
+
output_dir: str | None = None) -> str:
|
|
59
|
+
"""Render a DOCX template with Jinja-style placeholders.
|
|
60
|
+
|
|
61
|
+
The template must be a `.docx` file. Placeholders use docxtpl syntax, for example
|
|
62
|
+
`{{ customer_name }}` or `{% for item in items %}...{% endfor %}`.
|
|
63
|
+
Returns the absolute path of the generated document.
|
|
64
|
+
"""
|
|
65
|
+
if not isinstance(data, dict):
|
|
66
|
+
raise TypeError("data must be a dict")
|
|
67
|
+
|
|
68
|
+
from docxtpl import DocxTemplate
|
|
69
|
+
|
|
70
|
+
path = resolve_output_path(filename, ".docx", output_dir)
|
|
71
|
+
doc = DocxTemplate(template_path)
|
|
72
|
+
doc.render(data)
|
|
73
|
+
doc.save(str(path))
|
|
74
|
+
return str(path.resolve())
|
|
@@ -17,11 +17,23 @@ from docx.oxml.ns import qn
|
|
|
17
17
|
from docx.shared import Cm, Mm, Pt, RGBColor
|
|
18
18
|
|
|
19
19
|
from .document_common import markdown_to_html, resolve_document_theme
|
|
20
|
+
from .document_docx_layout import (
|
|
21
|
+
box_side as _box_side,
|
|
22
|
+
cell_vertical_alignment as _cell_vertical_alignment,
|
|
23
|
+
css_font_size as _css_font_size,
|
|
24
|
+
css_length_to_cm as _css_length_to_cm,
|
|
25
|
+
css_length_to_points as _css_length_to_points,
|
|
26
|
+
css_length_to_twips as _css_length_to_twips,
|
|
27
|
+
first_font as _first_font,
|
|
28
|
+
table_alignment as _table_alignment,
|
|
29
|
+
table_cell_placements as _table_cell_placements,
|
|
30
|
+
word_safe_fragments as _word_safe_fragments,
|
|
31
|
+
)
|
|
20
32
|
|
|
21
33
|
|
|
22
34
|
_INHERITED_CSS = {
|
|
23
35
|
"color", "font-family", "font-size", "font-style", "font-weight",
|
|
24
|
-
"text-align", "text-decoration", "line-height",
|
|
36
|
+
"letter-spacing", "text-align", "text-decoration", "line-height",
|
|
25
37
|
}
|
|
26
38
|
_COLOR_NAMES = {
|
|
27
39
|
"black": "000000", "white": "FFFFFF", "red": "FF0000", "blue": "0000FF",
|
|
@@ -214,10 +226,11 @@ class _DocxRenderer:
|
|
|
214
226
|
self.render_inline(paragraph, tag)
|
|
215
227
|
else:
|
|
216
228
|
self.render_blocks(tag)
|
|
217
|
-
fill =
|
|
229
|
+
fill = _background_color(style)
|
|
218
230
|
if fill:
|
|
219
231
|
for paragraph in self.doc.paragraphs[before:]:
|
|
220
232
|
_set_paragraph_shading(paragraph, fill)
|
|
233
|
+
_apply_container_box(self.doc.paragraphs[before:], style)
|
|
221
234
|
if _has_page_break_after(style):
|
|
222
235
|
_add_page_break(self.doc)
|
|
223
236
|
else:
|
|
@@ -239,22 +252,21 @@ class _DocxRenderer:
|
|
|
239
252
|
elif level == 2:
|
|
240
253
|
_set_paragraph_border(p, self.theme["secondary"], side="left", size="20")
|
|
241
254
|
p.paragraph_format.left_indent = Cm(0.18)
|
|
242
|
-
|
|
255
|
+
self._append_text(p, text, tag)
|
|
243
256
|
if tag is not None:
|
|
244
|
-
self.
|
|
257
|
+
self._apply_paragraph_style(p, tag)
|
|
245
258
|
|
|
246
259
|
def render_inline(self, paragraph, node: Tag | NavigableString) -> None:
|
|
247
260
|
if isinstance(node, Comment):
|
|
248
261
|
return
|
|
249
262
|
if isinstance(node, NavigableString):
|
|
250
|
-
|
|
263
|
+
self._append_text(paragraph, str(node))
|
|
251
264
|
return
|
|
252
265
|
for child in node.children:
|
|
253
266
|
if isinstance(child, Comment):
|
|
254
267
|
continue
|
|
255
268
|
if isinstance(child, NavigableString):
|
|
256
|
-
|
|
257
|
-
self._apply_run_style(run, node)
|
|
269
|
+
self._append_text(paragraph, str(child), node)
|
|
258
270
|
elif not isinstance(child, Tag):
|
|
259
271
|
continue
|
|
260
272
|
elif child.name == "br":
|
|
@@ -276,18 +288,19 @@ class _DocxRenderer:
|
|
|
276
288
|
for li in tag.find_all("li", recursive=False):
|
|
277
289
|
p = self.doc.add_paragraph(style=style_name)
|
|
278
290
|
p.paragraph_format.left_indent = Cm(0.65 + level * 0.5)
|
|
291
|
+
self._apply_paragraph_style(p, li)
|
|
279
292
|
for child in li.children:
|
|
280
293
|
if isinstance(child, Comment):
|
|
281
294
|
continue
|
|
282
295
|
if isinstance(child, Tag) and child.name in {"ul", "ol"}:
|
|
283
296
|
continue
|
|
284
297
|
if isinstance(child, (Tag, NavigableString)):
|
|
285
|
-
self.render_inline(p, child) if isinstance(child, Tag) else
|
|
298
|
+
self.render_inline(p, child) if isinstance(child, Tag) else self._append_text(p, str(child), li)
|
|
286
299
|
for nested in li.find_all(["ul", "ol"], recursive=False):
|
|
287
300
|
self._render_list(nested, ordered=nested.name == "ol", level=level + 1)
|
|
288
301
|
|
|
289
302
|
def _render_table(self, tag: Tag) -> None:
|
|
290
|
-
rows = tag.find_all("tr")
|
|
303
|
+
rows = [row for row in tag.find_all("tr") if row.find_parent("table") is tag]
|
|
291
304
|
if not rows:
|
|
292
305
|
return
|
|
293
306
|
caption = tag.find("caption", recursive=False)
|
|
@@ -297,35 +310,49 @@ class _DocxRenderer:
|
|
|
297
310
|
run = paragraph.add_run(caption.get_text(" ", strip=True))
|
|
298
311
|
run.bold = True
|
|
299
312
|
_set_run_font(run, self.font_name, self.font_size, self.theme["primary"])
|
|
300
|
-
col_count =
|
|
313
|
+
placements, col_count = _table_cell_placements(rows)
|
|
301
314
|
table = self.doc.add_table(rows=len(rows), cols=col_count)
|
|
302
315
|
table.style = "Table Grid"
|
|
303
|
-
|
|
316
|
+
table_style = self._style(tag)
|
|
317
|
+
table.alignment = _table_alignment(table_style.get("text-align"))
|
|
318
|
+
column_widths = _table_column_widths(tag, col_count)
|
|
319
|
+
table.autofit = not any(column_widths)
|
|
320
|
+
for index, width in enumerate(column_widths):
|
|
321
|
+
if width:
|
|
322
|
+
table.columns[index].width = Cm(width)
|
|
323
|
+
|
|
304
324
|
for row_index, source_row in enumerate(rows):
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
325
|
+
_prevent_table_row_split(table.rows[row_index])
|
|
326
|
+
if source_row.find_parent("thead") is not None:
|
|
327
|
+
_repeat_table_header(table.rows[row_index])
|
|
328
|
+
row_style = self._style(source_row)
|
|
329
|
+
for source_cell, target_col, rowspan, colspan in placements[row_index]:
|
|
330
|
+
bottom = min(row_index + rowspan - 1, len(rows) - 1)
|
|
331
|
+
right = min(target_col + colspan - 1, col_count - 1)
|
|
311
332
|
target = table.cell(row_index, target_col)
|
|
312
|
-
if
|
|
313
|
-
target = target.merge(table.cell(
|
|
333
|
+
if bottom > row_index or right > target_col:
|
|
334
|
+
target = target.merge(table.cell(bottom, right))
|
|
314
335
|
target.text = ""
|
|
315
336
|
p = target.paragraphs[0]
|
|
337
|
+
self._apply_paragraph_style(p, source_cell, include_box_model=False)
|
|
316
338
|
self.render_inline(p, source_cell)
|
|
317
339
|
cell_style = self._style(source_cell)
|
|
318
|
-
is_header = source_cell.name == "th" or
|
|
319
|
-
fill =
|
|
340
|
+
is_header = source_cell.name == "th" or source_row.find_parent("thead") is not None
|
|
341
|
+
fill = _background_color(cell_style) or _background_color(row_style)
|
|
320
342
|
if not fill:
|
|
321
343
|
fill = self.theme["primary"] if is_header else (self.theme["table_alt"] if row_index % 2 else "FFFFFF")
|
|
322
344
|
_set_cell_shading(target, fill)
|
|
323
|
-
_set_cell_vertical_alignment(target, "
|
|
345
|
+
_set_cell_vertical_alignment(target, _cell_vertical_alignment(cell_style.get("vertical-align")))
|
|
346
|
+
_set_cell_margins(target, cell_style)
|
|
347
|
+
_set_cell_borders(target, cell_style, self.theme["secondary"])
|
|
348
|
+
width = _css_length_to_cm(cell_style.get("width"))
|
|
349
|
+
if width:
|
|
350
|
+
target.width = Cm(width)
|
|
324
351
|
for run in p.runs:
|
|
325
|
-
|
|
352
|
+
if is_header and not _run_has_explicit_color(run):
|
|
353
|
+
run.font.color.rgb = RGBColor.from_string("FFFFFF")
|
|
326
354
|
if is_header:
|
|
327
355
|
run.bold = True
|
|
328
|
-
target_col += colspan
|
|
329
356
|
self.doc.add_paragraph().paragraph_format.space_after = Pt(0)
|
|
330
357
|
|
|
331
358
|
def _render_landscape_container(self, tag: Tag) -> None:
|
|
@@ -339,7 +366,7 @@ class _DocxRenderer:
|
|
|
339
366
|
|
|
340
367
|
def _render_callout(self, tag: Tag, default_fill: str | None = None) -> None:
|
|
341
368
|
style = self._style(tag)
|
|
342
|
-
fill =
|
|
369
|
+
fill = _background_color(style) or default_fill or self.theme["accent"]
|
|
343
370
|
border = _parse_border_color(style.get("border-left")) or self.theme["secondary"]
|
|
344
371
|
before = len(self.doc.paragraphs)
|
|
345
372
|
block_children = [c for c in tag.children if isinstance(c, Tag) and c.name in _BLOCK_TAGS]
|
|
@@ -370,7 +397,7 @@ class _DocxRenderer:
|
|
|
370
397
|
alt = tag.get("alt") or "图片"
|
|
371
398
|
paragraph.add_run(f"[{alt}]").italic = True
|
|
372
399
|
|
|
373
|
-
def _apply_paragraph_style(self, paragraph, tag: Tag) -> None:
|
|
400
|
+
def _apply_paragraph_style(self, paragraph, tag: Tag, *, include_box_model: bool = True) -> None:
|
|
374
401
|
style = self._style(tag)
|
|
375
402
|
alignment = style.get("text-align")
|
|
376
403
|
if alignment in {"center", "right", "justify", "left"}:
|
|
@@ -380,9 +407,13 @@ class _DocxRenderer:
|
|
|
380
407
|
"justify": WD_ALIGN_PARAGRAPH.JUSTIFY,
|
|
381
408
|
"left": WD_ALIGN_PARAGRAPH.LEFT,
|
|
382
409
|
}[alignment]
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
410
|
+
if include_box_model:
|
|
411
|
+
fill = _background_color(style)
|
|
412
|
+
if fill:
|
|
413
|
+
_set_paragraph_shading(paragraph, fill)
|
|
414
|
+
_apply_paragraph_metrics(paragraph, style, include_box_model=include_box_model)
|
|
415
|
+
if include_box_model:
|
|
416
|
+
_apply_paragraph_borders(paragraph, style)
|
|
386
417
|
|
|
387
418
|
def _apply_run_style(self, run, tag: Tag) -> None:
|
|
388
419
|
style = self._style(tag)
|
|
@@ -399,10 +430,27 @@ class _DocxRenderer:
|
|
|
399
430
|
run.underline = True
|
|
400
431
|
if tag.name in {"s", "del"} or "line-through" in decoration:
|
|
401
432
|
run.font.strike = True
|
|
433
|
+
if tag.name == "mark":
|
|
434
|
+
_set_run_shading(run, _background_color(style) or "FFF2CC")
|
|
435
|
+
if tag.name == "sup":
|
|
436
|
+
run.font.superscript = True
|
|
437
|
+
if tag.name == "sub":
|
|
438
|
+
run.font.subscript = True
|
|
439
|
+
spacing = _css_length_to_twips(style.get("letter-spacing"))
|
|
440
|
+
if spacing is not None:
|
|
441
|
+
_set_run_spacing(run, spacing)
|
|
402
442
|
if tag.name == "code":
|
|
403
443
|
run.font.name = "Cascadia Mono"
|
|
404
444
|
_set_run_shading(run, "F2F2F2")
|
|
405
445
|
|
|
446
|
+
def _append_text(self, paragraph, text: str, tag: Tag | None = None) -> None:
|
|
447
|
+
for value, color in _word_safe_fragments(text):
|
|
448
|
+
run = paragraph.add_run(value)
|
|
449
|
+
if tag is not None:
|
|
450
|
+
self._apply_run_style(run, tag)
|
|
451
|
+
if color is not None:
|
|
452
|
+
run.font.color.rgb = RGBColor.from_string(color)
|
|
453
|
+
|
|
406
454
|
def _style(self, tag: Tag) -> dict[str, str]:
|
|
407
455
|
inherited: dict[str, str] = {}
|
|
408
456
|
if isinstance(tag.parent, Tag):
|
|
@@ -413,7 +461,11 @@ class _DocxRenderer:
|
|
|
413
461
|
def _is_callout(self, tag: Tag) -> bool:
|
|
414
462
|
classes = set(tag.get("class", []))
|
|
415
463
|
style = self._style(tag)
|
|
416
|
-
|
|
464
|
+
callout_classes = {
|
|
465
|
+
"info-box", "info-block", "highlight", "warning", "warning-block",
|
|
466
|
+
"callout", "notice", "executive-summary", "summary-box",
|
|
467
|
+
}
|
|
468
|
+
return bool(classes & callout_classes) or "border-left" in style
|
|
417
469
|
|
|
418
470
|
|
|
419
471
|
_BLOCK_TAGS = {"p", "div", "section", "article", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "table", "blockquote", "pre", "hr"}
|
|
@@ -490,6 +542,112 @@ def _parse_declarations(raw: str) -> dict[str, str]:
|
|
|
490
542
|
return declarations
|
|
491
543
|
|
|
492
544
|
|
|
545
|
+
def _background_color(style: dict[str, str]) -> str | None:
|
|
546
|
+
"""Return a Word-compatible solid fallback for CSS backgrounds."""
|
|
547
|
+
for key in ("background-color", "background", "background-image"):
|
|
548
|
+
color = _parse_color(style.get(key))
|
|
549
|
+
if color:
|
|
550
|
+
return color
|
|
551
|
+
return None
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _apply_paragraph_metrics(paragraph, style: dict[str, str], *, include_box_model: bool = True) -> None:
|
|
555
|
+
if include_box_model:
|
|
556
|
+
before = _css_length_to_points(_box_side(style, "margin", "top"))
|
|
557
|
+
after = _css_length_to_points(_box_side(style, "margin", "bottom"))
|
|
558
|
+
padding_top = _css_length_to_points(_box_side(style, "padding", "top")) or 0
|
|
559
|
+
padding_bottom = _css_length_to_points(_box_side(style, "padding", "bottom")) or 0
|
|
560
|
+
if before is not None or padding_top:
|
|
561
|
+
paragraph.paragraph_format.space_before = Pt((before or 0) + padding_top)
|
|
562
|
+
if after is not None or padding_bottom:
|
|
563
|
+
paragraph.paragraph_format.space_after = Pt((after or 0) + padding_bottom)
|
|
564
|
+
|
|
565
|
+
left = _css_length_to_cm(_box_side(style, "margin", "left")) or 0
|
|
566
|
+
right = _css_length_to_cm(_box_side(style, "margin", "right")) or 0
|
|
567
|
+
left += _css_length_to_cm(_box_side(style, "padding", "left")) or 0
|
|
568
|
+
right += _css_length_to_cm(_box_side(style, "padding", "right")) or 0
|
|
569
|
+
if left:
|
|
570
|
+
paragraph.paragraph_format.left_indent = Cm(left)
|
|
571
|
+
if right:
|
|
572
|
+
paragraph.paragraph_format.right_indent = Cm(right)
|
|
573
|
+
indent = _css_length_to_cm(style.get("text-indent"))
|
|
574
|
+
if indent is not None:
|
|
575
|
+
paragraph.paragraph_format.first_line_indent = Cm(indent)
|
|
576
|
+
|
|
577
|
+
line_height = style.get("line-height", "").strip().lower()
|
|
578
|
+
if line_height:
|
|
579
|
+
match = re.fullmatch(r"([0-9.]+)\s*(%|pt|px)?", line_height)
|
|
580
|
+
if match:
|
|
581
|
+
amount = float(match.group(1))
|
|
582
|
+
unit = match.group(2)
|
|
583
|
+
if unit == "%":
|
|
584
|
+
paragraph.paragraph_format.line_spacing = amount / 100
|
|
585
|
+
elif unit in {"pt", "px"}:
|
|
586
|
+
paragraph.paragraph_format.line_spacing = Pt(amount if unit == "pt" else amount * 0.75)
|
|
587
|
+
else:
|
|
588
|
+
paragraph.paragraph_format.line_spacing = amount
|
|
589
|
+
if style.get("page-break-inside") == "avoid" or style.get("break-inside") == "avoid":
|
|
590
|
+
paragraph.paragraph_format.keep_together = True
|
|
591
|
+
if style.get("page-break-after") == "avoid" or style.get("break-after") == "avoid":
|
|
592
|
+
paragraph.paragraph_format.keep_with_next = True
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _apply_paragraph_borders(paragraph, style: dict[str, str]) -> None:
|
|
596
|
+
for side in ("top", "left", "bottom", "right"):
|
|
597
|
+
raw = style.get(f"border-{side}") or style.get("border")
|
|
598
|
+
color = _parse_border_color(raw)
|
|
599
|
+
if color and not _border_is_none(raw):
|
|
600
|
+
_set_paragraph_border(paragraph, color, side=side, size=_border_size(raw))
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _apply_container_box(paragraphs: list, style: dict[str, str]) -> None:
|
|
604
|
+
if not paragraphs:
|
|
605
|
+
return
|
|
606
|
+
left = _css_length_to_cm(_box_side(style, "padding", "left")) or 0
|
|
607
|
+
right = _css_length_to_cm(_box_side(style, "padding", "right")) or 0
|
|
608
|
+
for paragraph in paragraphs:
|
|
609
|
+
if left:
|
|
610
|
+
paragraph.paragraph_format.left_indent = Cm(left)
|
|
611
|
+
if right:
|
|
612
|
+
paragraph.paragraph_format.right_indent = Cm(right)
|
|
613
|
+
for side in ("left", "right"):
|
|
614
|
+
raw = style.get(f"border-{side}") or style.get("border")
|
|
615
|
+
color = _parse_border_color(raw)
|
|
616
|
+
if color and not _border_is_none(raw):
|
|
617
|
+
_set_paragraph_border(paragraph, color, side=side, size=_border_size(raw))
|
|
618
|
+
first, last = paragraphs[0], paragraphs[-1]
|
|
619
|
+
top = _css_length_to_points(_box_side(style, "padding", "top"))
|
|
620
|
+
bottom = _css_length_to_points(_box_side(style, "padding", "bottom"))
|
|
621
|
+
if top:
|
|
622
|
+
first.paragraph_format.space_before = Pt(top)
|
|
623
|
+
if bottom:
|
|
624
|
+
last.paragraph_format.space_after = Pt(bottom)
|
|
625
|
+
for paragraph, side in ((first, "top"), (last, "bottom")):
|
|
626
|
+
raw = style.get(f"border-{side}") or style.get("border")
|
|
627
|
+
color = _parse_border_color(raw)
|
|
628
|
+
if color and not _border_is_none(raw):
|
|
629
|
+
_set_paragraph_border(paragraph, color, side=side, size=_border_size(raw))
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _border_size(value: str | None) -> str:
|
|
633
|
+
points = _css_length_to_points(value)
|
|
634
|
+
return str(max(2, min(round((points or 1) * 8), 96)))
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _border_is_none(value: str | None) -> bool:
|
|
638
|
+
return not value or any(token in value.lower().split() for token in ("none", "hidden", "0"))
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _table_column_widths(tag: Tag, col_count: int) -> list[float | None]:
|
|
642
|
+
widths: list[float | None] = []
|
|
643
|
+
colgroup = tag.find("colgroup", recursive=False)
|
|
644
|
+
if colgroup is not None:
|
|
645
|
+
for col in colgroup.find_all("col", recursive=False):
|
|
646
|
+
style = _parse_declarations(col.get("style", ""))
|
|
647
|
+
widths.append(_css_length_to_cm(style.get("width") or col.get("width")))
|
|
648
|
+
return (widths + [None] * col_count)[:col_count]
|
|
649
|
+
|
|
650
|
+
|
|
493
651
|
def _parse_color(value: str | None) -> str | None:
|
|
494
652
|
if not value:
|
|
495
653
|
return None
|
|
@@ -512,38 +670,6 @@ def _parse_border_color(value: str | None) -> str | None:
|
|
|
512
670
|
return _parse_color(value)
|
|
513
671
|
|
|
514
672
|
|
|
515
|
-
def _css_font_size(value: str | None, default: int) -> float:
|
|
516
|
-
if not value:
|
|
517
|
-
return default
|
|
518
|
-
match = re.search(r"([0-9.]+)\s*(pt|px|em|rem)?", value)
|
|
519
|
-
if not match:
|
|
520
|
-
return default
|
|
521
|
-
amount = float(match.group(1))
|
|
522
|
-
unit = match.group(2) or "pt"
|
|
523
|
-
if unit == "px":
|
|
524
|
-
amount *= 0.75
|
|
525
|
-
elif unit in {"em", "rem"}:
|
|
526
|
-
amount *= default
|
|
527
|
-
return max(6, min(amount, 72))
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
def _css_length_to_cm(value: str | None) -> float | None:
|
|
531
|
-
if not value:
|
|
532
|
-
return None
|
|
533
|
-
match = re.search(r"([0-9.]+)\s*(cm|mm|in|pt|px|%)?", str(value))
|
|
534
|
-
if not match:
|
|
535
|
-
return None
|
|
536
|
-
amount = float(match.group(1))
|
|
537
|
-
unit = match.group(2) or "px"
|
|
538
|
-
return {"cm": amount, "mm": amount / 10, "in": amount * 2.54, "pt": amount * 2.54 / 72, "px": amount * 2.54 / 96, "%": 15.5 * amount / 100}[unit]
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
def _first_font(value: str | None) -> str | None:
|
|
542
|
-
if not value:
|
|
543
|
-
return None
|
|
544
|
-
return value.split(",", 1)[0].strip().strip("\"'")
|
|
545
|
-
|
|
546
|
-
|
|
547
673
|
def _set_style_font(style, name: str, size: float, color: str) -> None:
|
|
548
674
|
style.font.name = name
|
|
549
675
|
style.font.size = Pt(size)
|
|
@@ -559,6 +685,20 @@ def _set_run_font(run, name: str, size: float, color: str | None = None) -> None
|
|
|
559
685
|
run.font.color.rgb = RGBColor.from_string(color)
|
|
560
686
|
|
|
561
687
|
|
|
688
|
+
def _set_run_spacing(run, twips: int) -> None:
|
|
689
|
+
r_pr = run._r.get_or_add_rPr()
|
|
690
|
+
spacing = r_pr.find(qn("w:spacing"))
|
|
691
|
+
if spacing is None:
|
|
692
|
+
spacing = OxmlElement("w:spacing")
|
|
693
|
+
r_pr.append(spacing)
|
|
694
|
+
spacing.set(qn("w:val"), str(twips))
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _run_has_explicit_color(run) -> bool:
|
|
698
|
+
r_pr = run._r.rPr
|
|
699
|
+
return r_pr is not None and r_pr.find(qn("w:color")) is not None
|
|
700
|
+
|
|
701
|
+
|
|
562
702
|
def _style_all_runs(paragraph, name: str, size: float, color: str) -> None:
|
|
563
703
|
for run in paragraph.runs:
|
|
564
704
|
_set_run_font(run, name, size, color)
|
|
@@ -589,12 +729,14 @@ def _set_paragraph_border(paragraph, color: str, *, side: str, size: str = "8")
|
|
|
589
729
|
p_pr.append(borders)
|
|
590
730
|
sides = ["top", "left", "bottom", "right"] if side == "all" else [side]
|
|
591
731
|
for side_name in sides:
|
|
592
|
-
edge =
|
|
732
|
+
edge = borders.find(qn(f"w:{side_name}"))
|
|
733
|
+
if edge is None:
|
|
734
|
+
edge = OxmlElement(f"w:{side_name}")
|
|
735
|
+
borders.append(edge)
|
|
593
736
|
edge.set(qn("w:val"), "single")
|
|
594
737
|
edge.set(qn("w:sz"), size)
|
|
595
738
|
edge.set(qn("w:space"), "4")
|
|
596
739
|
edge.set(qn("w:color"), color)
|
|
597
|
-
borders.append(edge)
|
|
598
740
|
|
|
599
741
|
|
|
600
742
|
def _set_cell_shading(cell, color: str) -> None:
|
|
@@ -609,9 +751,64 @@ def _set_cell_shading(cell, color: str) -> None:
|
|
|
609
751
|
|
|
610
752
|
def _set_cell_vertical_alignment(cell, value: str) -> None:
|
|
611
753
|
tc_pr = cell._tc.get_or_add_tcPr()
|
|
612
|
-
align =
|
|
754
|
+
align = tc_pr.find(qn("w:vAlign"))
|
|
755
|
+
if align is None:
|
|
756
|
+
align = OxmlElement("w:vAlign")
|
|
757
|
+
tc_pr.append(align)
|
|
613
758
|
align.set(qn("w:val"), value)
|
|
614
|
-
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _set_cell_margins(cell, style: dict[str, str]) -> None:
|
|
762
|
+
tc_pr = cell._tc.get_or_add_tcPr()
|
|
763
|
+
margins = tc_pr.find(qn("w:tcMar"))
|
|
764
|
+
if margins is None:
|
|
765
|
+
margins = OxmlElement("w:tcMar")
|
|
766
|
+
tc_pr.append(margins)
|
|
767
|
+
defaults = {"top": 80, "left": 100, "bottom": 80, "right": 100}
|
|
768
|
+
for side, default in defaults.items():
|
|
769
|
+
value = _css_length_to_twips(_box_side(style, "padding", side))
|
|
770
|
+
node = margins.find(qn(f"w:{side}"))
|
|
771
|
+
if node is None:
|
|
772
|
+
node = OxmlElement(f"w:{side}")
|
|
773
|
+
margins.append(node)
|
|
774
|
+
node.set(qn("w:w"), str(value if value is not None else default))
|
|
775
|
+
node.set(qn("w:type"), "dxa")
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _set_cell_borders(cell, style: dict[str, str], default_color: str) -> None:
|
|
779
|
+
if not any(key == "border" or key.startswith("border-") for key in style):
|
|
780
|
+
return
|
|
781
|
+
tc_pr = cell._tc.get_or_add_tcPr()
|
|
782
|
+
borders = tc_pr.find(qn("w:tcBorders"))
|
|
783
|
+
if borders is None:
|
|
784
|
+
borders = OxmlElement("w:tcBorders")
|
|
785
|
+
tc_pr.append(borders)
|
|
786
|
+
for side in ("top", "left", "bottom", "right"):
|
|
787
|
+
raw = style.get(f"border-{side}") or style.get("border")
|
|
788
|
+
node = borders.find(qn(f"w:{side}"))
|
|
789
|
+
if node is None:
|
|
790
|
+
node = OxmlElement(f"w:{side}")
|
|
791
|
+
borders.append(node)
|
|
792
|
+
if _border_is_none(raw):
|
|
793
|
+
node.set(qn("w:val"), "nil")
|
|
794
|
+
continue
|
|
795
|
+
node.set(qn("w:val"), "single")
|
|
796
|
+
node.set(qn("w:sz"), _border_size(raw))
|
|
797
|
+
node.set(qn("w:color"), _parse_border_color(raw) or default_color)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _prevent_table_row_split(row) -> None:
|
|
801
|
+
tr_pr = row._tr.get_or_add_trPr()
|
|
802
|
+
if tr_pr.find(qn("w:cantSplit")) is None:
|
|
803
|
+
tr_pr.append(OxmlElement("w:cantSplit"))
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _repeat_table_header(row) -> None:
|
|
807
|
+
tr_pr = row._tr.get_or_add_trPr()
|
|
808
|
+
if tr_pr.find(qn("w:tblHeader")) is None:
|
|
809
|
+
marker = OxmlElement("w:tblHeader")
|
|
810
|
+
marker.set(qn("w:val"), "true")
|
|
811
|
+
tr_pr.append(marker)
|
|
615
812
|
|
|
616
813
|
|
|
617
814
|
def _add_hyperlink(paragraph, text: str, url: str, color: str) -> None:
|