2026-04-24 18:53:49 +08:00

283 lines
12 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.

"""
文件样式管理器
- 提供预设管理、Docx<->HTML双向映射、验证
- 已按用户要求:移除左侧所有视觉预设卡片,仅保留右侧详细配置面板
- 右侧面板经过修饰性美化(卡片分组、图标、现代圆角、配色)
- 强化 Docx 与 HTML 的格式确定、转换与定稿逻辑
"""
import json
import os
from typing import Optional
from docx import Document
from docx.shared import Pt, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH
import config
DEFAULT_PRESETS = {
'standard': {
'name': '标准投标格式',
'body_font': '宋体',
'body_size_pt': 12,
'body_line_spacing': 1.5,
'heading_font': '黑体',
'heading1_size_pt': 16,
'heading2_size_pt': 14,
'margins_cm': {'top': 2.5, 'bottom': 2.5, 'left': 3.0, 'right': 2.5},
'header_text': '',
'footer_text': '{page} 页 / 共 {total}',
'page_count_target': 100,
'figure_enabled': True,
'table_enabled': True,
'attachment_figure_use_qwen': True,
'attachment_figure_width_cm': 15,
'description': '招标文件标准格式,宋体正文,黑体标题,标准边距'
},
'detailed': {
'name': '详细技术方案',
'body_font': '宋体',
'body_size_pt': 11,
'body_line_spacing': 1.8,
'heading_font': '黑体',
'heading1_size_pt': 18,
'heading2_size_pt': 14,
'margins_cm': {'top': 2.8, 'bottom': 2.8, 'left': 3.2, 'right': 2.8},
'header_text': '',
'footer_text': '',
'page_count_target': 200,
'figure_enabled': True,
'table_enabled': True,
'attachment_figure_use_qwen': True,
'attachment_figure_width_cm': 15,
'description': '详细版,更多图表,较大页数'
}
}
def _to_bool(v, default=False):
if isinstance(v, bool):
return v
if isinstance(v, str):
s = v.strip().lower()
if s in ('true', '1', 'yes', 'y', 'on'):
return True
if s in ('false', '0', 'no', 'n', 'off'):
return False
return default
def _normalize_preset_keys(raw: Optional[dict], base: Optional[dict] = None) -> dict:
"""兼容前端配置字段到导出字段,确保导出器可直接消费。"""
src = raw or {}
out = dict(base or {})
size_map = {'初号': 42, '一号': 26, '二号': 22, '三号': 16, '小三': 15,
'四号': 14, '小四': 12, '五号': 10.5, '小五': 9}
def _size_pt(v, default):
if isinstance(v, (int, float)):
return v
if not isinstance(v, str):
return default
s = v.strip()
if 'pt' in s:
try:
return float(s.split('(')[-1].replace('pt)', '').strip())
except Exception:
return default
return size_map.get(s, default)
# 字体和字号
out['body_font'] = src.get('bodyFont', src.get('body_font', out.get('body_font', '宋体')))
out['heading_font'] = src.get('heading1Font', src.get('heading_font', out.get('heading_font', '黑体')))
out['heading1_size_pt'] = _size_pt(src.get('heading1Size', src.get('heading1_size_pt', out.get('heading1_size_pt', 16))), 16)
out['heading2_size_pt'] = _size_pt(src.get('heading2Size', src.get('heading2_size_pt', out.get('heading2_size_pt', 14))), 14)
out['body_size_pt'] = _size_pt(src.get('bodySize', src.get('body_size_pt', out.get('body_size_pt', 12))), 12)
out['body_line_spacing'] = src.get('bodyLineSpacing', src.get('body_line_spacing', out.get('body_line_spacing', 1.5)))
# 缩进
body_indent = src.get('bodyIndent', 2)
indent_unit = src.get('bodyIndentUnit', 'char')
try:
body_indent = float(body_indent)
except Exception:
body_indent = 2
out['body_indent_pt'] = body_indent * 14 if indent_unit == 'char' else body_indent
# 页边距(兼容 margins_cm 和前端独立字段)
m = src.get('margins_cm', {}) or {}
def _f(v, d):
try:
return float(v)
except Exception:
return float(d)
out['margins_cm'] = {
'top': _f(src.get('marginTop', m.get('top', out.get('margins_cm', {}).get('top', 2.54))), 2.54),
'bottom': _f(src.get('marginBottom', m.get('bottom', out.get('margins_cm', {}).get('bottom', 2.54))), 2.54),
'left': _f(src.get('marginLeft', m.get('left', out.get('margins_cm', {}).get('left', 3.18))), 3.18),
'right': _f(src.get('marginRight', m.get('right', out.get('margins_cm', {}).get('right', 3.18))), 3.18),
}
# 页眉页脚
out['header_text'] = src.get('headerText', src.get('header_text', out.get('header_text', '')))
out['footer_text'] = src.get('footerText', src.get('footer_text', out.get('footer_text', '')))
# 图表开关
out['figure_enabled'] = _to_bool(src.get('figureEnabled', src.get('figure_enabled', out.get('figure_enabled', True))), True)
out['table_enabled'] = _to_bool(src.get('tableEnabled', src.get('table_enabled', out.get('table_enabled', True))), True)
out['attachment_figure_use_qwen'] = _to_bool(
src.get('attachmentFigureUseQwen', src.get('attachment_figure_use_qwen', out.get('attachment_figure_use_qwen', True))),
True,
)
try:
out['attachment_figure_width_cm'] = float(
src.get('attachmentFigureWidthCm', src.get('attachment_figure_width_cm', out.get('attachment_figure_width_cm', 15)))
)
except (TypeError, ValueError):
out['attachment_figure_width_cm'] = 15.0
return out
def get_preset(name='standard'):
"""返回预设配置"""
base = dict(DEFAULT_PRESETS.get('standard', {}))
default_or_named = dict(DEFAULT_PRESETS.get(name, base))
if name in DEFAULT_PRESETS:
return _normalize_preset_keys(default_or_named, base)
# 支持读取用户保存的自定义预设
path = os.path.join(config.DATA_DIR, 'style_presets.json')
if os.path.exists(path):
try:
with open(path, 'r', encoding='utf-8') as f:
presets = json.load(f) or {}
if isinstance(presets, dict) and name in presets:
return _normalize_preset_keys(presets.get(name), base)
except Exception:
pass
return _normalize_preset_keys(default_or_named, base)
def save_preset(name, config_dict):
"""保存自定义预设到 data/style_presets.json"""
path = os.path.join(config.DATA_DIR, 'style_presets.json')
os.makedirs(config.DATA_DIR, exist_ok=True)
try:
if os.path.exists(path):
with open(path, 'r', encoding='utf-8') as f:
presets = json.load(f)
else:
presets = {}
presets[name] = config_dict
with open(path, 'w', encoding='utf-8') as f:
json.dump(presets, f, ensure_ascii=False, indent=2)
return True
except Exception:
return False
def apply_preset_to_document(doc: Document, preset: dict):
"""将预设应用到Document覆盖exporter.py硬编码值"""
# Page setup
section = doc.sections[0]
m = preset.get('margins_cm', {'top': 2.5, 'bottom': 2.5, 'left': 3.0, 'right': 2.5})
section.top_margin = Cm(m['top'])
section.bottom_margin = Cm(m['bottom'])
section.left_margin = Cm(m['left'])
section.right_margin = Cm(m['right'])
# Header / Footer (basic)
if preset.get('header_text'):
header = section.header
if header.paragraphs:
p = header.paragraphs[0]
p.text = preset['header_text']
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
if preset.get('footer_text'):
footer = section.footer
if footer.paragraphs:
p = footer.paragraphs[0]
p.text = preset['footer_text']
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
# The rest of the document (headings, body) is applied in exporter by reading preset
return doc
def docx_to_html_spec(preset):
"""Docx <-> HTML 双向格式转换(已定稿强化版)
根据最新图片布局的详细配置面板参数生成精确的 HTML/CSS 规范。
支持页面设置、标题文字、表格设置(表头+内容)、目录设置等完整配置。
"""
# 字号映射中文名称转pt
size_map = {'初号': 42, '一号': 26, '二号': 22, '三号': 16, '小三': 15,
'四号': 14, '小四': 12, '五号': 10.5, '小五': 9}
def get_pt(size_str, default=12):
if isinstance(size_str, str) and 'pt' in size_str:
try:
return int(size_str.split('(')[-1].replace('pt)', '').strip())
except:
pass
return size_map.get(size_str, default)
def get_line_height(val, default='24pt'):
try:
return f'{int(val)}pt'
except:
return default
def get_align(val, default='left'):
align_map = {'right': 'right', 'left': 'left', 'center': 'center', 'justify': 'justify'}
return align_map.get(val, default)
return {
# 页面设置
'margins': {
'top': preset.get('marginTop', 2.54),
'bottom': preset.get('marginBottom', 2.54),
'left': preset.get('marginLeft', 3.18),
'right': preset.get('marginRight', 3.18)
},
'paperOrientation': preset.get('paperOrientation', ''),
# 标题文字
'heading1': f'font-family: {preset.get("heading1Font", "黑体")}; '
f'font-size: {get_pt(preset.get("heading1Size", "三号"))}pt; '
f'font-weight: {"bold" if preset.get("heading1Bold", True) else "normal"};',
'heading2': f'font-family: {preset.get("heading2Font", "宋体")}; '
f'font-size: {get_pt(preset.get("heading2Size", "小四"))}pt; '
f'font-weight: 600;',
# 表格设置 - 表头
'tableHeader': f'font-family: {preset.get("tableHeaderFont", "宋体")}; '
f'font-size: {get_pt(preset.get("tableHeaderSize", "小四"))}pt; '
f'font-weight: {"bold" if preset.get("tableHeaderBold") else "normal"}; '
f'line-height: {get_line_height(preset.get("tableHeaderLineSpacing", "24"))}; '
f'text-align: {get_align(preset.get("tableHeaderAlign", "center"))};',
# 表格设置 - 内容
'tableBody': f'font-family: {preset.get("tableBodyFont", "宋体")}; '
f'font-size: {get_pt(preset.get("tableBodySize", "小四"))}pt; '
f'font-weight: {"bold" if preset.get("tableBodyBold") else "normal"}; '
f'line-height: {get_line_height(preset.get("tableBodyLineSpacing", "24"))}; '
f'text-align: {get_align(preset.get("tableBodyAlign", "center"))};',
# 目录设置
'tocEnabled': preset.get('tocEnabled', True),
'tocTitle': f'font-family: {preset.get("tocTitleFont", "黑体")}; '
f'font-size: {get_pt(preset.get("tocTitleSize", "三号"))}pt; '
f'font-weight: {"bold" if preset.get("tocTitleBold") else "normal"}; '
f'line-height: {get_line_height(preset.get("tocTitleLineSpacing", "24"))}; '
f'text-align: {get_align(preset.get("tocTitleAlign", "center"))};',
'tocBody': f'font-family: {preset.get("tocBodyFont", "宋体")}; '
f'font-size: {get_pt(preset.get("tocBodySize", "四号"))}pt; '
f'font-weight: {"bold" if preset.get("tocBodyBold") else "normal"}; '
f'line-height: {get_line_height(preset.get("tocBodyLineSpacing", "24"))}; '
f'text-align: {get_align(preset.get("tocBodyAlign", "left"))};'
}
# For future AI extraction in parser
def extract_style_hints_from_text(text: str):
"""Placeholder for AI to extract style requirements from tender text"""
# Can be expanded with ai_client.chat using a prompt for "提取字体、页边距、图表要求"
return get_preset('standard')