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.
Files changed (26) hide show
  1. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/PKG-INFO +1 -1
  2. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/pyproject.toml +1 -1
  3. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/document.py +36 -34
  4. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/document_docx.py +263 -66
  5. licos_dev_sdk-0.3.2/src/licos_dev_sdk/document_docx_layout.py +141 -0
  6. licos_dev_sdk-0.3.2/tests/test_document_spreadsheet.py +149 -0
  7. licos_dev_sdk-0.3.0/tests/test_document_spreadsheet.py +0 -881
  8. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/.gitignore +0 -0
  9. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/__init__.py +0 -0
  10. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/_utils.py +0 -0
  11. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/archive.py +0 -0
  12. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/chart.py +0 -0
  13. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/data.py +0 -0
  14. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/diagram.py +0 -0
  15. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/document_common.py +0 -0
  16. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/image.py +0 -0
  17. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/model.py +0 -0
  18. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/observability.py +0 -0
  19. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/pdf_inspect.py +0 -0
  20. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/presentation.py +0 -0
  21. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/spreadsheet.py +0 -0
  22. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/spreadsheet_inspect.py +0 -0
  23. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/src/licos_dev_sdk/web.py +0 -0
  24. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/tests/test_model.py +0 -0
  25. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/tests/test_observability.py +0 -0
  26. {licos_dev_sdk-0.3.0 → licos_dev_sdk-0.3.2}/tests/test_output_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-dev-sdk
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: LICOS Dev SDK - file generation and model capability clients
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: beautifulsoup4>=4.12
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-dev-sdk"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "LICOS Dev SDK - file generation and model capability clients"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -1,17 +1,18 @@
1
- """Document generation — PDF, DOCX."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Any
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) -> str:
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 = _parse_color(style.get("background-color") or style.get("background"))
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
- run = p.add_run(text)
255
+ self._append_text(p, text, tag)
243
256
  if tag is not None:
244
- self._apply_run_style(run, tag)
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
- paragraph.add_run(str(node))
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
- run = paragraph.add_run(str(child))
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 p.add_run(str(child))
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 = max(sum(max(int(cell.get("colspan", 1)), 1) for cell in row.find_all(["th", "td"], recursive=False)) for row in rows)
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
- table.autofit = True
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
- target_col = 0
306
- cells = source_row.find_all(["th", "td"], recursive=False)
307
- for source_cell in cells:
308
- if target_col >= col_count:
309
- break
310
- colspan = max(int(source_cell.get("colspan", 1)), 1)
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 colspan > 1 and target_col + colspan <= col_count:
313
- target = target.merge(table.cell(row_index, target_col + colspan - 1))
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 row_index == 0
319
- fill = _parse_color(cell_style.get("background-color") or cell_style.get("background"))
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, "center")
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
- _set_run_font(run, self.font_name, self.font_size - 1, "FFFFFF" if is_header else self.theme["text"])
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 = _parse_color(style.get("background-color") or style.get("background")) or default_fill or self.theme["accent"]
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
- fill = _parse_color(style.get("background-color") or style.get("background"))
384
- if fill:
385
- _set_paragraph_shading(paragraph, fill)
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
- return bool(classes & {"info-box", "highlight", "warning", "callout", "notice"}) or "border-left" in style
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 = OxmlElement(f"w:{side_name}")
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 = OxmlElement("w:vAlign")
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
- tc_pr.append(align)
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: