768 lines
27 KiB
Python
768 lines
27 KiB
Python
#!/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")
|