"""
文件样式管理器
- 提供预设管理、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,
'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,
'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)
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')