-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexport.py
More file actions
2084 lines (1924 loc) · 100 KB
/
Copy pathexport.py
File metadata and controls
2084 lines (1924 loc) · 100 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""export.py — 导出模块:Word 分析报告 + CSV 原始数据
从 gen_export_demo.py 验证过的逻辑抽取,供 API 路由复用。
"""
import csv
import datetime
import functools
import io
import os
import re
import tempfile
import threading
import time
import matplotlib
matplotlib.use("Agg") # 无界面后端,服务器环境必需
import matplotlib.pyplot as plt
from docx import Document
from docx.shared import Cm, Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
# matplotlib pyplot 全局状态非线程安全:FastAPI 线程池并发导出可能错图/偶发异常
# (回归修复)。所有绘图函数经 _plot_locked 串行化,且 finally 关闭全部 Figure 防泄漏。
_PLOT_LOCK = threading.Lock()
# Word COM 单实例锁(回归修复:原 _refresh_fields_docx 与 _convert_to_pdf 各持一把锁,
# 并发导出时两个线程同时 Dispatch Word → RPC server is busy/文件锁冲突)
_WORD_LOCK = threading.Lock()
def _parse_share(s) -> float | None:
"""从份额字符串稳健提取数字(数据准确性:一处实现,全局复用)
支持:'18.2%' / '46.5%(2026年Q1)' / '1,234.5%' / '18.2% (2026)' / 全角%
范围值('3-5%')取中点。解析失败返回 None(不抛异常、不产生错误数字)。
"""
if s is None:
return None
t = str(s).replace("%", "%").replace(",", "")
m = re.search(r"(\d+(?:\.\d+)?)\s*[-~至]\s*(\d+(?:\.\d+)?)", t)
if m: # 范围取中点
return (float(m.group(1)) + float(m.group(2))) / 2
m = re.search(r"(\d+(?:\.\d+)?)", t)
return float(m.group(1)) if m else None
def _pct(x, digits: int = 1, signed: bool = False) -> str:
"""百分数统一格式化(回归修复 G5:原 cagr/max_swing/market_share 三套写法,
精度不齐且无契约)。约定上游传 0-100 百分数;None/非数值 → "—"。"""
if x is None:
return "—"
try:
v = float(x)
except (TypeError, ValueError):
return "—"
if signed and v > 0:
return f"+{v:.{digits}f}%"
return f"{v:.{digits}f}%"
def _plot_locked(func):
"""装饰器:串行化 matplotlib 绘图 + 异常安全关闭所有 Figure"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
with _PLOT_LOCK:
try:
return func(*args, **kwargs)
finally:
plt.close("all")
return wrapper
# 中文字体:运行时探测可用 CJK 字体(Linux/CI 无微软雅黑时回退,防图表豆腐块)
def _pick_cjk_font() -> list:
"""按优先级探测本机可用中文字体;找不到时警告并回退"""
import logging
import matplotlib.font_manager as fm
candidates = ["Microsoft YaHei", "SimHei", "Noto Sans CJK SC", "Noto Sans CJK",
"WenQuanYi Zen Hei", "PingFang SC", "Source Han Sans SC"]
available = {f.name for f in fm.fontManager.ttflist}
chosen = [c for c in candidates if c in available]
if not chosen:
logging.warning("未找到中文字体(已探测 %s),图表中文可能显示为方块", "、".join(candidates))
return chosen
plt.rcParams["font.sans-serif"] = _pick_cjk_font() or ["DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
# Word 报告字体(学术论文规范):
# 正文:宋体 小四(12pt)
# 标题:黑体,一级四号(14pt)> 二级小四(12pt)> 三级五号(10.5pt)——大小递减分级
FONT_BODY = "宋体" # 正文
FONT_HEADING = "黑体" # 标题
FONT_SIZES = {
"Title": (FONT_HEADING, Pt(22)), # 封面大标题 二号(22pt)
"Heading 1": (FONT_HEADING, Pt(14)), # 一级标题 四号(14pt)
"Heading 2": (FONT_HEADING, Pt(12)), # 二级标题 小四(12pt)
"Heading 3": (FONT_HEADING, Pt(10.5)), # 三级标题 五号(10.5pt)
"Normal": (FONT_BODY, Pt(12)), # 正文 小四(12pt)
"List Bullet": (FONT_BODY, Pt(12)), # 列表 小四
}
def _set_font_style(style, font_name: str, size: Pt, bold: bool = False) -> None:
"""设置段落样式的字体(含 eastAsia,否则中文字符回退宋体导致大小不一)
必须清除 w:asciiTheme/w:eastAsiaTheme 等 theme 属性——theme 字体优先于
直接指定的 rFonts,不清除则设置不生效(Heading 默认用 majorHAnsi 主题字体)。
"""
style.font.name = font_name
style.font.size = size
style.font.bold = bold
# 关键:中文字体必须写到 rPr/w:eastAsia,python-docx 只设 name 对中文不生效
rpr = style.element.get_or_add_rPr()
rfonts = rpr.get_or_add_rFonts()
# 清除 theme 属性(majorHAnsi/majorEastAsia 等),否则优先于直接指定
for attr in ("w:asciiTheme", "w:hAnsiTheme", "w:eastAsiaTheme", "w:cstheme"):
if rfonts.get(qn(attr)) is not None:
del rfonts.attrib[qn(attr)]
rfonts.set(qn("w:eastAsia"), font_name)
rfonts.set(qn("w:ascii"), font_name)
rfonts.set(qn("w:hAnsi"), font_name)
def _apply_doc_fonts(doc: Document) -> None:
"""统一整份文档的字体与段间距(学术论文规范):
- 正文宋体小四 + 标题黑体四号
- 章节标题段前 24pt / 段后 12pt(拉开章节间隔)
- 小节标题段前 15.6pt / 段后 6pt(参考实训报告排版)
- 正文段后 3pt 微间隔,避免整块挤在一起
"""
spacing = {
"Title": (Pt(0), Pt(6)),
"Heading 1": (Pt(24), Pt(12)), # 章节:前 2 行 / 后 1 行
"Heading 2": (Pt(15.6), Pt(6)), # 小节:前约 1.3 行(实训报告同款)/ 后 0.5 行
"Heading 3": (Pt(12), Pt(4)),
"Normal": (Pt(0), Pt(3)),
"List Bullet": (Pt(0), Pt(2)),
}
for name, (font_name, size) in FONT_SIZES.items():
try:
_set_font_style(doc.styles[name], font_name, size,
bold=name in ("Title", "Heading 1", "Heading 2"))
sb, sa = spacing.get(name, (Pt(0), Pt(0)))
doc.styles[name].paragraph_format.space_before = sb
doc.styles[name].paragraph_format.space_after = sa
except KeyError:
continue
def _force_runs_font(doc: Document) -> None:
"""兜底:遍历所有段落 run 强制设置字体(模板渲染/样式继承不完全时仍生效)
样式表定义对 Word 大多数情况有效,但 docxtpl 渲染的模板段落可能带直接格式
(direct formatting),此时 run 级强制设置能覆盖,保证全文字体一致。
"""
for p in doc.paragraphs:
style_name = p.style.name if p.style else ""
is_heading = style_name.startswith("Heading") or style_name == "Title"
font_name = FONT_HEADING if is_heading else FONT_BODY
for run in p.runs:
run.font.name = font_name
# 标题 run 保留样式字号(不覆盖),正文 run 无字号时给默认
if run.font.size is None and not is_heading:
run.font.size = Pt(12)
rpr = run._element.get_or_add_rPr()
rfonts = rpr.get_or_add_rFonts()
rfonts.set(qn("w:eastAsia"), font_name)
rfonts.set(qn("w:ascii"), font_name)
rfonts.set(qn("w:hAnsi"), font_name)
# 表格单元格文字也统一(正文宋体)
for tbl in doc.tables:
for row in tbl.rows:
for cell in row.cells:
for p in cell.paragraphs:
for run in p.runs:
run.font.name = FONT_BODY
rpr = run._element.get_or_add_rPr()
rfonts = rpr.get_or_add_rFonts()
rfonts.set(qn("w:eastAsia"), FONT_BODY)
rfonts.set(qn("w:ascii"), FONT_BODY)
rfonts.set(qn("w:hAnsi"), FONT_BODY)
def _disable_spellcheck(doc: Document) -> None:
"""隐藏拼写/语法检查(文档级设置,随文档走)
报告含大量英文品牌/术语(AirPods/Huawei/UN Comtrade 等),Word 拼写检查
会标红波浪线。关键设置(不是 proofState——那只是"已检查"标记,打开时
Word 仍会重新检查标红):
- w:hideSpellingErrors:仅隐藏此文档的拼写错误(谁打开都不显示波浪线)
- w:hideGrammaticalErrors:同上,语法错误
- w:proofState clean:辅助标记
"""
from docx.oxml import OxmlElement
settings = doc.settings.element
# 删除已有同名元素再重插(防重复)
for tag in ("w:hideSpellingErrors", "w:hideGrammaticalErrors", "w:proofState"):
for el in settings.findall(qn(tag)):
settings.remove(el)
# 插到 settings 开头(schema 顺序:proofState 在前,hide 类在后)
for tag, val in (("w:hideSpellingErrors", "true"), ("w:hideGrammaticalErrors", "true")):
el = OxmlElement(tag)
el.set(qn("w:val"), val)
settings.insert(0, el)
ps = OxmlElement("w:proofState")
ps.set(qn("w:spelling"), "clean")
ps.set(qn("w:grammar"), "clean")
settings.insert(0, ps)
def _prevent_table_split(doc: Document) -> None:
"""表格行禁止跨页断开(w:cantSplit)
学术论文规范:表格不能从中间被分页切断。给所有表格行加 cantSplit——
行放不下时整行移到下一页,而不是被切成两半。
"""
from docx.oxml import OxmlElement
for tbl in doc.tables:
for row in tbl.rows:
tr_pr = row._tr.get_or_add_trPr()
if tr_pr.find(qn("w:cantSplit")) is None:
cant = OxmlElement("w:cantSplit")
tr_pr.append(cant)
def _add_page_numbers(doc: Document) -> None:
"""页脚加页码(居中:第 X 页 / 共 Y 页,PAGE/NUMPAGES 域)"""
from docx.oxml import OxmlElement
section = doc.sections[0]
footer = section.footer
p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
def _add_field(text: str) -> None:
r = p.add_run()
r.font.size = Pt(9)
b = OxmlElement("w:fldChar"); b.set(qn("w:fldCharType"), "begin")
instr = OxmlElement("w:instrText")
instr.set(qn("xml:space"), "preserve")
instr.text = text
sep = OxmlElement("w:fldChar"); sep.set(qn("w:fldCharType"), "separate")
ph = OxmlElement("w:t"); ph.text = "1"
end = OxmlElement("w:fldChar"); end.set(qn("w:fldCharType"), "end")
for el in (b, instr, sep, ph, end):
r._element.append(el)
run = p.add_run("第 ")
run.font.size = Pt(9)
_add_field(" PAGE ")
run = p.add_run(" 页 / 共 ")
run.font.size = Pt(9)
_add_field(" NUMPAGES ")
run = p.add_run(" 页")
run.font.size = Pt(9)
def _add_toc_field(doc: Document, anchor: object = None) -> None:
"""生成学术论文式目录(成品化:点线页码 + 跳转,不依赖 Word 打开时更新)
必须在文档内容全部生成后调用(目录需要知道每个标题的页码域):
- 为每个 Heading 1 标题加书签
- 在 anchor 段落后生成目录条目:标题文字 + 右对齐点线制表位 + PAGEREF 页码域
- 点线制表位(dot leader)是学术论文目录标准样式;PAGEREF 支持 Ctrl+点击跳转
"""
from docx.oxml import OxmlElement
def _add_bookmark(p, bm_id: int, name: str) -> None:
bm_start = OxmlElement("w:bookmarkStart")
bm_start.set(qn("w:id"), str(bm_id))
bm_start.set(qn("w:name"), name)
bm_end = OxmlElement("w:bookmarkEnd")
bm_end.set(qn("w:id"), str(bm_id))
p._p.insert(0, bm_start)
p._p.append(bm_end)
def _add_pageref(entry, bm_name: str) -> None:
"""条目 run 后追加 PAGEREF 页码域(tab 触发点线 + 页码)"""
r = entry.add_run("\t")._element
b = OxmlElement("w:fldChar"); b.set(qn("w:fldCharType"), "begin")
instr = OxmlElement("w:instrText")
instr.set(qn("xml:space"), "preserve")
instr.text = f' PAGEREF {bm_name} \\h '
sep = OxmlElement("w:fldChar"); sep.set(qn("w:fldCharType"), "separate")
ph = OxmlElement("w:t"); ph.text = "0"
end = OxmlElement("w:fldChar"); end.set(qn("w:fldCharType"), "end")
for el in (b, instr, sep, ph, end):
r.append(el)
# 1. 给所有 Heading 1 标题加书签("目录"自身跳过)
bm_id = 1
headings = []
for p in doc.paragraphs:
if p.style.name != "Heading 1" or p.text.strip() == "目录":
continue
name = f"_Toc_{bm_id}"
_add_bookmark(p, bm_id, name)
headings.append((p.text.strip(), name))
bm_id += 1
# 2. 生成目录条目(正序:lxml addnext 插到上一条之后)
from docx.text.paragraph import Paragraph as _Paragraph
# 内部锚点链接(w:anchor)不需要 relationship 声明——只有外部链接(w:r:id)才需要
last_el = anchor._p # 从 anchor 段落元素开始
for title, bm_name in headings:
new_el = last_el.makeelement(qn("w:p"), {})
last_el.addnext(new_el) # 插到 last_el 之后
entry = _Paragraph(new_el, anchor._parent)
# 回归修复:alignment=1 是 CENTER,页码会在 15.5cm 处居中;RIGHT=2 才对齐点线
entry.paragraph_format.tab_stops.add_tab_stop(Cm(15.5), alignment=2, leader=1)
# 目录文字用 w:hyperlink 包裹(单击直接跳转,无需 Ctrl+点击)
hl = OxmlElement("w:hyperlink")
hl.set(qn("w:anchor"), bm_name) # 锚点 = 标题书签名
r_el = OxmlElement("w:r")
rpr = OxmlElement("w:rPr")
rfonts = OxmlElement("w:rFonts")
rfonts.set(qn("w:ascii"), "微软雅黑")
rfonts.set(qn("w:hAnsi"), "微软雅黑")
rfonts.set(qn("w:eastAsia"), "微软雅黑")
rpr.append(rfonts)
color = OxmlElement("w:color")
color.set(qn("w:val"), "123C5C") # 深海蓝,区分普通文字
rpr.append(color)
r_el.append(rpr)
t = OxmlElement("w:t")
t.text = title
t.set(qn("xml:space"), "preserve")
r_el.append(t)
hl.append(r_el)
entry._p.append(hl)
# PAGEREF 页码域(点线 + 页码)
_add_pageref(entry, bm_name)
last_el = new_el
# settings.xml 加 updateFields(必须按 w:settings 的 schema 顺序插入,append 到末尾可能被 Word 忽略)
settings = doc.settings.element
if settings.find(qn("w:updateFields")) is None:
upd = OxmlElement("w:updateFields")
upd.set(qn("w:val"), "true")
# w:settings 子元素顺序:w:zoom, w:embedSystemFonts, w:defaultTabStop, w:updateFields, ...
# 插到第一个非 zoom 的元素前,保证在 w:defaultTabStop 之后
anchor_el = None
for child in settings:
tag = child.tag.split('}')[-1]
if tag not in ("zoom", "embedSystemFonts", "characterSpacingControl", "defaultTabStop"):
anchor_el = child
break
if anchor_el is not None:
anchor_el.addprevious(upd)
else:
settings.append(upd)
@_plot_locked
def build_trend_chart(trend: dict) -> io.BytesIO:
"""生成趋势折线图 PNG(内存流),供 Word 报告嵌入"""
# 按年份排序(回归修复 E2:键统一转 int——fill_between 对 str 键数组
# 会抛 TypeError;兼容 {year: {value, weight}} 与 {year: float} 两种结构)
trend = _norm_series(trend)
years = sorted(trend.keys())
def _val(y):
v = trend[y]
return v["value"] if isinstance(v, dict) else v
values = [_val(y) / 1e8 for y in years] # 亿美元
# 宽幅布局,给标注留足空间(tight_layout 防裁切)
fig, ax = plt.subplots(figsize=(10, 5))
fig.subplots_adjust(left=0.1, right=0.95, top=0.88, bottom=0.15)
ax.plot(years, values, marker="o", linewidth=2.2, color="#2e5bff")
ax.fill_between(years, values, alpha=0.12, color="#2e5bff")
ax.set_title("出口贸易金额趋势(亿美元)", fontsize=13)
ax.set_xlabel("年份", fontsize=11)
ax.set_ylabel("亿美元", fontsize=11)
ax.grid(True, alpha=0.3)
# 顶部留 15% 余量,防止峰值标注文字超出绘图区
vmin, vmax = ax.get_ylim()
ax.set_ylim(vmin, vmax * 1.15)
for x, y in zip(years, values):
# 标注放数据点下方(避免顶部溢出被裁切;统一向下永不出界)
ax.annotate(f"{y:.2f}", (x, y), textcoords="offset points",
xytext=(0, -14), fontsize=10, ha="center")
fig.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=150)
plt.close(fig)
buf.seek(0)
return buf
def build_executive_summary(product: str, target: str, year: str, stats: dict,
analysis: dict, total_value: float) -> str:
"""生成执行摘要(报告开头):关键数字 + AI 一句话总结 + 数据来源标注"""
lines = []
if stats:
# 数据区间(Citation:数字可溯源);单年显示"X年",多年显示"X-Y"
fy, ly = stats.get("first_year"), stats.get("last_year")
y_range = f"{fy}-{ly}" if fy and ly and fy != ly else (f"{fy}年" if fy else year)
lines.append(f"• 总出口额: {total_value / 1e8:.2f} 亿美元({y_range},UN Comtrade)")
if stats.get("cagr_pct") is not None:
lines.append(f"• 年复合增长率: {_pct(stats['cagr_pct'])}({y_range})")
if stats.get("peak_year"):
lines.append(f"• 峰值年份: {stats['peak_year']}({y_range} 区间内)")
if stats.get("change_over_period_pct") is not None:
lines.append(f"• 期末较期初变化: {stats['change_over_period_pct']:.1f}%({y_range})")
if analysis.get("overview"):
lines.append(f"• AI 总结: {analysis['overview']}")
lines.append(f"• 数据来源: UN Comtrade 公共 API,报告生成于 {datetime.date.today().isoformat()}")
return "\n".join(lines) if lines else f"({product} → {target} {year},暂无摘要数据)"
def _is_valid_pdf(path: str) -> bool:
"""PDF 有效性校验:文件存在、非 0 字节、%PDF 魔数开头 + %%EOF 结尾
(回归修复:原只查 4 字节魔数,截断/损坏文件会被当成品返回)"""
try:
if not os.path.exists(path) or os.path.getsize(path) == 0:
return False
with open(path, "rb") as f:
head = f.read(4)
f.seek(max(0, os.path.getsize(path) - 1024))
tail = f.read(1024)
return head == b"%PDF" and b"%%EOF" in tail
except OSError:
return False
def finalize_docx(buf: io.BytesIO, as_pdf: bool = False) -> tuple:
"""报告收尾(共用):写临时文件 → COM 更新域/修表格跨页 → 可选转 PDF → 返回
返回 (buf, fmt):fmt 为实际生成格式('docx'/'pdf'),调用方按 fmt 定 media_type 和文件名。
- docx: COM 更新 PAGEREF 页码、页脚 PAGE、表格防切分,补写拼写检查隐藏
- pdf: 在上一步基础上 Word/LibreOffice 导出 PDF;转换失败/结果无效降级返回 docx
- COM 全部失败:原样返回输入 buf(fmt 按请求,docx 域靠用户打开时更新)
"""
import logging
import os
import tempfile
# mkstemp 原子创建唯一临时文件(时间戳在同毫秒并发时可能撞名覆盖)
fd, tmp_path = tempfile.mkstemp(suffix=".docx", prefix="_tp_export_")
os.close(fd)
try:
with open(tmp_path, "wb") as f:
f.write(buf.getvalue())
_refresh_fields_docx(tmp_path)
read_path = tmp_path
fmt = "docx"
if as_pdf:
_convert_to_pdf(tmp_path)
pdf_path = tmp_path.replace(".docx", ".pdf")
if _is_valid_pdf(pdf_path):
read_path = pdf_path
fmt = "pdf"
else:
logging.warning("PDF 转换结果无效或缺失,降级返回 docx")
with open(read_path, "rb") as f:
return io.BytesIO(f.read()), fmt
except Exception:
# COM 全部失败:返回原始 docx(域靠用户打开时自动更新);PDF 请求强制降级 docx,
# 避免"docx 内容 + .pdf 后缀 + application/pdf"的损坏文件
logging.exception("报告收尾处理失败,返回原始 docx")
return buf, "docx"
finally:
for p in (tmp_path, tmp_path.replace(".docx", ".pdf")):
try:
os.remove(p)
except OSError:
# 回归修复 E8:Windows 下文件被占用删除失败时留痕(原静默累积临时文件)
logging.debug("临时文件清理失败(可能被占用): %s", p)
def _run_com_with_timeout(func, timeout: float = 45.0) -> bool:
"""在独立线程里执行 Word COM 调用并限时等待(回归修复 E1)
问题:Word 挂起(修复弹窗/RPC 卡死)时原实现永久占线程,且 _WORD_LOCK
被长期持有堵死所有导出,LibreOffice 回退永远等不到。
方案:COM 放 daemon 线程 + join(timeout);超时返回 False(COM 线程自行收尾),
调用方直接走回退路径,不再阻塞。线程内显式 CoInitialize(COM 线程亲和)。
"""
import logging
import threading as _t
result = {}
def runner():
import pythoncom
pythoncom.CoInitialize()
try:
func()
result["ok"] = True
except Exception as e:
result["error"] = e
finally:
pythoncom.CoUninitialize()
t = _t.Thread(target=runner, daemon=True)
t.start()
t.join(timeout)
if t.is_alive():
logging.warning("Word COM 调用超时(>%ss),跳过 COM 阶段", timeout)
return False
if "error" in result:
raise result["error"]
return bool(result.get("ok"))
def _word_convert_pdf(docx_path: str) -> None:
"""Word COM 转 PDF(在 _run_com_with_timeout 线程内执行)"""
import win32com.client
# DispatchEx 强制新建独立实例(回归修复:Dispatch 会连接用户已打开的
# Word,随后 Quit() 会关掉用户文档,有数据丢失风险)
word = win32com.client.DispatchEx("Word.Application")
word.Visible = False
try:
word.DisplayAlerts = 0
doc = word.Documents.Open(docx_path)
doc.SaveAs2(docx_path.replace(".docx", ".pdf"), FileFormat=17)
doc.Close()
finally:
word.Quit()
def _find_soffice():
"""探测 LibreOffice 可执行文件:PATH + 常见安装路径(回归修复 E6:
原只查 PATH,Windows/macOS 默认不在 PATH)"""
import shutil
found = shutil.which("soffice") or shutil.which("libreoffice")
if found:
return found
candidates = [
r"C:\Program Files\LibreOffice\program\soffice.exe",
r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
"/usr/bin/soffice",
]
for c in candidates:
if os.path.exists(c):
return c
return None
def _convert_to_pdf(docx_path: str) -> None:
"""docx → pdf(同目录同名 .pdf)。Word COM 优先,LibreOffice headless 回退
(Linux/Docker/无 Word 环境),两者都失败记日志(回归修复:此前完全静默且
Linux 下 PDF 永远降级 docx——LibreOffice 白装)。
"""
import logging
import subprocess
with _WORD_LOCK:
# 路径 1:Word COM(Windows + 已装 Word)——带超时(回归修复 E1)
try:
if _run_com_with_timeout(lambda: _word_convert_pdf(docx_path)):
return
except Exception as e:
logging.warning("Word COM 转 PDF 失败(尝试 LibreOffice 回退): %s", e)
# 路径 2:LibreOffice headless(跨平台后备)
try:
soffice = _find_soffice()
if soffice is None:
logging.warning("未找到 soffice/libreoffice,PDF 转换不可用(将降级 docx)")
return
out_dir = os.path.dirname(os.path.abspath(docx_path))
proc = subprocess.run(
[soffice, "--headless", "--convert-to", "pdf", "--outdir", out_dir, docx_path],
timeout=120, capture_output=True,
)
# 回归修复 E6:非零退出(profile 锁冲突等)时记录 stderr,不再静默
if proc.returncode != 0:
err = (proc.stderr or b"").decode("utf-8", errors="replace")[-500:]
logging.warning("LibreOffice 退出码 %d: %s", proc.returncode, err)
except Exception as e:
logging.warning("LibreOffice 转 PDF 失败(将降级 docx): %s", e)
def build_word_report(product: str, target: str, year: str, hs_code: str,
rows: list, ai: dict, hs_description: str = "",
stats: dict | None = None, analysis: dict | None = None,
landscape: dict | None = None,
market_ctx: dict | None = None,
matrix: list | None = None,
background: dict | None = None,
competitiveness: dict | None = None,
reporter: str = "中国") -> io.BytesIO:
"""生成贸易数据 Word 报告(与市场分析同套规范:封面/目录/字体/页码/表格防切)
章节:封面 → 目录 → 一、执行摘要 → 二、出口趋势(图)→ 三、数据总览(表)
→ 四、出口大国对比(矩阵)→ 五、竞争格局 → 六、目标市场消费环境
→ 七、原始数据(表)→ 八、AI 市场分析 → 附录:数据来源。
"""
from docx.oxml import OxmlElement
from docx.shared import RGBColor
total_value = sum(r.get("primaryValue") or 0 for r in rows)
total_wgt = sum(r.get("netWgt") or 0 for r in rows)
hs_desc = f"({hs_description})" if hs_description else ""
# 趋势图 PNG(≥2 年才生成);用 summarize_trend 逐年累加,与执行摘要 stats 同口径
chart_buf = None
from trade import summarize_trend, get_latest_year
trend_map = summarize_trend(rows)
if len(trend_map) >= 2:
chart_buf = build_trend_chart(trend_map)
doc = Document()
_apply_doc_fonts(doc)
style = doc.styles["Normal"]
style.font.name = FONT_BODY
style.font.size = Pt(12)
NAVY = RGBColor(0x12, 0x3C, 0x5C)
ACCENT = RGBColor(0xC4, 0x45, 0x2C)
def _hr(space_before: bool = True):
p = doc.add_paragraph()
if space_before:
p.paragraph_format.space_before = Pt(6)
ppr = p._p.get_or_add_pPr()
pbdr = OxmlElement("w:pBdr")
bottom = OxmlElement("w:bottom")
bottom.set(qn("w:val"), "single")
bottom.set(qn("w:sz"), "12")
bottom.set(qn("w:space"), "1")
bottom.set(qn("w:color"), "123C5C")
pbdr.append(bottom)
ppr.append(pbdr)
return p
def _h(text, level=1, blank_before=False):
if level == 1 and blank_before:
for _ in range(2):
blank = doc.add_paragraph()
# 空行段落加极小 run(2pt 字):被推到页首时只是微距而非空两行
br = blank.add_run(" ")
br.font.size = Pt(2)
blank.paragraph_format.space_before = Pt(0)
blank.paragraph_format.space_after = Pt(0)
blank.paragraph_format.line_spacing = Pt(2)
blank.paragraph_format.keepNext = True
return doc.add_heading(text, level=level)
def _p(text="", bold=False, indent=True):
p = doc.add_paragraph()
if indent:
p.paragraph_format.first_line_indent = Pt(24)
r = p.add_run(text)
r.bold = bold
return p
# ===== 封面 =====
brand = doc.add_paragraph()
brand.alignment = WD_ALIGN_PARAGRAPH.CENTER
brand.paragraph_format.space_before = Pt(12)
br = brand.add_run("TRADEPILOT AI · EXPORT INTELLIGENCE")
br.font.size = Pt(11)
br.font.color.rgb = NAVY
br.bold = True
_hr()
t = doc.add_heading(f"{product}出口贸易分析报告", level=0)
t.alignment = WD_ALIGN_PARAGRAPH.CENTER
t.paragraph_format.space_before = Pt(24)
for run in t.runs:
run.font.color.rgb = NAVY
st = doc.add_paragraph()
st.alignment = WD_ALIGN_PARAGRAPH.CENTER
st.paragraph_format.space_before = Pt(6)
sr = st.add_run(f"目标市场:{target} · 出口国:中国 · HS{hs_code}{hs_desc}")
sr.font.size = Pt(14)
sr.font.color.rgb = ACCENT
sr.bold = True
doc.add_paragraph()
info_lines = [
f"数据来源:UN Comtrade 联合国商品贸易数据库",
f"生成日期:{datetime.date.today().isoformat()} · 报告编号:TP-{datetime.date.today().strftime('%Y%m%d')}-{target}-HS{hs_code}",
"统计指标由程序精确计算 · AI 仅作解读 · 数据可溯源",
]
for line in info_lines:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.space_after = Pt(2)
r = p.add_run(line)
r.font.size = Pt(10.5)
r.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
_hr(space_before=True)
# 核心数据速览(封面 KPI 区标题:非章节标题,用 Normal 加粗避免混进目录)
kpi_title = doc.add_paragraph()
kpi_title.paragraph_format.space_before = Pt(12)
ktr = kpi_title.add_run("核心数据速览")
ktr.font.size = Pt(14)
ktr.bold = True
kpi_rows = []
# 回归修复:多年报告封面 KPI 用年份区间标注(原写死单年 year,与多年总量对不上);
# 与执行摘要同口径:stats.first_year-last_year
if stats and stats.get("first_year") and stats.get("last_year") and stats["first_year"] != stats["last_year"]:
year_label_kpi = f"{stats['first_year']}-{stats['last_year']}"
else:
year_label_kpi = str(year)
kpi_rows.append((f"对{target}出口总额({year_label_kpi})", f"{total_value / 1e8:.2f} 亿美元"))
if total_wgt:
kpi_rows.append(("出口净重", f"{total_wgt / 1e6:.2f} 千吨"))
if stats:
if stats.get("cagr_pct") is not None:
kpi_rows.append(("年复合增长率 CAGR", _pct(stats['cagr_pct'])))
if stats.get("peak_year"):
kpi_rows.append(("峰值年份", f"{stats['peak_year']}"))
kpi_rows.append(("数据记录数", f"{len(rows)} 条"))
if kpi_rows:
kpi_tbl = doc.add_table(rows=len(kpi_rows), cols=2)
kpi_tbl.style = "Light Grid Accent 1"
for i, (a, b) in enumerate(kpi_rows):
kpi_tbl.rows[i].cells[0].text = a
kpi_tbl.rows[i].cells[1].text = b
for cell in kpi_tbl.rows[i].cells:
for cp in cell.paragraphs:
cp.alignment = WD_ALIGN_PARAGRAPH.CENTER
_hr()
foot = doc.add_paragraph()
foot.alignment = WD_ALIGN_PARAGRAPH.CENTER
fr = foot.add_run("TradePilot AI · Export Intelligence")
fr.font.size = Pt(9)
fr.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
# ===== 目录(学术论文式:点线页码 + 单击跳转)=====
doc.add_page_break()
toc_title = _h("目录", 1)
doc.add_page_break()
# ===== 一、执行摘要 =====
_h("一、执行摘要", 1, blank_before=True)
summary_text = build_executive_summary(
product, target, year,
stats or {}, analysis or {},
total_value,
)
for line in summary_text.split("\n"):
_p(line)
# ===== 二、出口趋势(图)=====
_h("二、出口趋势", 1, blank_before=True)
if chart_buf:
_p(f"{reporter}对{target}出口 {product}(HS {hs_code})出口额变化趋势,单位:亿美元:")
doc.add_picture(chart_buf, width=Cm(14))
_p(f"数据来源:UN Comtrade 公共 API(HS {hs_code})", indent=False)
else:
_p("(单年数据,无趋势图)")
# ===== 三、数据总览表 =====
_h("三、数据总览", 1, blank_before=True)
tbl = doc.add_table(rows=3, cols=3)
tbl.style = "Light Grid Accent 1"
hdr = tbl.rows[0].cells
hdr[0].text, hdr[1].text, hdr[2].text = "指标", "数值", "单位"
tbl.rows[1].cells[0].text = "贸易金额"
tbl.rows[1].cells[1].text = f"{total_value:,.0f}"
tbl.rows[1].cells[2].text = "美元"
tbl.rows[2].cells[0].text = "净重"
tbl.rows[2].cells[1].text = f"{total_wgt:,.0f}"
tbl.rows[2].cells[2].text = "公斤"
if stats:
_h("统计指标(程序精确计算)", 2)
if stats.get("change_over_period_pct") is not None:
_p(f"• 区间 {stats['first_year']}-{stats['last_year']} 期末较期初变化 {stats['change_over_period_pct']:.1f}%", indent=False)
if stats.get("max_swing_year") is not None:
_p(f"• 最大单年波动:{stats['max_swing_year']} 年 {_pct(stats['max_swing_pct'])}", indent=False)
prices = stats.get("unit_prices") or []
if prices:
# 回归修复 E4:price 非数值(None/字符串)时跳过该项,不整体崩溃
clean_prices = [p for p in prices
if isinstance(p.get("price"), (int, float)) and p.get("year") is not None]
if clean_prices:
_p("• 单价趋势:" + "; ".join(
f"{p['year']}年 {p['price']:.2f} 美元/公斤" for p in clean_prices), indent=False)
# 单价柱状图(量价结构一眼看清)
_p("单价(美元/公斤)逐年变化:")
_add_bar_chart(doc, {p["year"]: p["price"] for p in clean_prices},
"出口单价趋势", "美元/公斤")
# ===== 四、出口大国对比(饼图主角 + 表格作证)=====
_h("四、出口大国对比", 1, blank_before=True)
if matrix:
_p(f"该品类对{target}的出口大国竞争格局:")
# 饼图先放:份额结构一眼看清
share_labels = [m.get("country", "") for m in matrix if m.get("market_share") is not None]
share_vals = [m["market_share"] for m in matrix if m.get("market_share") is not None]
if share_labels:
_p(f"各出口国占 {target} 市场进口份额(%,<4% 合并为其他):")
_add_pie_chart(doc, share_labels, share_vals, f"{product} 出口国份额结构", raw_values=share_vals)
# 饼图"其他"拆解 + 全量明细表(作证饼图 + 延伸分析)
_h("份额明细与「其他」拆解", 2)
_p("饼图中的「其他」包含以下出口国——份额虽小但增速与单价各不相同,值得单独观察:", indent=False)
mtbl = doc.add_table(rows=1 + len(matrix), cols=6)
mtbl.style = "Light Grid Accent 1"
for j, head in enumerate(["出口国", "最新出口(亿美元)", "占市场进口份额", "5年CAGR", "单价($/kg)", "判断"]):
mtbl.rows[0].cells[j].text = head
for i, m in enumerate(matrix, 1):
mtbl.rows[i].cells[0].text = str(m.get("country", ""))
mtbl.rows[i].cells[1].text = f"{m.get('export_value', 0) / 1e8:.2f}" if m.get("export_value") else "—"
mtbl.rows[i].cells[2].text = _pct(m.get("market_share")) if m.get("market_share") is not None else "—"
mtbl.rows[i].cells[3].text = f"{m['cagr_pct']:+.1f}%" if m.get("cagr_pct") is not None else "—"
mtbl.rows[i].cells[4].text = f"${m['unit_price']:.2f}" if m.get("unit_price") is not None else "—"
mtbl.rows[i].cells[5].text = str(m.get("verdict", ""))
# 解读:谁在涨谁在跌(基于矩阵数据)
_h("竞争态势解读", 2)
rising = [m for m in matrix if (m.get("cagr_pct") or 0) > 5]
falling = [m for m in matrix if (m.get("cagr_pct") or 0) < -2]
leader = matrix[0] if matrix else {}
if leader.get("country"):
# market_share 可为 None(目标市场无进口数据),防 "None%" 渲染(回归修复)
share_txt = _pct(leader.get("market_share")) if leader.get("market_share") is not None else "暂无数据"
_p(f"• {leader['country']}以 {leader.get('export_value', 0) / 1e8:.2f} 亿美元居首(占市场进口 {share_txt}),"
f"CAGR {(leader.get('cagr_pct') or 0):+.1f}%({leader.get('verdict', '')})。", indent=False)
if rising:
names = "、".join(m["country"] for m in rising[:3])
cagrs = "、".join("{:+.1f}%".format(m["cagr_pct"] or 0) for m in rising[:3])
_p(f"• 上升方:{names}(CAGR {cagrs})——"
f"这些出口国份额在扩大,是{leader.get('country', '中国')}的主要追赶者。", indent=False)
if falling:
names = "、".join(m["country"] for m in falling[:3])
cagrs = "、".join("{:+.1f}%".format(m["cagr_pct"] or 0) for m in falling[:3])
_p(f"• 下滑方:{names}(CAGR {cagrs})——"
f"份额收缩,竞争压力相对缓解。", indent=False)
else:
_p("(出口大国对比数据不足)")
# ===== 五、竞争格局(饼图主角 + 表格作证)=====
_h("五、竞争格局", 1, blank_before=True)
if landscape and landscape.get("top_brands"):
brands = landscape["top_brands"]
_p(f"{landscape.get('product_category', product)} 龙头品牌竞争格局(来源:{landscape.get('_source', '行业检索')})。")
# 品牌份额饼图先放(解析 share 里的数字,用模块级 _parse_share 统一处理)
pie_labels = []
pie_vals = []
for b in brands[:8]:
v = _parse_share(b.get("share"))
if v is not None and v > 0:
pie_labels.append(b.get("name", ""))
pie_vals.append(v)
if pie_labels:
_p(f"龙头品牌市场份额结构(<4% 合并为其他):")
_add_pie_chart(doc, pie_labels, pie_vals, f"{product} 品牌份额", raw_values=pie_vals)
# 份额排名表(作证饼图 + 地位说明)
_h("品牌份额排名", 2)
_p("饼图份额对应的品牌全量明细——排名、份额、口径与市场地位。份额口径(如「2021年中国市场出货量份额」)必须与份额同列标注,避免把中国份额误读成目标市场份额。", indent=False)
btbl = doc.add_table(rows=1 + len(brands), cols=4)
btbl.style = "Light Grid Accent 1"
for j, head in enumerate(["品牌", "市场份额", "份额口径", "市场地位"]):
btbl.rows[0].cells[j].text = head
for i, b in enumerate(brands, 1):
btbl.rows[i].cells[0].text = str(b.get("name", ""))
btbl.rows[i].cells[1].text = str(b.get("share", ""))
# 数据可信(B5 口径纪律):share_scope 缺失时显式标注"未标注口径",
# 而非留空让份额数字脱离口径裸奔(中国份额被误读成德国份额的根源)
btbl.rows[i].cells[2].text = str(b.get("share_scope", "") or "未标注口径")
btbl.rows[i].cells[3].text = str(b.get("position", ""))
if landscape.get("shift_reasons"):
_h("格局变动原因", 2)
for r in landscape["shift_reasons"]:
_p(f"• {r}", indent=False)
if landscape.get("chain_insight"):
_h("产业链洞察", 2)
_p(f"• {landscape['chain_insight']}", indent=False)
else:
_p("(竞争格局数据不足)")
# ===== 六、目标市场消费环境 =====
_h("六、目标市场消费环境", 1, blank_before=True)
if market_ctx and market_ctx.get("available"):
env = []
if market_ctx.get("gdp"):
env.append(f"GDP {market_ctx['gdp'] / 1e12:.2f} 万亿美元")
if market_ctx.get("population"):
env.append(f"人口 {market_ctx['population'] / 1e8:.2f} 亿")
if market_ctx.get("gdp_per_capita"):
env.append(f"人均 GDP {market_ctx['gdp_per_capita']:,.0f} 美元")
_p(f"{target} 经济环境(World Bank):{'、'.join(env)}")
if market_ctx.get("gdp_per_capita"):
pc = market_ctx["gdp_per_capita"]
level = "高收入市场(消费力强,支撑中高端产品溢价)" if pc > 30000 else (
"中等收入市场(性价比敏感)" if pc > 10000 else "发展中市场(价格驱动)")
_p(f"• 需求判断:人均 GDP {pc:,.0f} 美元 → {level}。", indent=False)
if market_ctx.get("population"):
_p(f"• 人口 {market_ctx['population'] / 1e8:.2f} 亿:人口规模决定市场容量上限。", indent=False)
if background and background.get("global_trade_growth"):
_p(f"• 宏观背景:全球贸易增长预测 {background['global_trade_growth']}({background.get('_source', 'WTO')})。", indent=False)
else:
_p("(目标市场经济数据不足)")
# ===== 七、驱动因素分析(CPI/科技出口 + 需求/供给/竞争三侧)=====
_h("七、驱动因素分析", 1, blank_before=True)
_p("驱动出口与销量变化的因素可分为需求侧、供给侧、竞争侧:")
# 需求侧:CPI 通胀趋势(World Bank 免费 API)→ 趋势图
if market_ctx and market_ctx.get("available") and market_ctx.get("iso3"):
iso3 = market_ctx["iso3"]
cpi_series = {}
try:
from market_data import get_worldbank_series
years5 = list(range(get_latest_year() - 4, get_latest_year() + 1))
cpi_series = get_worldbank_series(iso3, "cpi", years5)
except Exception:
logging.warning("CPI 通胀数据获取失败(报告缺需求侧章节):%s", iso3, exc_info=True)
if cpi_series:
_h("需求侧:通胀与消费环境", 2)
_p(f"{target} 通胀率(CPI 年变化 %,World Bank 官方数据)近 5 年趋势:")
_add_line_chart(doc, cpi_series, "CPI 通胀率变化", "%")
sorted_cpi = sorted(cpi_series.items())
latest_cpi = sorted_cpi[-1][1] if sorted_cpi else None
if latest_cpi is not None:
level = "低通胀(消费环境稳定,利于可选消费支出)" if latest_cpi < 3 else (
"温和通胀(消费略有压力)" if latest_cpi < 5 else "高通胀(消费承压,可选消费萎缩)")
_p(f"• 最新通胀 {latest_cpi:.1f}%:{level}。", indent=False)
if len(sorted_cpi) >= 2:
first_cpi = sorted_cpi[0][1]
last_cpi = sorted_cpi[-1][1]
if first_cpi and first_cpi > 3 and last_cpi < first_cpi:
_p(f"• 通胀从 {first_cpi:.1f}% 回落至 {last_cpi:.1f}%:购买力修复,"
f"对消费电子产品需求是利好信号。", indent=False)
# 供给侧:高科技出口占比(出口能力结构)
if market_ctx and market_ctx.get("high_tech_exports") is not None:
_h("供给侧:出口能力结构", 2)
_p(f"• {target} 高科技出口占制成品出口 {market_ctx['high_tech_exports']:.1f}%"
f"(World Bank)——该市场自身科技产业基础,决定对进口消费电子的依赖度。", indent=False)
if market_ctx and market_ctx.get("mobile") is not None:
_p(f"• 每百人手机订阅 {market_ctx['mobile']:.0f} 部:移动设备渗透率支撑智能硬件需求。", indent=False)
# 驱动因素数据表(因素 / 数据 / 影响)
factor_rows = [("驱动因素", "数据", "影响方向")]
if market_ctx and market_ctx.get("gdp_per_capita"):
factor_rows.append(("人均 GDP(消费力)", f"{market_ctx['gdp_per_capita']:,.0f} 美元", "高收入市场支撑中高端溢价"))
if market_ctx and market_ctx.get("cpi") is not None:
factor_rows.append(("通胀率 CPI", f"{market_ctx['cpi']:.1f}%", "低通胀利于可选消费支出"))
if market_ctx and market_ctx.get("high_tech_exports") is not None:
factor_rows.append(("高科技出口占比", f"{market_ctx['high_tech_exports']:.1f}%", "科技产业基础 → 进口依赖度"))
if competitiveness and competitiveness.get("tc") is not None:
factor_rows.append(("贸易竞争力 TC", f"{competitiveness['tc']}",
"强则出口主导,弱则进口依赖"))
if competitiveness and competitiveness.get("market_share") is not None:
factor_rows.append(("占市场进口份额", _pct(competitiveness['market_share']), "现有渗透率 = 增长基数"))
if stats and stats.get("cagr_pct") is not None:
factor_rows.append(("出口 CAGR", _pct(stats['cagr_pct']), "出口动能方向"))
if landscape and landscape.get("top_brands"):
factor_rows.append(("龙头品牌份额", f"{landscape['top_brands'][0].get('share', '')}", "市场集中度决定进入难度"))
if len(factor_rows) > 1:
ftbl = doc.add_table(rows=len(factor_rows), cols=3)
ftbl.style = "Light Grid Accent 1"
for i, (a, b, c) in enumerate(factor_rows):
ftbl.rows[i].cells[0].text = a
ftbl.rows[i].cells[1].text = b
ftbl.rows[i].cells[2].text = c
if i == 0:
for cell in ftbl.rows[0].cells:
if cell.paragraphs[0].runs:
cell.paragraphs[0].runs[0].bold = True
_p()
_p("上表数据来源:UN Comtrade / World Bank / 行业检索。")
# ===== 八、原始数据表 =====
_h("八、原始数据(UN Comtrade)", 1, blank_before=True)
raw_tbl = doc.add_table(rows=1 + len(rows), cols=5)
raw_tbl.style = "Light Grid Accent 1"
for j, head in enumerate(["年份", "流向", "HS编码", "金额(美元)", "净重(公斤)"]):
raw_tbl.rows[0].cells[j].text = head
for i, r in enumerate(rows, 1):
raw_tbl.rows[i].cells[0].text = str(r.get("refYear"))
raw_tbl.rows[i].cells[1].text = "出口"
raw_tbl.rows[i].cells[2].text = str(r.get("cmdCode"))
raw_tbl.rows[i].cells[3].text = f"{float(r.get('primaryValue') or 0):,.0f}"
raw_tbl.rows[i].cells[4].text = f"{float(r.get('netWgt') or 0):,.0f}"
# ===== 九、AI 市场分析 =====
_h("九、AI 市场分析", 1, blank_before=True)
ms = ai.get("market_size") or {}
gt = ai.get("growth_trend") or {}
risks = ai.get("risks") or []
up = ai.get("user_profile") or {}
_h("市场规模", 2)
# 防重复:AI 的 value 常自带"(2026年估算)",再追加会双份(渲染 bug 修复)
ms_value = str(ms.get("value", "未知"))
ms_year = str(ms.get("year", ""))
ms_suffix = f"({ms_year}年估算)" if ms_year and "估算" not in ms_value else ""
_p(f"{ms_value}{ms_suffix}")
_h("增长趋势", 2)
_p(f"CAGR {gt.get('cagr', '未知')},{gt.get('forecast_years', '')}")
if gt.get("description"):
_p(gt["description"])
_h("用户画像", 2)
_p(f"年龄区间: {up.get('age_range', '')} | 收入水平: {up.get('income_level', '')}")
_h("风险分析", 2)
for r in risks:
if isinstance(r, dict):
_p(f"• {r.get('type')}({r.get('level')}): {r.get('description')}", indent=False)
_h("AI 总结", 2)
_p(ai.get("summary", ""))
# ===== 附录:数据来源 =====
_h("附录:数据来源与说明", 1, blank_before=True)
src_rows = [("数据维度", "来源", "说明")]
src_rows.append(("出口贸易数据", "UN Comtrade 联合国商品贸易统计数据库", "HS 编码口径,公共 API 实时查询"))
src_rows.append(("统计指标", "程序精确计算", "CAGR / 区间变化 / 最大波动 / 单价趋势"))
stbl = doc.add_table(rows=len(src_rows), cols=3)
stbl.style = "Light Grid Accent 1"