wechat-robot-skills/skills/docx/scripts/_document_builder.py
2026-07-26 02:45:00 +08:00

768 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
from typing import Any, Iterable, Optional
from _docx_common import W_NS, input_file, qn
MAX_BLOCKS = 1_000
MAX_TABLE_CELLS = 20_000
MAX_IMAGES = 100
ALIGNMENTS = {
"left": 0,
"center": 1,
"right": 2,
"justify": 3,
"distribute": 4,
}
VERTICAL_ALIGNMENTS = {
"top": 0,
"center": 1,
"bottom": 3,
}
HEX_COLOR_LENGTHS = {6, 8}
def expect_object(value: Any, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise ValueError(f"{label} 必须是对象")
return value
def expect_list(value: Any, label: str) -> list[Any]:
if not isinstance(value, list):
raise ValueError(f"{label} 必须是数组")
return value
def color(value: Any, label: str) -> str:
text = str(value).strip().lstrip("#")
if len(text) not in HEX_COLOR_LENGTHS:
raise ValueError(f"{label} 必须是 6 位或 8 位十六进制颜色")
try:
int(text, 16)
except ValueError as exc:
raise ValueError(f"{label} 必须是十六进制颜色") from exc
return text.upper()[-6:]
def set_run_font_name(run: Any, name: str, east_asia: Optional[str] = None) -> None:
run.font.name = name
fonts = run._element.get_or_add_rPr().get_or_add_rFonts()
fonts.set(qn("ascii"), name)
fonts.set(qn("hAnsi"), name)
fonts.set(qn("eastAsia"), east_asia or name)
def apply_run_style(run: Any, raw_spec: Any, defaults: Optional[dict[str, Any]] = None) -> None:
from docx.enum.text import WD_UNDERLINE
from docx.shared import Pt, RGBColor
spec = {**(defaults or {}), **expect_object(raw_spec, "run")}
allowed = {
"text",
"bold",
"italic",
"underline",
"strike",
"color",
"highlight",
"font",
"east_asia_font",
"size",
"superscript",
"subscript",
"small_caps",
"all_caps",
"style",
"hyperlink",
}
unknown = set(spec) - allowed
if unknown:
raise ValueError(f"run 包含未知字段:{sorted(unknown)}")
if "bold" in spec:
run.bold = bool(spec["bold"])
if "italic" in spec:
run.italic = bool(spec["italic"])
if "underline" in spec:
underline = spec["underline"]
if underline in {True, "single"}:
run.underline = True
elif underline in {False, None}:
run.underline = False
elif underline == "double":
run.underline = WD_UNDERLINE.DOUBLE
else:
raise ValueError("underline 仅支持 true、false、single、double")
if "strike" in spec:
run.font.strike = bool(spec["strike"])
if "color" in spec:
run.font.color.rgb = RGBColor.from_string(color(spec["color"], "run.color"))
if "font" in spec or "east_asia_font" in spec:
font_name = str(spec.get("font", "Arial"))
set_run_font_name(run, font_name, str(spec.get("east_asia_font", font_name)))
if "size" in spec:
size = float(spec["size"])
if size < 1 or size > 200:
raise ValueError("run.size 必须在 1 到 200 之间")
run.font.size = Pt(size)
for key in ("superscript", "subscript", "small_caps", "all_caps"):
if key in spec:
setattr(run.font, key, bool(spec[key]))
if "style" in spec:
run.style = str(spec["style"])
def _add_hyperlink(
paragraph: Any,
text: str,
url: str,
raw_spec: dict[str, Any],
) -> Any:
from docx.oxml import OxmlElement
from docx.opc.constants import RELATIONSHIP_TYPE
relationship_id = paragraph.part.relate_to(
url,
RELATIONSHIP_TYPE.HYPERLINK,
is_external=True,
)
hyperlink = OxmlElement("w:hyperlink")
hyperlink.set(f"{{http://schemas.openxmlformats.org/officeDocument/2006/relationships}}id", relationship_id)
run = paragraph.add_run(text)
apply_run_style(run, raw_spec)
run_properties = run._element.get_or_add_rPr()
if run_properties.find(qn("color")) is None:
color_element = OxmlElement("w:color")
color_element.set(qn("val"), "0563C1")
run_properties.append(color_element)
if run_properties.find(qn("u")) is None:
underline = OxmlElement("w:u")
underline.set(qn("val"), "single")
run_properties.append(underline)
paragraph._p.remove(run._element)
hyperlink.append(run._element)
paragraph._p.append(hyperlink)
return run
def add_runs(
paragraph: Any,
*,
text: Optional[str] = None,
runs: Optional[list[Any]] = None,
defaults: Optional[dict[str, Any]] = None,
) -> None:
if runs is not None and text is not None:
raise ValueError("段落不能同时提供 text 和 runs")
if runs is None:
run = paragraph.add_run("" if text is None else str(text))
if defaults:
apply_run_style(run, defaults)
return
for index, raw_run in enumerate(runs):
if isinstance(raw_run, str):
run = paragraph.add_run(raw_run)
if defaults:
apply_run_style(run, defaults)
continue
spec = expect_object(raw_run, f"runs[{index}]")
run_text = str(spec.get("text", ""))
if spec.get("hyperlink"):
_add_hyperlink(
paragraph,
run_text,
str(spec["hyperlink"]),
{**(defaults or {}), **spec},
)
else:
run = paragraph.add_run(run_text)
apply_run_style(run, spec, defaults)
def apply_paragraph_format(paragraph: Any, raw_spec: Any) -> None:
from docx.shared import Inches, Pt
spec = expect_object(raw_spec, "paragraph")
if "style" in spec:
paragraph.style = str(spec["style"])
if "alignment" in spec:
alignment = str(spec["alignment"]).lower()
if alignment not in ALIGNMENTS:
raise ValueError(f"段落对齐方式无效:{alignment}")
paragraph.alignment = ALIGNMENTS[alignment]
paragraph_format = paragraph.paragraph_format
points = {
"space_before": "space_before",
"space_after": "space_after",
"line_spacing": "line_spacing",
}
for source, target in points.items():
if source in spec:
value = float(spec[source])
if value < 0 or value > 500:
raise ValueError(f"{source} 超出合理范围")
setattr(paragraph_format, target, Pt(value))
inches = {
"left_indent": "left_indent",
"right_indent": "right_indent",
"first_line_indent": "first_line_indent",
}
for source, target in inches.items():
if source in spec:
setattr(paragraph_format, target, Inches(float(spec[source])))
for key in (
"keep_with_next",
"keep_together",
"page_break_before",
"widow_control",
):
if key in spec:
setattr(paragraph_format, key, bool(spec[key]))
def add_paragraph_from_spec(
container: Any,
raw_spec: Any,
*,
style: Optional[str] = None,
default_run_style: Optional[dict[str, Any]] = None,
) -> Any:
spec = (
{"text": raw_spec}
if isinstance(raw_spec, str)
else expect_object(raw_spec, "paragraph")
)
paragraph = container.add_paragraph()
if style:
paragraph.style = style
add_runs(
paragraph,
text=str(spec["text"]) if "text" in spec else None,
runs=spec.get("runs"),
defaults=default_run_style,
)
apply_paragraph_format(paragraph, spec)
return paragraph
def _set_cell_shading(cell: Any, fill: str) -> None:
from docx.oxml import OxmlElement
properties = cell._tc.get_or_add_tcPr()
existing = properties.find(qn("shd"))
if existing is not None:
properties.remove(existing)
shading = OxmlElement("w:shd")
shading.set(qn("fill"), color(fill, "table.shading"))
properties.append(shading)
def _set_cell_width(cell: Any, width_inches: float) -> None:
from docx.oxml import OxmlElement
cell.width = _inches(width_inches)
properties = cell._tc.get_or_add_tcPr()
width = properties.find(qn("tcW"))
if width is None:
width = OxmlElement("w:tcW")
properties.append(width)
width.set(qn("w"), str(round(width_inches * 1440)))
width.set(qn("type"), "dxa")
def _inches(value: float) -> Any:
from docx.shared import Inches
return Inches(float(value))
def _repeat_table_row(row: Any) -> None:
from docx.oxml import OxmlElement
properties = row._tr.get_or_add_trPr()
repeat = OxmlElement("w:tblHeader")
repeat.set(qn("val"), "true")
properties.append(repeat)
def _prevent_row_split(row: Any) -> None:
from docx.oxml import OxmlElement
properties = row._tr.get_or_add_trPr()
cannot_split = OxmlElement("w:cantSplit")
properties.append(cannot_split)
def add_table(container: Any, raw_spec: Any) -> Any:
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT
spec = expect_object(raw_spec, "table")
rows = expect_list(spec.get("rows"), "table.rows")
if not rows:
raise ValueError("table.rows 不能为空")
normalized_rows = [expect_list(row, "table.rows[]") for row in rows]
column_count = max((len(row) for row in normalized_rows), default=0)
if column_count < 1:
raise ValueError("表格必须至少有一列")
if len(rows) * column_count > MAX_TABLE_CELLS:
raise ValueError("单个表格超过单元格数量限制")
table = container.add_table(rows=len(rows), cols=column_count)
table.style = str(spec.get("style", "Table Grid"))
table.autofit = bool(spec.get("autofit", False))
alignment = str(spec.get("alignment", "center")).lower()
if alignment not in {"left", "center", "right"}:
raise ValueError("table.alignment 仅支持 left、center、right")
table.alignment = {
"left": WD_TABLE_ALIGNMENT.LEFT,
"center": WD_TABLE_ALIGNMENT.CENTER,
"right": WD_TABLE_ALIGNMENT.RIGHT,
}[alignment]
widths = spec.get("column_widths")
if widths is not None:
widths = expect_list(widths, "table.column_widths")
if len(widths) != column_count:
raise ValueError("table.column_widths 数量必须等于列数")
widths = [float(item) for item in widths]
header_rows = int(spec.get("header_rows", 1))
if header_rows < 0 or header_rows > len(rows):
raise ValueError("table.header_rows 超出范围")
header_fill = str(spec.get("header_fill", "1F4E78"))
header_font_color = str(spec.get("header_font_color", "FFFFFF"))
vertical = str(spec.get("vertical_alignment", "center")).lower()
if vertical not in VERTICAL_ALIGNMENTS:
raise ValueError("table.vertical_alignment 无效")
for row_index, (row, values) in enumerate(zip(table.rows, normalized_rows)):
_prevent_row_split(row)
if row_index < header_rows:
_repeat_table_row(row)
for column_index, cell in enumerate(row.cells):
if widths:
_set_cell_width(cell, widths[column_index])
cell.vertical_alignment = {
0: WD_CELL_VERTICAL_ALIGNMENT.TOP,
1: WD_CELL_VERTICAL_ALIGNMENT.CENTER,
3: WD_CELL_VERTICAL_ALIGNMENT.BOTTOM,
}[VERTICAL_ALIGNMENTS[vertical]]
if column_index >= len(values):
continue
value = values[column_index]
paragraph = cell.paragraphs[0]
if isinstance(value, dict):
value_spec = expect_object(value, "table cell")
add_runs(
paragraph,
text=str(value_spec["text"]) if "text" in value_spec else None,
runs=value_spec.get("runs"),
)
apply_paragraph_format(paragraph, value_spec)
else:
add_runs(paragraph, text="" if value is None else str(value))
if row_index < header_rows:
_set_cell_shading(cell, header_fill)
for run in paragraph.runs:
run.bold = True
from docx.shared import RGBColor
run.font.color.rgb = RGBColor.from_string(
color(header_font_color, "header_font_color")
)
for raw_merge in spec.get("merges", []):
merge = expect_object(raw_merge, "table.merges[]")
start = expect_list(merge.get("start"), "table.merge.start")
end = expect_list(merge.get("end"), "table.merge.end")
if len(start) != 2 or len(end) != 2:
raise ValueError("table merge 坐标必须是 [row, column]")
table.cell(int(start[0]), int(start[1])).merge(
table.cell(int(end[0]), int(end[1]))
)
return table
def _add_page_number(
paragraph: Any,
*,
prefix: str = "",
suffix: str = "",
) -> None:
from docx.oxml import OxmlElement
if prefix:
paragraph.add_run(prefix)
run = paragraph.add_run()
begin = OxmlElement("w:fldChar")
begin.set(qn("fldCharType"), "begin")
instruction = OxmlElement("w:instrText")
instruction.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
instruction.text = " PAGE "
separate = OxmlElement("w:fldChar")
separate.set(qn("fldCharType"), "separate")
value = OxmlElement("w:t")
value.text = "1"
end = OxmlElement("w:fldChar")
end.set(qn("fldCharType"), "end")
run._r.extend([begin, instruction, separate, value, end])
if suffix:
paragraph.add_run(suffix)
def _add_toc(container: Any, raw_spec: Any) -> Any:
from docx.oxml import OxmlElement
spec = expect_object(raw_spec, "toc")
levels = str(spec.get("levels", "1-3"))
if spec.get("title"):
title = container.add_paragraph(str(spec["title"]))
title.style = str(spec.get("title_style", "Heading 1"))
title.paragraph_format.keep_with_next = True
paragraph = container.add_paragraph()
run = paragraph.add_run()
begin = OxmlElement("w:fldChar")
begin.set(qn("fldCharType"), "begin")
instruction = OxmlElement("w:instrText")
instruction.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
instruction.text = f'TOC \\o "{levels}" \\h \\z \\u'
separate = OxmlElement("w:fldChar")
separate.set(qn("fldCharType"), "separate")
placeholder = OxmlElement("w:t")
placeholder.text = "目录将在打开文档时更新"
end = OxmlElement("w:fldChar")
end.set(qn("fldCharType"), "end")
run._r.extend([begin, instruction, separate, placeholder, end])
return paragraph
def _add_horizontal_rule(container: Any, raw_spec: Any) -> Any:
from docx.oxml import OxmlElement
spec = expect_object(raw_spec, "horizontal_rule")
paragraph = container.add_paragraph()
properties = paragraph._p.get_or_add_pPr()
borders = OxmlElement("w:pBdr")
bottom = OxmlElement("w:bottom")
bottom.set(qn("val"), str(spec.get("style", "single")))
bottom.set(qn("sz"), str(int(spec.get("size", 6))))
bottom.set(qn("space"), str(int(spec.get("space", 1))))
bottom.set(qn("color"), color(spec.get("color", "808080"), "rule.color"))
borders.append(bottom)
properties.append(borders)
return paragraph
def _add_image(container: Any, raw_spec: Any) -> Any:
from docx.enum.text import WD_ALIGN_PARAGRAPH
spec = expect_object(raw_spec, "image")
image_path = input_file(
str(spec.get("path", "")),
{".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tif", ".tiff"},
)
paragraph = container.add_paragraph()
alignment = str(spec.get("alignment", "center")).lower()
if alignment not in ALIGNMENTS:
raise ValueError("image.alignment 无效")
paragraph.alignment = ALIGNMENTS[alignment]
run = paragraph.add_run()
kwargs: dict[str, Any] = {}
if "width_inches" in spec:
kwargs["width"] = _inches(float(spec["width_inches"]))
if "height_inches" in spec:
kwargs["height"] = _inches(float(spec["height_inches"]))
run.add_picture(str(image_path), **kwargs)
if spec.get("caption"):
caption = container.add_paragraph(str(spec["caption"]))
caption.style = str(spec.get("caption_style", "Caption"))
caption.alignment = WD_ALIGN_PARAGRAPH.CENTER
return paragraph
def _add_section(document: Any, raw_spec: Any) -> Any:
from docx.enum.section import WD_SECTION
spec = expect_object(raw_spec, "section_break")
break_type = str(spec.get("break_type", "new_page")).lower()
mapping = {
"continuous": WD_SECTION.CONTINUOUS,
"new_column": WD_SECTION.NEW_COLUMN,
"new_page": WD_SECTION.NEW_PAGE,
"even_page": WD_SECTION.EVEN_PAGE,
"odd_page": WD_SECTION.ODD_PAGE,
}
if break_type not in mapping:
raise ValueError(f"section_break.break_type 无效:{break_type}")
section = document.add_section(mapping[break_type])
apply_page_settings(section, spec)
return section
def add_blocks(document: Any, container: Any, raw_blocks: Any) -> dict[str, int]:
from docx.enum.text import WD_BREAK
blocks = expect_list(raw_blocks, "blocks")
if len(blocks) > MAX_BLOCKS:
raise ValueError(f"blocks 不能超过 {MAX_BLOCKS}")
counts = {
"paragraphs": 0,
"tables": 0,
"images": 0,
"page_breaks": 0,
"sections": 0,
}
for index, raw_block in enumerate(blocks):
block = expect_object(raw_block, f"blocks[{index}]")
kind = str(block.get("type", "paragraph"))
if kind == "paragraph":
add_paragraph_from_spec(container, block)
counts["paragraphs"] += 1
elif kind == "heading":
level = int(block.get("level", 1))
if level < 1 or level > 9:
raise ValueError("heading.level 必须在 1 到 9 之间")
paragraph = container.add_paragraph(style=f"Heading {level}")
add_runs(
paragraph,
text=str(block["text"]) if "text" in block else None,
runs=block.get("runs"),
)
apply_paragraph_format(paragraph, block)
paragraph.paragraph_format.keep_with_next = True
counts["paragraphs"] += 1
elif kind in {"bullet_list", "numbered_list"}:
items = expect_list(block.get("items"), f"{kind}.items")
base_style = "List Bullet" if kind == "bullet_list" else "List Number"
level = int(block.get("level", 0))
style = base_style if level == 0 else f"{base_style} {min(level + 1, 3)}"
for item in items:
add_paragraph_from_spec(container, item, style=style)
counts["paragraphs"] += 1
elif kind == "table":
add_table(container, block)
counts["tables"] += 1
elif kind == "image":
if counts["images"] >= MAX_IMAGES:
raise ValueError(f"图片数量不能超过 {MAX_IMAGES}")
_add_image(container, block)
counts["images"] += 1
counts["paragraphs"] += 1
elif kind == "page_break":
paragraph = container.add_paragraph()
paragraph.add_run().add_break(WD_BREAK.PAGE)
counts["page_breaks"] += 1
counts["paragraphs"] += 1
elif kind == "horizontal_rule":
_add_horizontal_rule(container, block)
counts["paragraphs"] += 1
elif kind == "toc":
_add_toc(container, block)
counts["paragraphs"] += 1
elif kind == "section_break":
if container is not document:
raise ValueError("页眉或页脚中不能添加 section_break")
_add_section(document, block)
counts["sections"] += 1
elif kind == "spacer":
paragraph = container.add_paragraph()
paragraph.paragraph_format.space_after = _points(
float(block.get("points", 6))
)
counts["paragraphs"] += 1
else:
raise ValueError(f"不支持的 block.type{kind}")
return counts
def _points(value: float) -> Any:
from docx.shared import Pt
return Pt(value)
def apply_page_settings(section: Any, raw_spec: Any) -> None:
from docx.enum.section import WD_ORIENT
from docx.shared import Cm, Inches, Mm
spec = expect_object(raw_spec, "page")
size = str(spec.get("size", "A4")).upper()
if size == "A4":
width, height = Mm(210), Mm(297)
elif size == "LETTER":
width, height = Inches(8.5), Inches(11)
elif size == "LEGAL":
width, height = Inches(8.5), Inches(14)
else:
if "width_inches" not in spec or "height_inches" not in spec:
raise ValueError("自定义纸张必须提供 width_inches 和 height_inches")
width = Inches(float(spec["width_inches"]))
height = Inches(float(spec["height_inches"]))
orientation = str(spec.get("orientation", "portrait")).lower()
if orientation not in {"portrait", "landscape"}:
raise ValueError("page.orientation 只能是 portrait 或 landscape")
if orientation == "landscape":
section.orientation = WD_ORIENT.LANDSCAPE
section.page_width, section.page_height = height, width
else:
section.orientation = WD_ORIENT.PORTRAIT
section.page_width, section.page_height = width, height
margin_defaults = {
"top": 0.85,
"bottom": 0.85,
"left": 0.9,
"right": 0.9,
"header": 0.35,
"footer": 0.35,
}
margins = expect_object(spec.get("margins", {}), "page.margins")
for key, default in margin_defaults.items():
value = float(margins.get(key, default))
if value < 0 or value > 5:
raise ValueError(f"page.margins.{key} 超出合理范围")
target = f"{key}_margin" if key in {"top", "bottom", "left", "right"} else f"{key}_distance"
setattr(section, target, Inches(value))
if "gutter" in margins:
section.gutter = Inches(float(margins["gutter"]))
def apply_document_defaults(document: Any, raw_spec: Any) -> None:
from docx.shared import Pt, RGBColor
spec = expect_object(raw_spec, "default_font")
name = str(spec.get("name", "Arial"))
east_asia = str(spec.get("east_asia", "Noto Sans CJK SC"))
size = float(spec.get("size", 10.5))
normal = document.styles["Normal"]
normal.font.name = name
normal.font.size = Pt(size)
normal_fonts = normal._element.get_or_add_rPr().get_or_add_rFonts()
normal_fonts.set(qn("ascii"), name)
normal_fonts.set(qn("hAnsi"), name)
normal_fonts.set(qn("eastAsia"), east_asia)
normal.paragraph_format.space_after = Pt(float(spec.get("space_after", 6)))
normal.paragraph_format.line_spacing = float(spec.get("line_spacing", 1.15))
if "color" in spec:
normal.font.color.rgb = RGBColor.from_string(
color(spec["color"], "default_font.color")
)
def apply_named_styles(document: Any, raw_spec: Any) -> None:
from docx.shared import Pt, RGBColor
styles = expect_object(raw_spec, "styles")
for style_name, raw_style in styles.items():
if style_name not in document.styles:
raise ValueError(f"文档样式不存在:{style_name}")
spec = expect_object(raw_style, f"styles.{style_name}")
style = document.styles[style_name]
if "font" in spec:
font_name = str(spec["font"])
style.font.name = font_name
fonts = style._element.get_or_add_rPr().get_or_add_rFonts()
fonts.set(qn("ascii"), font_name)
fonts.set(qn("hAnsi"), font_name)
if "east_asia_font" in spec:
style._element.get_or_add_rPr().get_or_add_rFonts().set(
qn("eastAsia"),
str(spec["east_asia_font"]),
)
if "size" in spec:
style.font.size = Pt(float(spec["size"]))
if "bold" in spec:
style.font.bold = bool(spec["bold"])
if "italic" in spec:
style.font.italic = bool(spec["italic"])
if "color" in spec:
style.font.color.rgb = RGBColor.from_string(
color(spec["color"], f"styles.{style_name}.color")
)
if "space_before" in spec:
style.paragraph_format.space_before = Pt(float(spec["space_before"]))
if "space_after" in spec:
style.paragraph_format.space_after = Pt(float(spec["space_after"]))
if "keep_with_next" in spec:
style.paragraph_format.keep_with_next = bool(spec["keep_with_next"])
def apply_core_properties(document: Any, raw_spec: Any) -> None:
spec = expect_object(raw_spec, "properties")
allowed = {
"title",
"subject",
"author",
"last_modified_by",
"category",
"keywords",
"comments",
"language",
"identifier",
"version",
}
unknown = set(spec) - allowed
if unknown:
raise ValueError(f"properties 包含未知字段:{sorted(unknown)}")
properties = document.core_properties
for key, value in spec.items():
setattr(properties, key, value)
def _clear_container(container: Any) -> None:
for paragraph in list(container.paragraphs):
container._element.remove(paragraph._element)
for table in list(container.tables):
container._element.remove(table._element)
def apply_header_footer(document: Any, raw_header: Any, raw_footer: Any) -> None:
for section in document.sections:
if raw_header is not None:
spec = expect_object(raw_header, "header")
_clear_container(section.header)
if "blocks" in spec:
add_blocks(document, section.header, spec["blocks"])
else:
paragraph = section.header.add_paragraph()
add_runs(
paragraph,
text=str(spec.get("text", "")),
runs=spec.get("runs"),
)
apply_paragraph_format(paragraph, spec)
if raw_footer is not None:
spec = expect_object(raw_footer, "footer")
_clear_container(section.footer)
if "blocks" in spec:
add_blocks(document, section.footer, spec["blocks"])
else:
paragraph = section.footer.add_paragraph()
add_runs(
paragraph,
text=str(spec.get("text", "")),
runs=spec.get("runs"),
)
apply_paragraph_format(paragraph, spec)
if spec.get("page_number"):
_add_page_number(
paragraph,
prefix=str(spec.get("page_number_prefix", "")),
suffix=str(spec.get("page_number_suffix", "")),
)
def set_update_fields(document: Any) -> None:
from docx.oxml import OxmlElement
settings = document.settings._element
existing = settings.find(qn("updateFields"))
if existing is None:
existing = OxmlElement("w:updateFields")
settings.append(existing)
existing.set(qn("val"), "true")