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