Cai
2026-08-02 59bc552091d44a71f312fd274c19d5b98bd667de
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
from __future__ import annotations
 
from pathlib import Path
from math import pow
 
from docx import Document
from docx.enum.section import WD_SECTION
from docx.enum.table import WD_ALIGN_VERTICAL, WD_CELL_VERTICAL_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.style import WD_STYLE_TYPE
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Inches, Pt, RGBColor
 
 
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "股票价格合理性评估操作手册_v1.0.docx"
 
# compact_reference_guide preset + named Chinese readability override.
PAGE_W_DXA = 12240
PAGE_H_DXA = 15840
CONTENT_W_DXA = 9360
TABLE_INDENT_DXA = 120
FONT_LATIN = "Calibri"
FONT_EAST_ASIA = "Microsoft YaHei"
 
BLUE = "2E74B5"
DARK_BLUE = "1F4D78"
INK = "0B2545"
MUTED = "5F6B7A"
LIGHT_BLUE = "E8EEF5"
LIGHT_GRAY = "F2F4F7"
CALLOUT = "F4F6F9"
PALE_GOLD = "FFF6D8"
GOLD = "7A5A00"
PALE_RED = "FDECEC"
RED = "9B1C1C"
PALE_GREEN = "EAF4EC"
GREEN = "245B36"
WHITE = "FFFFFF"
BLACK = "000000"
 
 
def set_run_font(run, size=None, bold=None, italic=None, color=None, latin=FONT_LATIN, east_asia=FONT_EAST_ASIA):
    run.font.name = latin
    if run._element.rPr is None:
        run._element.get_or_add_rPr()
    rfonts = run._element.rPr.rFonts
    if rfonts is None:
        rfonts = OxmlElement("w:rFonts")
        run._element.rPr.insert(0, rfonts)
    rfonts.set(qn("w:ascii"), latin)
    rfonts.set(qn("w:hAnsi"), latin)
    rfonts.set(qn("w:eastAsia"), east_asia)
    if size is not None:
        run.font.size = Pt(size)
    if bold is not None:
        run.bold = bold
    if italic is not None:
        run.italic = italic
    if color is not None:
        run.font.color.rgb = RGBColor.from_string(color)
 
 
def set_style_font(style, size, color=BLACK, bold=False):
    style.font.name = FONT_LATIN
    style.font.size = Pt(size)
    style.font.bold = bold
    style.font.color.rgb = RGBColor.from_string(color)
    rpr = style.element.get_or_add_rPr()
    rfonts = rpr.rFonts
    if rfonts is None:
        rfonts = OxmlElement("w:rFonts")
        rpr.insert(0, rfonts)
    rfonts.set(qn("w:ascii"), FONT_LATIN)
    rfonts.set(qn("w:hAnsi"), FONT_LATIN)
    rfonts.set(qn("w:eastAsia"), FONT_EAST_ASIA)
 
 
def set_cell_shading(cell, fill):
    tc_pr = cell._tc.get_or_add_tcPr()
    shd = tc_pr.find(qn("w:shd"))
    if shd is None:
        shd = OxmlElement("w:shd")
        tc_pr.append(shd)
    shd.set(qn("w:fill"), fill)
    shd.set(qn("w:val"), "clear")
 
 
def set_cell_margins(cell, top=80, start=120, bottom=80, end=120):
    tc_pr = cell._tc.get_or_add_tcPr()
    tc_mar = tc_pr.first_child_found_in("w:tcMar")
    if tc_mar is None:
        tc_mar = OxmlElement("w:tcMar")
        tc_pr.append(tc_mar)
    for tag, value in (("top", top), ("start", start), ("bottom", bottom), ("end", end)):
        node = tc_mar.find(qn(f"w:{tag}"))
        if node is None:
            node = OxmlElement(f"w:{tag}")
            tc_mar.append(node)
        node.set(qn("w:w"), str(value))
        node.set(qn("w:type"), "dxa")
 
 
def set_repeat_table_header(row):
    tr_pr = row._tr.get_or_add_trPr()
    tbl_header = tr_pr.find(qn("w:tblHeader"))
    if tbl_header is None:
        tbl_header = OxmlElement("w:tblHeader")
        tr_pr.append(tbl_header)
    tbl_header.set(qn("w:val"), "true")
 
 
def set_table_geometry(table, widths_dxa, indent_dxa=TABLE_INDENT_DXA):
    assert sum(widths_dxa) == CONTENT_W_DXA, (widths_dxa, sum(widths_dxa))
    table.autofit = False
    tbl_pr = table._tbl.tblPr
    tbl_w = tbl_pr.find(qn("w:tblW"))
    if tbl_w is None:
        tbl_w = OxmlElement("w:tblW")
        tbl_pr.append(tbl_w)
    tbl_w.set(qn("w:w"), str(CONTENT_W_DXA))
    tbl_w.set(qn("w:type"), "dxa")
    tbl_ind = tbl_pr.find(qn("w:tblInd"))
    if tbl_ind is None:
        tbl_ind = OxmlElement("w:tblInd")
        tbl_pr.append(tbl_ind)
    tbl_ind.set(qn("w:w"), str(indent_dxa))
    tbl_ind.set(qn("w:type"), "dxa")
    layout = tbl_pr.find(qn("w:tblLayout"))
    if layout is None:
        layout = OxmlElement("w:tblLayout")
        tbl_pr.append(layout)
    layout.set(qn("w:type"), "fixed")
 
    grid = table._tbl.tblGrid
    for child in list(grid):
        grid.remove(child)
    for width in widths_dxa:
        col = OxmlElement("w:gridCol")
        col.set(qn("w:w"), str(width))
        grid.append(col)
    for row in table.rows:
        for idx, cell in enumerate(row.cells):
            tc_pr = cell._tc.get_or_add_tcPr()
            tc_w = tc_pr.find(qn("w:tcW"))
            if tc_w is None:
                tc_w = OxmlElement("w:tcW")
                tc_pr.append(tc_w)
            tc_w.set(qn("w:w"), str(widths_dxa[idx]))
            tc_w.set(qn("w:type"), "dxa")
            cell.width = Inches(widths_dxa[idx] / 1440)
            set_cell_margins(cell)
            cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
 
 
def set_cell_borders(cell, color="D9DEE5", size="4"):
    tc_pr = cell._tc.get_or_add_tcPr()
    borders = tc_pr.find(qn("w:tcBorders"))
    if borders is None:
        borders = OxmlElement("w:tcBorders")
        tc_pr.append(borders)
    for edge in ("top", "start", "bottom", "end", "insideH", "insideV"):
        node = borders.find(qn(f"w:{edge}"))
        if node is None:
            node = OxmlElement(f"w:{edge}")
            borders.append(node)
        node.set(qn("w:val"), "single")
        node.set(qn("w:sz"), size)
        node.set(qn("w:color"), color)
 
 
def add_table(doc, headers, rows, widths_dxa, header_fill=LIGHT_BLUE, font_size=9.2,
              aligns=None, first_col_bold=False):
    table = doc.add_table(rows=1, cols=len(headers))
    table.style = "Table Grid"
    set_table_geometry(table, widths_dxa)
    header = table.rows[0]
    set_repeat_table_header(header)
    for idx, text in enumerate(headers):
        cell = header.cells[idx]
        cell.text = str(text)
        set_cell_shading(cell, header_fill)
        set_cell_borders(cell)
        for p in cell.paragraphs:
            p.paragraph_format.space_before = Pt(0)
            p.paragraph_format.space_after = Pt(0)
            p.paragraph_format.line_spacing = 1.12
            p.alignment = aligns[idx] if aligns else WD_ALIGN_PARAGRAPH.CENTER
            for run in p.runs:
                set_run_font(run, size=font_size, bold=True, color=INK)
    for r_index, row_data in enumerate(rows):
        cells = table.add_row().cells
        for idx, value in enumerate(row_data):
            cells[idx].text = "" if value is None else str(value)
            if r_index % 2 == 1:
                set_cell_shading(cells[idx], "FAFBFC")
            set_cell_borders(cells[idx])
            for p in cells[idx].paragraphs:
                p.paragraph_format.space_before = Pt(0)
                p.paragraph_format.space_after = Pt(0)
                p.paragraph_format.line_spacing = 1.15
                p.alignment = aligns[idx] if aligns else (WD_ALIGN_PARAGRAPH.LEFT if idx else WD_ALIGN_PARAGRAPH.CENTER)
                for run in p.runs:
                    set_run_font(run, size=font_size, bold=(first_col_bold and idx == 0), color=BLACK)
    doc.add_paragraph().paragraph_format.space_after = Pt(0)
    return table
 
 
def shade_paragraph(paragraph, fill=CALLOUT, border_color="D8E0E8"):
    p_pr = paragraph._p.get_or_add_pPr()
    shd = p_pr.find(qn("w:shd"))
    if shd is None:
        shd = OxmlElement("w:shd")
        p_pr.append(shd)
    shd.set(qn("w:fill"), fill)
    borders = p_pr.find(qn("w:pBdr"))
    if borders is None:
        borders = OxmlElement("w:pBdr")
        p_pr.append(borders)
    left = borders.find(qn("w:left"))
    if left is None:
        left = OxmlElement("w:left")
        borders.append(left)
    left.set(qn("w:val"), "single")
    left.set(qn("w:sz"), "18")
    left.set(qn("w:space"), "8")
    left.set(qn("w:color"), border_color)
 
 
def add_callout(doc, label, text, kind="info"):
    colors = {
        "info": (CALLOUT, BLUE, INK),
        "caution": (PALE_GOLD, GOLD, GOLD),
        "risk": (PALE_RED, RED, RED),
        "positive": (PALE_GREEN, GREEN, GREEN),
    }
    fill, border, text_color = colors[kind]
    p = doc.add_paragraph()
    p.paragraph_format.left_indent = Inches(0.08)
    p.paragraph_format.right_indent = Inches(0.03)
    p.paragraph_format.space_before = Pt(4)
    p.paragraph_format.space_after = Pt(8)
    p.paragraph_format.line_spacing = 1.2
    shade_paragraph(p, fill, border)
    r = p.add_run(f"{label}  ")
    set_run_font(r, size=10.5, bold=True, color=text_color)
    r = p.add_run(text)
    set_run_font(r, size=10.5, color=BLACK)
    return p
 
 
def add_formula(doc, *lines):
    p = doc.add_paragraph()
    p.style = doc.styles["Formula"]
    shade_paragraph(p, "F7F9FB", "AAB7C4")
    for idx, line in enumerate(lines):
        if idx:
            p.add_run("\n")
        r = p.add_run(line)
        set_run_font(r, size=10.2, bold=(idx == 0), color=INK, latin="Consolas", east_asia=FONT_EAST_ASIA)
    return p
 
 
def add_body(doc, text, bold_lead=None):
    p = doc.add_paragraph()
    if bold_lead and text.startswith(bold_lead):
        r = p.add_run(bold_lead)
        set_run_font(r, size=11, bold=True, color=INK)
        r = p.add_run(text[len(bold_lead):])
        set_run_font(r, size=11, color=BLACK)
    else:
        r = p.add_run(text)
        set_run_font(r, size=11, color=BLACK)
    return p
 
 
def add_bullet_list(doc, items, level=0):
    num_id = create_numbering(doc, kind="bullet")
    for text in items:
        p = doc.add_paragraph()
        apply_num(p, num_id, level)
        r = p.add_run(text)
        set_run_font(r, size=11, color=BLACK)
 
 
def add_numbered_list(doc, items):
    num_id = create_numbering(doc, kind="decimal")
    for text in items:
        p = doc.add_paragraph()
        apply_num(p, num_id, 0)
        r = p.add_run(text)
        set_run_font(r, size=11, color=BLACK)
 
 
def create_numbering(doc, kind="bullet"):
    numbering = doc.part.numbering_part.element
    abstract_ids = [int(x.get(qn("w:abstractNumId"))) for x in numbering.findall(qn("w:abstractNum"))]
    num_ids = [int(x.get(qn("w:numId"))) for x in numbering.findall(qn("w:num"))]
    abstract_id = (max(abstract_ids) + 1) if abstract_ids else 1
    num_id = (max(num_ids) + 1) if num_ids else 1
    abstract = OxmlElement("w:abstractNum")
    abstract.set(qn("w:abstractNumId"), str(abstract_id))
    nsid = OxmlElement("w:nsid")
    nsid.set(qn("w:val"), f"{abstract_id:08X}")
    abstract.append(nsid)
    multi = OxmlElement("w:multiLevelType")
    multi.set(qn("w:val"), "singleLevel")
    abstract.append(multi)
    lvl = OxmlElement("w:lvl")
    lvl.set(qn("w:ilvl"), "0")
    start = OxmlElement("w:start")
    start.set(qn("w:val"), "1")
    lvl.append(start)
    num_fmt = OxmlElement("w:numFmt")
    num_fmt.set(qn("w:val"), "bullet" if kind == "bullet" else "decimal")
    lvl.append(num_fmt)
    lvl_text = OxmlElement("w:lvlText")
    lvl_text.set(qn("w:val"), "•" if kind == "bullet" else "%1.")
    lvl.append(lvl_text)
    suff = OxmlElement("w:suff")
    suff.set(qn("w:val"), "tab")
    lvl.append(suff)
    p_pr = OxmlElement("w:pPr")
    tabs = OxmlElement("w:tabs")
    tab = OxmlElement("w:tab")
    tab.set(qn("w:val"), "num")
    tab.set(qn("w:pos"), "540")
    tabs.append(tab)
    p_pr.append(tabs)
    ind = OxmlElement("w:ind")
    ind.set(qn("w:left"), "540")
    ind.set(qn("w:hanging"), "270")
    p_pr.append(ind)
    spacing = OxmlElement("w:spacing")
    spacing.set(qn("w:after"), "80")
    spacing.set(qn("w:line"), "300")
    spacing.set(qn("w:lineRule"), "auto")
    p_pr.append(spacing)
    lvl.append(p_pr)
    if kind == "bullet":
        r_pr = OxmlElement("w:rPr")
        rfonts = OxmlElement("w:rFonts")
        rfonts.set(qn("w:ascii"), "Arial")
        rfonts.set(qn("w:hAnsi"), "Arial")
        r_pr.append(rfonts)
        lvl.append(r_pr)
    abstract.append(lvl)
    numbering.append(abstract)
    num = OxmlElement("w:num")
    num.set(qn("w:numId"), str(num_id))
    abs_id = OxmlElement("w:abstractNumId")
    abs_id.set(qn("w:val"), str(abstract_id))
    num.append(abs_id)
    numbering.append(num)
    return num_id
 
 
def apply_num(paragraph, num_id, level):
    p_pr = paragraph._p.get_or_add_pPr()
    num_pr = p_pr.find(qn("w:numPr"))
    if num_pr is None:
        num_pr = OxmlElement("w:numPr")
        p_pr.append(num_pr)
    ilvl = OxmlElement("w:ilvl")
    ilvl.set(qn("w:val"), str(level))
    num_id_el = OxmlElement("w:numId")
    num_id_el.set(qn("w:val"), str(num_id))
    num_pr.append(ilvl)
    num_pr.append(num_id_el)
 
 
def add_hyperlink(paragraph, text, url):
    part = paragraph.part
    r_id = part.relate_to(url, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", is_external=True)
    hyperlink = OxmlElement("w:hyperlink")
    hyperlink.set(qn("r:id"), r_id)
    new_run = OxmlElement("w:r")
    r_pr = OxmlElement("w:rPr")
    r_style = OxmlElement("w:rStyle")
    r_style.set(qn("w:val"), "Hyperlink")
    r_pr.append(r_style)
    r_fonts = OxmlElement("w:rFonts")
    r_fonts.set(qn("w:ascii"), FONT_LATIN)
    r_fonts.set(qn("w:hAnsi"), FONT_LATIN)
    r_fonts.set(qn("w:eastAsia"), FONT_EAST_ASIA)
    r_pr.append(r_fonts)
    new_run.append(r_pr)
    t = OxmlElement("w:t")
    t.text = text
    new_run.append(t)
    hyperlink.append(new_run)
    paragraph._p.append(hyperlink)
    return hyperlink
 
 
def add_page_field(paragraph):
    r = paragraph.add_run("第 ")
    set_run_font(r, size=9, color=MUTED)
    run = paragraph.add_run()
    fld_char1 = OxmlElement("w:fldChar")
    fld_char1.set(qn("w:fldCharType"), "begin")
    instr_text = OxmlElement("w:instrText")
    instr_text.set(qn("xml:space"), "preserve")
    instr_text.text = " PAGE "
    fld_char2 = OxmlElement("w:fldChar")
    fld_char2.set(qn("w:fldCharType"), "end")
    run._r.append(fld_char1)
    run._r.append(instr_text)
    run._r.append(fld_char2)
    set_run_font(run, size=9, color=MUTED)
    r = paragraph.add_run(" 页")
    set_run_font(r, size=9, color=MUTED)
 
 
def add_section_title(doc, text, level=1):
    p = doc.add_paragraph(style=f"Heading {level}")
    p.paragraph_format.keep_with_next = True
    r = p.add_run(text)
    set_run_font(r, size={1: 16, 2: 13, 3: 12}[level], bold=True, color={1: BLUE, 2: BLUE, 3: DARK_BLUE}[level])
    return p
 
 
def page_break(doc):
    doc.add_page_break()
 
 
def configure_document(doc):
    section = doc.sections[0]
    section.page_width = Inches(8.5)
    section.page_height = Inches(11)
    section.top_margin = Inches(1)
    section.right_margin = Inches(1)
    section.bottom_margin = Inches(1)
    section.left_margin = Inches(1)
    section.header_distance = Inches(0.492)
    section.footer_distance = Inches(0.492)
 
    normal = doc.styles["Normal"]
    set_style_font(normal, 11, BLACK, False)
    normal.paragraph_format.space_before = Pt(0)
    normal.paragraph_format.space_after = Pt(6)
    normal.paragraph_format.line_spacing = 1.25
 
    for name, size, color, before, after in (
        ("Heading 1", 16, BLUE, 18, 10),
        ("Heading 2", 13, BLUE, 14, 7),
        ("Heading 3", 12, DARK_BLUE, 10, 5),
    ):
        style = doc.styles[name]
        set_style_font(style, size, color, True)
        style.paragraph_format.space_before = Pt(before)
        style.paragraph_format.space_after = Pt(after)
        style.paragraph_format.keep_with_next = True
        style.paragraph_format.keep_together = True
 
    formula = doc.styles.add_style("Formula", WD_STYLE_TYPE.PARAGRAPH)
    set_style_font(formula, 10.2, INK, False)
    formula.paragraph_format.left_indent = Inches(0.12)
    formula.paragraph_format.right_indent = Inches(0.05)
    formula.paragraph_format.space_before = Pt(4)
    formula.paragraph_format.space_after = Pt(8)
    formula.paragraph_format.line_spacing = 1.2
 
    # Running header and footer: quiet furniture from compact_reference_guide.
    header = section.header
    hp = header.paragraphs[0]
    hp.alignment = WD_ALIGN_PARAGRAPH.LEFT
    hp.paragraph_format.space_after = Pt(0)
    r = hp.add_run("股票价格合理性评估操作手册")
    set_run_font(r, size=8.5, color=MUTED)
    footer = section.footer
    fp = footer.paragraphs[0]
    fp.alignment = WD_ALIGN_PARAGRAPH.RIGHT
    fp.paragraph_format.space_before = Pt(0)
    add_page_field(fp)
 
 
def build_document():
    doc = Document()
    configure_document(doc)
 
    # Cover - editorial_cover pattern.
    p = doc.add_paragraph()
    p.paragraph_format.space_before = Pt(78)
    p.paragraph_format.space_after = Pt(16)
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    r = p.add_run("股票估值 · 操作手册")
    set_run_font(r, size=10.5, bold=True, color=GOLD)
 
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    p.paragraph_format.space_after = Pt(10)
    r = p.add_run("如何判断一只股票的价格是否合理")
    set_run_font(r, size=29, bold=True, color=INK)
 
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    p.paragraph_format.space_after = Pt(22)
    r = p.add_run("从信息取数、利润还原、质量校验,到估值区间与情景收益率")
    set_run_font(r, size=14, color=DARK_BLUE)
 
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    p.paragraph_format.space_before = Pt(18)
    p.paragraph_format.space_after = Pt(96)
    r = p.add_run("一份可以直接照着执行、复算和更新的通用框架")
    set_run_font(r, size=10.5, italic=True, color=MUTED)
 
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    r = p.add_run("版本 1.0  |  2026-07-30")
    set_run_font(r, size=10.5, bold=True, color=INK)
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    p.paragraph_format.space_after = Pt(10)
    r = p.add_run("适用范围:A股为主,兼顾港股、美股;不针对特定证券")
    set_run_font(r, size=9.5, color=MUTED)
    add_callout(doc, "重要说明", "本文档用于研究与估值训练,不构成投资建议。估值结果是条件判断,不是价格保证。", "caution")
 
    page_break(doc)
    add_section_title(doc, "先看这一页:最终要算出什么", 1)
    add_body(doc, "判断价格是否合理,不是找一个精确目标价,而是同时回答四个问题:企业能持续赚多少钱、合理估值是多少、当前价格隐含了什么假设、未来收益和下行风险是否匹配。")
    add_table(
        doc,
        ["最终输出", "计算结果", "用途"],
        [
            ["正常化利润", "历史事实经一次性、周期性和会计口径调整后的可持续利润", "决定估值的利润底座"],
            ["合理价值区间", "悲观 / 基准 / 乐观价值,不用单点目标价", "判断当前价格落在哪个区间"],
            ["预期年化收益率", "基于未来利润、退出估值与分红计算 IRR", "与最低要求回报比较"],
            ["反向估值假设", "当前价格隐含的收入增长、利润率、ROIC 或持续年限", "判断市场预期是否现实"],
            ["悲观损失", "悲观价值相对当前价格的跌幅", "确定风险是否可承受"],
        ],
        [1800, 4320, 3240],
        aligns=[WD_ALIGN_PARAGRAPH.CENTER, WD_ALIGN_PARAGRAPH.LEFT, WD_ALIGN_PARAGRAPH.LEFT],
        first_col_bold=True,
    )
    add_callout(doc, "核心判定", "只有当基准回报达标、当前价格位于合理区间、隐含假设可实现、悲观损失可承受时,才称为“价格合理”。合理不等于一定值得买;买入通常还需要安全边际。", "positive")
 
    add_section_title(doc, "30分钟快速路径", 2)
    add_numbered_list(doc, [
        "锁定估值日期、当前股价和完全稀释后股数,计算总市值。",
        "从最新年报、季报和业绩预告得到归母净利润、扣非归母净利润和未来利润范围。",
        "剔除一次性损益,检查现金流、负债和周期位置,得到正常化利润。",
        "按行业选一个主估值模型,再选一个交叉验证模型。",
        "计算悲观、基准、乐观三种情景的价值和5年预期收益率。",
        "做反向估值,写清楚当前价格要求公司未来做到什么。",
        "按本手册的判定矩阵得出:低估、合理、偏贵或暂时无法判断。",
    ])
 
    page_break(doc)
    add_section_title(doc, "第一部分  建立统一口径", 1)
    add_section_title(doc, "1.1 先确定估值任务", 2)
    add_body(doc, "在取数前先写下四个约束,否则同一家公司会被算出互相冲突的结果。")
    add_table(doc, ["项目", "必须填写", "示例"], [
        ["估值基准日", "股价、资产负债和已知公告截至哪一天", "2026-07-30收盘"],
        ["持有期限", "用多长时间检验价值兑现", "5年"],
        ["最低要求回报 R", "投资者对该风险要求的年化收益", "示例使用10%,须自行替换"],
        ["风险预算", "悲观情景可接受的永久亏损", "不超过20%"],
        ["估值币种", "股价、现金流、折现率必须使用一致币种", "人民币名义口径"],
    ], [1800, 4440, 3120], first_col_bold=True)
 
    add_section_title(doc, "1.2 股权价值、企业价值和每股价值", 2)
    add_formula(doc,
                "总市值 = 当前股价 × 完全稀释后总股数",
                "企业价值 EV = 总市值 + 有息负债 + 优先股 + 少数股东权益 - 可支配现金 - 非经营性金融资产",
                "每股价值 = 普通股股权价值 ÷ 完全稀释后总股数")
    add_bullet_list(doc, [
        "不要用流通市值代替总市值。限售股同样属于股权价值。",
        "完全稀释股数应考虑已授予期权、限制性股票、可转债和潜在增发。",
        "受限资金、客户备付金或维持经营必需的现金,不应全部当作可加回现金。",
        "使用 EV/EBITDA、EV/EBIT 或 FCFF-DCF 时必须先算企业价值;使用 P/E、P/B、FCFE 时直接对应股权价值。",
    ])
    add_callout(doc, "常见错误", "把企业价值除以股数直接当每股价值,会漏减债务和少数股东权益,通常高估股票价值。", "risk")
 
    add_section_title(doc, "1.3 最低要求回报怎么定", 2)
    add_formula(doc,
                "要求回报 R = 同币种长期无风险利率 + 股票风险溢价 + 公司特有风险调整",
                "公司特有风险调整考虑:规模、杠杆、周期、治理、流动性、国家与汇率风险")
    add_body(doc, "如果没有能力估计各项风险溢价,可以制定固定投资政策,例如低风险、一般风险、高风险分别用不同的要求回报,并对折现率上下浮动2个百分点做敏感性分析。本文示例用10%,只用于演示计算。")
 
    add_section_title(doc, "第二部分  信息从哪里获取,以及谁优先", 1)
    add_section_title(doc, "2.1 可靠性和时效性必须分开排序", 2)
    add_body(doc, "审计年报可靠性高,但可能已经过时;业绩预告更新,却只是区间估计。正确做法是:历史事实以正式披露为准,未来判断用最新信息更新,并保留每个数据的日期、期间和口径。")
    add_table(doc, ["等级", "信息类型", "主要用途", "处理规则"], [
        ["A1", "交易所披露的审计年报及审计意见", "历史事实、会计政策、附注、分部", "同期间最高优先级;检查非标审计意见"],
        ["A2", "正式半年报、季报、财务报表", "最近已实现业绩和资产负债", "优先于快报、预告和机构预测"],
        ["A3", "业绩快报、预告及修正公告", "尚未出正式财报的近端业绩", "快报通常高于预告;修正公告覆盖原公告"],
        ["A4", "经营数据、重大合同、产销、价格和资本运作公告", "预测销量、价格、产能和股数", "只使用已生效或高概率事项,避免把合同额当收入"],
        ["B1", "业绩说明会、投资者关系活动记录、公司官网", "解释经营驱动和管理层指引", "只能补充,不能替代法定披露"],
        ["B2", "政府、监管机构、海关、行业协会原始数据", "行业需求、价格、渗透率、宏观变量", "优先于二手媒体转述"],
        ["C1", "机构一致预期、数据库聚合", "市场基准和预测分歧", "用最新中位数;核对更新时间和覆盖机构数"],
        ["C2", "单篇券商报告", "详细业务拆分和预测逻辑", "复算关键假设;不把目标价当证据"],
        ["D", "媒体、自媒体、论坛、所谓渠道消息", "发现线索", "不直接进入估值;必须回到原始来源验证"],
    ], [900, 2520, 2520, 3420], font_size=8.8, first_col_bold=True)
 
    add_section_title(doc, "2.2 同一数据发生冲突时的规则", 2)
    add_numbered_list(doc, [
        "先确认是否属于同一报告期、同一会计口径和同一币种。",
        "同期间优先使用公司在交易所披露的最新正式文件;修正公告覆盖原公告。",
        "原始文件优先于数据库摘录,数据库只用于检索和批量整理。",
        "实际数优先于预测数;预测不可与实际重复相加。",
        "归母、扣非归母、净利润、EBITDA 和自由现金流不可互换。",
        "不能消除的冲突保留为范围,并降低估值置信度。",
    ])
 
    add_section_title(doc, "2.3 主要入口", 2)
    source_rows = [
        ["A股公告", "巨潮资讯、上交所、深交所、北交所", "年报、季报、快报、预告、问询回复、投资者关系记录"],
        ["港股公告", "披露易 HKEXnews", "年报、中报、公告、股本变化"],
        ["美股公告", "SEC EDGAR", "10-K、10-Q、8-K、20-F、6-K"],
        ["行业与宏观", "国家统计局、海关总署、央行、行业监管及协会", "销量、价格、产量、利率、进出口"],
        ["机构预期", "Wind、Choice、iFinD、Bloomberg、FactSet、LSEG等", "一致预期、历史预测、估值可比"],
        ["市场价格", "交易所、公司公告、可信行情终端", "基准日股价、总股本、复权与公司行动"],
    ]
    add_table(doc, ["市场/用途", "入口", "获取内容"], source_rows, [1440, 3000, 4920], first_col_bold=True)
 
    add_callout(doc, "留痕要求", "每个关键输入至少记录:数值、单位、报告期、发布日期、来源文件、页码或章节、实际/预测标记、是否经审计、调整说明。", "info")
 
    add_section_title(doc, "2.4 最小数据包", 2)
    add_bullet_list(doc, [
        "最近5个完整年度的利润表、资产负债表、现金流量表及分部数据。",
        "最近8个季度的收入、归母利润、扣非归母、经营现金流及关键经营指标。",
        "估值日的完全稀释股数、现金、有息负债、少数股东权益和潜在融资。",
        "未来2至3年的公司指引、机构一致预期及支撑预测的行业量价数据。",
    ])
 
    add_section_title(doc, "第三部分  算清利润", 1)
    add_section_title(doc, "3.1 财报利润链条", 2)
    add_formula(doc,
                "营业收入 = 销量 × 售价(或客户数 × 单客收入)",
                "毛利润 = 营业收入 - 营业成本",
                "核心经营利润 ≈ 毛利润 - 销售费用 - 管理费用 - 研发费用 - 正常财务费用",
                "归母净利润 = 净利润 - 少数股东损益")
    add_body(doc, "直接从利润表取得营业收入、营业利润、利润总额、净利润和归母净利润;再从附注、管理层讨论与分析和分部信息中找到各业务的收入、成本、毛利率及同比变化原因。")
 
    add_section_title(doc, "3.2 估值要保留的四个利润", 2)
    add_table(doc, ["利润口径", "计算方法", "作用"], [
        ["报表归母利润", "利润表或主要财务指标中的归母净利润", "与市场常见P/E对齐"],
        ["扣非归母利润", "公司披露的扣除非经常性损益后归母净利润", "正常化利润的起点"],
        ["正常化利润", "扣非归母 + 应加回的真正一次性损失 - 未扣掉的不可持续收益 ± 周期调整", "估计持续盈利能力"],
        ["未来12个月利润 NTM", "未来连续四个季度预计归母或正常化利润之和", "计算前瞻P/E和预期收益"],
    ], [1800, 4860, 2700], first_col_bold=True)
 
    add_section_title(doc, "3.3 TTM和单季度利润", 2)
    add_formula(doc,
                "TTM利润 = 最近完整年度利润 + 本年累计利润 - 上年同期累计利润",
                "单季度利润 = 本期累计利润 - 上一期累计利润",
                "预告隐含季度利润 = 本期累计预告区间 - 已披露季度实际利润")
    add_body(doc, "例:第一季度归母净利润3亿元,半年度预告8至10亿元,则第二季度隐含利润为5至7亿元。基准情景可先用中值6亿元,悲观情景接近5亿元,并检查预告是否包含一次性项目。")
 
    add_section_title(doc, "3.4 正常化利润调整", 2)
    add_table(doc, ["调整项目", "常见处理", "证据位置"], [
        ["资产出售、债务重组、诉讼赔偿", "通常剔除;若商业模式本来依赖资产交易则单独判断", "非经常性损益明细、附注"],
        ["政府补助和税收优惠", "判断与主营业务相关性、标准是否明确、能否持续", "其他收益、政府补助、所得税附注"],
        ["公允价值和投资收益", "经营性金融业务可保留;非主营投资单独估值", "投资收益、公允价值附注"],
        ["资产减值", "真正一次性且资产经济价值仍在时才考虑加回;重复减值属于经营成本", "减值明细、资产附注"],
        ["股权激励", "属于股东承担的经济成本,不宜简单加回;同时考虑稀释", "股份支付、股本变动"],
        ["周期性利润", "不用峰值利润;按中周期价格、产能利用率和成本计算", "3至10年历史、行业价格"],
        ["汇兑损益", "经常发生且与经营/负债结构相关时保留正常水平", "财务费用附注"],
        ["少数股东损益", "必须扣除不属于上市公司普通股股东的部分", "利润表、子公司权益结构"],
    ], [1740, 4620, 3000], font_size=8.8, first_col_bold=True)
 
    add_formula(doc,
                "正常化归母利润 = 扣非归母利润 + 合理的一次性损失加回 - 未被扣除的不可持续收益 ± 周期和会计口径调整")
    add_callout(doc, "审慎原则", "不能证明某项损失真正一次性,就不要加回;不能证明某项收益可持续,就不要把它资本化为高估值。", "caution")
 
    add_section_title(doc, "第四部分  追踪利润来源", 1)
    add_section_title(doc, "4.1 用利润桥解释变化", 2)
    add_body(doc, "利润增长只有被拆成业务驱动后才可用于预测。建议把本期相对上期的利润变化拆成以下七项。")
    add_formula(doc,
                "利润变化 = 销量贡献 + 售价贡献 + 产品/客户结构贡献 + 单位成本贡献",
                "             + 期间费用贡献 + 财务与税率贡献 + 一次性项目贡献")
    add_table(doc, ["驱动", "计算思路", "优先信息源"], [
        ["销量", "销量变化 × 上期单位毛利", "公司产销公告、分部数据、行业销量"],
        ["售价", "售价变化 × 本期销量", "公司披露、产品价格、行业价格"],
        ["产品结构", "高毛利产品占比变化造成的毛利变化", "分产品收入和毛利率"],
        ["单位成本", "原材料、人工、折旧和制造费用的单位变化", "成本附注、原材料价格、产能利用率"],
        ["期间费用", "各费用率变化 × 收入", "利润表、费用附注"],
        ["融资与税率", "净利息、汇兑、实际税率变化", "财务费用、所得税附注"],
        ["一次性项目", "非经常性损益的税后归母影响", "非经常性损益明细"],
    ], [1380, 4260, 3720], first_col_bold=True)
 
    add_section_title(doc, "4.2 未来利润怎么预测", 2)
    add_formula(doc,
                "未来收入 = 市场规模 × 市占率;或 销量 × 单价;或 客户数 × 单客收入",
                "未来毛利 = 未来收入 × 预计毛利率",
                "未来归母利润 = 毛利 - 期间费用 ± 其他持续项目 - 税 - 少数股东损益")
    add_numbered_list(doc, [
        "先预测业务量,不从历史利润增速直接外推。",
        "将价格、产品结构、单位成本和费用率分别设定,不把所有变化塞进一个净利率。",
        "完整反映增长所需的资本开支、营运资金和股份稀释。",
        "把已发生的季度作为实际数,只预测剩余季度,确保不重复计算。",
        "机构预测使用最近更新的中位数,并记录预测分歧和上调/下调方向。",
    ])
 
    add_section_title(doc, "4.3 不同类型公司的利润底座", 2)
    add_table(doc, ["公司类型", "建议利润底座", "避免"], [
        ["成熟稳定企业", "TTM正常化利润 + NTM利润", "过度依赖遥远增长"],
        ["高增长盈利企业", "NTM利润和3至5年业务驱动预测", "直接把最近高增速永久外推"],
        ["强周期企业", "中周期商品价格、成本和产量下的利润", "用景气顶峰P/E判断便宜"],
        ["亏损成长企业", "收入、毛利、单位经济性和达到正现金流所需资金", "用负P/E或远期极端利润"],
        ["金融企业", "正常信用成本、ROE、资本充足率和可分配利润", "机械套用EV/EBITDA"],
    ], [1800, 4500, 3060], first_col_bold=True)
 
    add_section_title(doc, "第五部分  利润质量与生存能力", 1)
    add_section_title(doc, "5.1 现金流校验", 2)
    add_formula(doc,
                "经营现金含金量 = 3年累计经营现金流 ÷ 3年累计归母净利润",
                "简化自由现金流 = 经营现金流 - 资本开支",
                "FCFE = 经营现金流 - 资本开支 + 可持续净借款",
                "自由现金流收益率 = 正常化FCFE ÷ 总市值")
    add_body(doc, "现金含金量要看多年累计,避免单季营运资金波动。大于1通常说明利润现金兑现较好,但预收款、金融企业、地产和高速扩张企业需要结合商业模式解释。FCFE中的净借款只能使用与目标资本结构一致的可持续水平,不能用一次性举债美化现金流。")
 
    add_section_title(doc, "5.2 回报率与增长质量", 2)
    add_formula(doc,
                "NOPAT = EBIT × (1 - 正常税率)",
                "ROIC = NOPAT ÷ 平均投入资本",
                "投入资本 ≈ 平均(股东权益 + 有息负债 - 可支配现金)",
                "可持续增长 g ≈ 再投资率 × 新增资本回报率")
    add_body(doc, "增长只有在新增资本回报率长期高于资本成本时才创造价值。若收入增长依赖大量应收、存货、资本开支或并购,必须降低增长的价值权重。")
 
    add_section_title(doc, "5.3 负债与稀释", 2)
    add_formula(doc,
                "净负债 = 有息负债 - 可支配现金",
                "净负债/EBITDA = 净负债 ÷ 正常化EBITDA",
                "利息保障倍数 = EBIT ÷ 利息费用",
                "股数稀释率 = 完全稀释股数同比增长率")
    add_callout(doc, "硬门槛", "若公司未来12至24个月存在明显偿债、再融资或持续经营风险,普通相对估值可能失效。此时先做困境概率、债务到期和融资稀释分析,再谈便宜。", "risk")
 
    add_section_title(doc, "5.4 估值前排雷清单", 2)
    add_bullet_list(doc, [
        "经营现金流长期显著低于利润,应收、存货或合同资产异常增长。",
        "毛利率显著高于同行却缺乏业务解释,或收入与税费、现金不匹配。",
        "资本化研发、利息资本化或在建工程快速增长,美化当期利润。",
        "频繁并购、商誉过高、反复减值或依赖出售资产维持利润。",
        "关联交易、资金占用、担保、非标审计意见或管理层无法保证财报真实完整。",
        "股份支付、增发、可转债和员工期权导致持续稀释。",
        "周期顶部利润、高景气价格或异常低信用成本被当成永久水平。",
    ])
 
    add_section_title(doc, "第六部分  选择正确的估值模型", 1)
    add_section_title(doc, "6.1 模型选择总表", 2)
    add_table(doc, ["企业类型", "主模型", "交叉验证", "关键输入"], [
        ["一般非金融盈利企业", "P/E或FCFF-DCF", "EV/EBIT、FCF收益率、反向DCF", "正常化利润、增长、ROIC、负债"],
        ["资本开支较重企业", "FCFF-DCF、EV/EBIT", "FCF收益率、重置价值", "维持性资本开支、周期利用率"],
        ["银行", "P/B-ROE、股利/剩余收益模型", "P/E、股息率", "正常ROE、信用成本、资本充足率"],
        ["保险", "P/EV或剩余收益", "P/B、股息率", "内含价值、新业务价值、偿付能力"],
        ["REIT/物业", "NAV、P/FFO", "股息率、资本化率", "可持续租金、空置率、净负债"],
        ["资源/强周期", "中周期利润、NAV", "EV/EBITDA、股息率", "中周期价格、储量、成本曲线"],
        ["亏损成长企业", "情景DCF、EV/收入", "单位经济性、资金缺口", "毛利率、获客成本、留存、稀释"],
        ["多业务控股公司", "SOTP分部加总", "合并DCF、控股折价", "各分部价值、净负债、税费"],
    ], [1740, 2340, 2520, 2760], font_size=8.7, first_col_bold=True)
    add_callout(doc, "模型纪律", "至少用一个主模型和一个不同逻辑的交叉验证。若两个结果相差超过25%,先解释口径、周期、负债、资本开支或终值假设,不要直接平均。", "caution")
 
    add_section_title(doc, "6.2 相对估值的可比对象", 2)
    add_body(doc, "同行可比不是业务名字相似就够了。至少匹配收入来源、增长、利润率、ROIC、负债、会计口径、地区风险和生命周期。建议用中位数,并说明公司相对同行应该溢价还是折价。")
 
    add_section_title(doc, "第七部分  估值标准与计算公式", 1)
    add_section_title(doc, "7.1 P/E与利润收益率", 2)
    add_formula(doc,
                "TTM P/E = 总市值 ÷ TTM归母净利润",
                "正常化P/E = 总市值 ÷ 正常化归母利润",
                "前瞻P/E = 总市值 ÷ NTM归母利润",
                "利润收益率 = 正常化利润 ÷ 总市值 = 1 ÷ 正常化P/E")
    add_body(doc, "合理P/E必须与利润增长、回报率、风险和资本需求匹配。低P/E可能来自周期顶部、利润下滑或高负债;高P/E只有在高质量增长可持续时才合理。")
    add_table(doc, ["检查项", "计算/观察", "通过标准"], [
        ["历史位置", "当前正常化P/E对比自身5至10年分位", "解释经营结构变化后再比较"],
        ["同行位置", "对比可比公司中位数", "溢折价能由增长、ROIC、风险解释"],
        ["增长匹配", "前瞻P/E与3至5年利润CAGR", "不依赖不现实的长期高增速"],
        ["现金匹配", "利润收益率与FCF收益率", "差异可由营运资金和资本开支解释"],
    ], [1800, 3960, 3600], first_col_bold=True)
 
    add_section_title(doc, "7.2 EV/EBIT、EV/EBITDA和FCF收益率", 2)
    add_formula(doc,
                "EBITDA = EBIT + 折旧摊销",
                "EV/EBIT = 企业价值 ÷ 正常化EBIT",
                "EV/EBITDA = 企业价值 ÷ 正常化EBITDA",
                "企业自由现金流收益率 = FCFF ÷ EV",
                "股权自由现金流收益率 = FCFE ÷ 总市值")
    add_bullet_list(doc, [
        "EV倍数适合资本结构不同的企业比较。",
        "EBITDA忽略资本开支;重资产企业不能只看EV/EBITDA。",
        "FCF收益率最接近股东实际可获得现金,但要区分维持性和增长性资本开支。",
        "同一分析中不得用股权现金流除以EV,也不得用企业现金流除以总市值。",
    ])
 
    add_section_title(doc, "7.3 P/B与ROE", 2)
    add_formula(doc,
                "P/B = 总市值 ÷ 归母净资产",
                "ROE = 归母净利润 ÷ 平均归母净资产",
                "稳定期理论P/B ≈ (ROE - g) ÷ (要求回报R - g)")
    add_body(doc, "P/B主要适合资产与盈利能力关系紧密的银行、保险和部分资产型公司。低P/B若对应低ROE、资产减值或资本约束,并不代表低估。")
 
    add_section_title(doc, "7.4 股东收益率", 2)
    add_formula(doc,
                "股东收益率 = (现金分红 + 净回购) ÷ 总市值",
                "净回购 = 回购注销金额 - 新股发行及股权融资金额")
    add_body(doc, "回购只有在未被股权激励和新增发行抵消、且回购价格合理时,才真正增加每股价值。")
 
    add_section_title(doc, "第八部分  DCF与反向估值", 1)
    add_section_title(doc, "8.1 FCFF-DCF", 2)
    add_formula(doc,
                "FCFF = EBIT × (1 - 税率) + 折旧摊销 - 资本开支 - 营运资金增加",
                "股权成本 Ke = 无风险利率 + Beta × 股票风险溢价 + 特有风险调整",
                "WACC = Ke × E/(D+E) + 税前债务成本Kd × (1-税率) × D/(D+E)",
                "终值 TV = FCFF(n+1) ÷ (WACC - 永续增长率g)",
                "企业价值 = Σ[FCFF(t) ÷ (1 + WACC)^t] + TV ÷ (1 + WACC)^n",
                "股权价值 = 企业价值 + 现金及非经营资产 - 债务 - 优先股 - 少数股东权益")
    add_body(doc, "预测期通常覆盖企业从当前状态过渡到稳定状态的时间。终值增长率必须低于折现率,并与估值币种下的长期经济增长相容。WACC中的E和D使用市场价值权重。现金流和折现率必须匹配:FCFF用WACC,FCFE用股权成本。")
 
    add_section_title(doc, "8.2 DCF必做的敏感性分析", 2)
    add_table(doc, ["变量", "基准", "悲观", "乐观"], [
        ["收入增长", "业务驱动中值", "低2至5个百分点", "高2至5个百分点"],
        ["稳态利润率", "历史/同行可实现水平", "成本或竞争恶化", "接近优秀同行但不超常"],
        ["WACC/股权成本", "要求回报对应", "提高1至2个百分点", "降低1个百分点"],
        ["永续增长率", "低于长期名义经济增长", "降低0.5至1个百分点", "最多小幅提高"],
        ["终值占比", "单独披露", "若高于70%降低置信度", "不可用乐观终值掩盖近端弱现金流"],
    ], [1800, 2880, 2340, 2340], first_col_bold=True)
 
    add_section_title(doc, "8.3 反向DCF", 2)
    add_body(doc, "反向估值把当前市值设为已知数,反求能使模型成立的收入增长率、利润率、ROIC或竞争优势持续年限。它不是第三个目标价,而是检查市场正在要求公司做到什么。")
    add_numbered_list(doc, [
        "将当前总市值转换为企业价值。",
        "锁定合理的折现率、终值增长率和资本需求。",
        "反求未来5至10年的收入增长或稳态利润率,使DCF价值等于当前EV。",
        "把隐含假设与历史最好水平、同行、行业空间和产能约束比较。",
        "若当前价格只有在乐观假设同时成立时才合理,应判为偏贵。",
    ])
    add_callout(doc, "最重要的问题", "不要只问“我算出的价值是多少”,还要问“市场价格要求未来发生什么,而这些事情发生的概率有多大”。", "positive")
 
    add_section_title(doc, "第九部分  预期收益率与最终判定", 1)
    add_section_title(doc, "9.1 五年持有收益率", 2)
    add_formula(doc,
                "第N年EPS = 当前基准EPS × (1 + 可持续增长率)^N",
                "第N年价格 = 第N年EPS × 合理退出P/E",
                "简化年化回报IRR = [(第N年价格 + 累计分红) ÷ 当前价格]^(1/N) - 1")
    add_body(doc, "更精确时应把每年分红按实际时点计入XIRR。退出P/E应使用企业进入更成熟阶段后的正常倍数,通常不应高于当前高增长期倍数。")
 
    add_section_title(doc, "9.2 安全边际和下行风险", 2)
    add_formula(doc,
                "安全边际 = (基准合理价值 - 当前价格) ÷ 基准合理价值",
                "悲观下行 = (当前价格 - 悲观价值) ÷ 当前价格")
    add_body(doc, "安全边际需要随不确定性增加。稳定、高透明度企业可以要求较小折价;周期、杠杆、治理或技术不确定性高的企业应要求更大折价。不要把固定20%或30%当作普遍真理。")
 
    add_section_title(doc, "9.3 默认判定矩阵", 2)
    add_table(doc, ["结论", "默认量化标准", "必要条件"], [
        ["明显低估", "基准IRR ≥ R+3个百分点,且价格低于基准价值下沿", "隐含假设不激进;悲观损失在风险预算内"],
        ["基本合理", "基准IRR在R上下3个百分点内,且价格位于合理区间", "业务假设可实现;至少两种模型方向一致"],
        ["偏贵", "基准IRR < R-3个百分点,或价格高于合理价值上沿", "只有乐观情景才能达标,或当前价格隐含超常经营"],
        ["无法判断", "主模型与验证模型差异>25%,或利润无法正常化", "信息不足、会计可信度低、重大二元事件未决"],
    ], [1560, 4260, 3540], first_col_bold=True)
    add_callout(doc, "阈值说明", "3个百分点和25%是便于执行的默认值,可按个人投资政策调整;一旦选定,应在不同股票之间保持一致。", "info")
 
    add_section_title(doc, "9.4 最终结论必须写成条件句", 2)
    add_formula(doc,
                "结论模板:在收入增速__%、正常化净利率__%、要求回报__%、退出P/E__倍的基准假设下,",
                "合理价值为__至__元,当前价格__元,5年预期IRR为__%,悲观下行__%。",
                "因此判定为__;若__指标跌破/超过__,需重新估值。")
 
    add_section_title(doc, "第十部分  完整示例:甲公司", 1)
    add_body(doc, "以下为虚构公司,仅用于演示。单位为亿元,股数为亿股。估值日价格22.50元,持有期5年,最低要求回报10%。")
    add_section_title(doc, "10.1 资本结构与利润", 2)
    add_table(doc, ["项目", "数值", "计算/说明"], [
        ["当前股价", "22.50元", "估值日收盘价"],
        ["完全稀释股数", "10亿股", "含潜在稀释"],
        ["总市值", "225亿元", "22.50 × 10"],
        ["现金", "40亿元", "假设均可支配"],
        ["有息负债", "60亿元", "短债+长债"],
        ["少数股东权益", "5亿元", "企业价值口径需加"],
        ["企业价值EV", "250亿元", "225 + 60 + 5 - 40"],
        ["TTM归母利润", "18亿元", "报表数"],
        ["一次性资产出售收益", "3亿元", "税后归母影响"],
        ["正常化利润", "15亿元", "18 - 3"],
        ["未来12个月基准利润", "16.5亿元", "业务驱动预测"],
        ["正常化FCFE", "13亿元", "用于FCF收益率"],
    ], [2880, 1800, 4680], first_col_bold=True)
 
    add_section_title(doc, "10.2 相对估值", 2)
    add_formula(doc,
                "报表TTM P/E = 225 ÷ 18 = 12.5倍",
                "正常化P/E = 225 ÷ 15 = 15.0倍",
                "前瞻P/E = 225 ÷ 16.5 = 13.6倍",
                "FCF收益率 = 13 ÷ 225 = 5.8%")
    add_body(doc, "假设公司历史、同行和经营质量支持基准15倍前瞻P/E,则相对估值为16.5 × 15 = 247.5亿元,即每股24.75元。")
 
    add_section_title(doc, "10.3 简化DCF", 2)
    add_body(doc, "假设第一年FCFF为16亿元,未来5年增长7%,WACC为10%,永续增长率3%,净债务及少数股东权益合计25亿元。")
    dcf_rows = [
        ["1", "16.00", "14.55"],
        ["2", "17.12", "14.15"],
        ["3", "18.32", "13.76"],
        ["4", "19.60", "13.39"],
        ["5", "20.97", "13.02"],
        ["终值", "308.60", "191.62"],
    ]
    add_table(doc, ["年度", "FCFF/终值", "折现现值"], dcf_rows, [1800, 3240, 4320], first_col_bold=True)
    add_formula(doc,
                "企业价值 = 68.87 + 191.62 = 260.49亿元",
                "股权价值 = 260.49 - 25 = 235.49亿元",
                "每股价值 = 235.49 ÷ 10 = 23.55元")
    add_callout(doc, "解释", "相对估值24.75元、DCF 23.55元,差异约5%,方向一致。DCF终值占企业价值约74%,因此仍须对WACC和永续增长率做敏感性分析。", "info")
 
    add_section_title(doc, "10.4 情景收益率", 2)
    scenario_data = []
    for name, growth, exit_pe, div in [("悲观", 0.02, 12, 1.80), ("基准", 0.07, 15, 2.25), ("乐观", 0.11, 18, 2.70)]:
        eps5 = 1.65 * pow(1 + growth, 5)
        total5 = eps5 * exit_pe + div
        irr = pow(total5 / 22.5, 1 / 5) - 1
        scenario_data.append([name, f"{growth:.0%}", f"{eps5:.2f}元", f"{exit_pe}倍", f"{div:.2f}元", f"{irr:.1%}"])
    add_table(doc, ["情景", "EPS增长", "第5年EPS", "退出P/E", "累计分红", "5年IRR"], scenario_data,
              [1200, 1440, 1680, 1440, 1680, 1920], font_size=9.0,
              aligns=[WD_ALIGN_PARAGRAPH.CENTER] * 6, first_col_bold=True)
    add_body(doc, "基准情景IRR约10.4%,刚超过10%的最低要求;悲观情景仅约1.0%。用基准情景未来总价值36.96元按10%折现,当前合理价格约22.95元。")
 
    add_section_title(doc, "10.5 反向估值与结论", 2)
    add_body(doc, "当前22.50元大致要求公司未来5年EPS增长约7%、退出P/E约15倍并累计分红2.25元。这些假设若与历史增长、行业空间、ROIC和资本需求相符,则价格可以被经营事实支撑。")
    add_callout(doc, "示例结论", "综合DCF 23.55元、前瞻P/E价值24.75元、基准IRR 10.4%,甲公司当前22.50元处于合理区间偏低位置,但安全边际不大,因此判定“基本合理”,而非“明显低估”。若正常化利润下调超过10%或合理退出P/E降至12倍,应重新估值。", "positive")
 
    page_break(doc)
    add_section_title(doc, "附录A  一页式计算模板", 1)
    add_body(doc, "复制本页填写。所有金额必须使用同一币种和单位。")
    add_table(doc, ["模块", "输入/结果", "数值", "来源/说明"], [
        ["任务", "估值日 / 持有期 / 要求回报 / 风险预算", "", ""],
        ["价格", "当前股价 / 稀释股数 / 总市值", "", ""],
        ["资本", "现金 / 有息负债 / 少数股东权益 / EV", "", ""],
        ["利润", "TTM归母 / 扣非归母 / 正常化利润 / NTM利润", "", ""],
        ["现金流", "CFO / Capex / FCFE / FCF收益率", "", ""],
        ["质量", "现金含金量 / ROIC / 净负债EBITDA / 稀释率", "", ""],
        ["主估值", "模型 / 关键假设 / 价值区间", "", ""],
        ["验证估值", "模型 / 关键假设 / 价值区间", "", ""],
        ["情景", "悲观价值 / 基准价值 / 乐观价值", "", ""],
        ["收益", "悲观IRR / 基准IRR / 乐观IRR", "", ""],
        ["反向", "当前价格隐含增长 / 利润率 / 退出倍数", "", ""],
        ["结论", "低估 / 合理 / 偏贵 / 无法判断", "", "触发重新估值的条件:"],
    ], [1260, 3540, 1740, 2820], font_size=8.8, first_col_bold=True)
 
    add_section_title(doc, "一页式判定", 2)
    add_bullet_list(doc, [
        "基准IRR是否达到最低要求回报?",
        "当前价格是否位于至少两种模型共同支持的价值区间?",
        "当前价格隐含的增长、利润率和ROIC是否可实现?",
        "悲观下行是否处于风险预算内?",
        "利润是否真实、可持续,并能转换成现金?",
        "未来12至24个月是否存在偿债、稀释或持续经营风险?",
    ])
 
    page_break(doc)
    add_section_title(doc, "附录B  利润正常化与预测工作表", 1)
    add_section_title(doc, "B1 正常化利润", 2)
    add_table(doc, ["项目", "金额", "加/减", "税后归母影响", "依据与来源"], [
        ["报表归母净利润", "", "起点", "", ""],
        ["公司披露非经常性损益", "", "减", "", ""],
        ["未扣除的不可持续收益", "", "减", "", ""],
        ["可合理加回的一次性损失", "", "加", "", ""],
        ["周期调整", "", "加/减", "", ""],
        ["会计政策和其他调整", "", "加/减", "", ""],
        ["正常化归母利润", "", "结果", "", ""],
    ], [2520, 1320, 1080, 1800, 2640], font_size=8.8, first_col_bold=True)
 
    add_section_title(doc, "B2 未来利润驱动", 2)
    add_table(doc, ["驱动", "最近实际", "悲观", "基准", "乐观", "证据"], [
        ["销量/客户数", "", "", "", "", ""],
        ["售价/单客收入", "", "", "", "", ""],
        ["营业收入", "", "", "", "", ""],
        ["毛利率", "", "", "", "", ""],
        ["销售费用率", "", "", "", "", ""],
        ["管理费用率", "", "", "", "", ""],
        ["研发费用率", "", "", "", "", ""],
        ["财务费用", "", "", "", "", ""],
        ["正常税率", "", "", "", "", ""],
        ["少数股东损益", "", "", "", "", ""],
        ["正常化归母利润", "", "", "", "", ""],
    ], [1800, 1320, 1320, 1320, 1320, 2280], font_size=8.5, first_col_bold=True)
 
    page_break(doc)
    add_section_title(doc, "附录C  信息证据登记表", 1)
    add_table(doc, ["数据项", "数值", "期间", "发布日期", "来源文件/章节", "实际/预测", "等级", "调整说明"], [
        ["当前股价", "", "", "", "", "实际", "A", ""],
        ["完全稀释股数", "", "", "", "", "实际/估计", "", ""],
        ["归母利润", "", "", "", "", "实际", "", ""],
        ["扣非归母", "", "", "", "", "实际", "", ""],
        ["业绩预告", "", "", "", "", "公司估计", "", ""],
        ["机构一致预期", "", "", "", "", "预测", "", ""],
        ["销量/价格", "", "", "", "", "", "", ""],
        ["现金与负债", "", "", "", "", "实际", "", ""],
        ["资本开支", "", "", "", "", "实际/预测", "", ""],
        ["估值参数", "", "", "", "", "假设", "", ""],
    ], [1080, 900, 900, 1080, 1980, 1080, 720, 1620], font_size=7.8,
              aligns=[WD_ALIGN_PARAGRAPH.CENTER] * 4 + [WD_ALIGN_PARAGRAPH.LEFT] + [WD_ALIGN_PARAGRAPH.CENTER] * 2 + [WD_ALIGN_PARAGRAPH.LEFT],
              first_col_bold=True)
    add_callout(doc, "使用规则", "每次财报、预告或重大经营信息发布后,仅更新受影响的输入和情景;保留旧版本,以便检查预测偏差和机构修正。", "info")
 
    add_section_title(doc, "附录D  无法正常估值的情形", 1)
    add_bullet_list(doc, [
        "财务真实性存在重大疑问,或审计意见、监管问询尚未消除。",
        "公司价值主要取决于单一诉讼、审批、勘探或临床试验等二元事件。",
        "未来融资缺口巨大,稀释比例无法合理估计。",
        "商业模式尚未验证,单位经济性为负且没有可信的改善路径。",
        "主模型与验证模型差异持续超过25%,且不能由周期、负债或口径解释。",
        "行业规则、价格机制或资产权属发生重大变化,历史利润失去参考意义。",
    ])
    add_callout(doc, "正确结论", "“无法判断”是一种有效结论。信息不足时强行给出目标价,会把不确定性伪装成精确数字。", "caution")
 
    page_break(doc)
    add_section_title(doc, "附录E  权威规则与估值参考", 1)
    add_body(doc, "以下链接用于查找原始披露规则和估值方法。规则可能修订,使用时应以估值日最新版本为准。")
    references = [
        ("巨潮资讯网(深交所法定信息披露平台)", "https://www.cninfo.com.cn/"),
        ("上海证券交易所", "https://www.sse.com.cn/"),
        ("深圳证券交易所", "https://www.szse.cn/"),
        ("北京证券交易所", "https://www.bse.cn/"),
        ("香港交易所披露易", "https://www.hkexnews.hk/"),
        ("美国SEC EDGAR", "https://www.sec.gov/edgar/search/"),
        ("证监会《上市公司信息披露管理办法》(2025-07-01起施行)", "https://www.csrc.gov.cn/shanghai/c105565/c7549909/content.shtml"),
        ("证监会《非经常性损益(2023年修订)》", "https://www.csrc.gov.cn/csrc/c101954/c7451398/content.shtml"),
        ("上交所《股票上市规则(2026年4月修订)》", "https://www.sse.com.cn/lawandrules/sselawsrules2025/stocks/mainipo/c/c_20260424_10816589.shtml"),
        ("Damodaran:估值框架与DCF", "https://pages.stern.nyu.edu/~adamodar/New_Home_Page/lectures/val.html"),
        ("Damodaran:每股股权价值的调整", "https://pages.stern.nyu.edu/adamodar/New_Home_Page/littlebook/valuepershare.htm"),
        ("Investor.gov:P/E定义", "https://www.investor.gov/introduction-investing/investing-basics/glossary/price-earnings-pe-ratio"),
    ]
    num_id = create_numbering(doc, kind="decimal")
    for title, url in references:
        p = doc.add_paragraph()
        apply_num(p, num_id, 0)
        add_hyperlink(p, title, url)
        r = p.add_run(f"  {url}")
        set_run_font(r, size=8.5, color=MUTED)
 
    add_section_title(doc, "最后检查:交付前的十个问题", 2)
    add_numbered_list(doc, [
        "估值日期、币种和持有期是否明确?",
        "总市值是否使用完全稀释后总股数?",
        "利润是否区分报表、扣非、正常化和未来12个月?",
        "一次性和周期性项目是否有证据支持调整?",
        "利润增长是否拆成销量、价格、结构、成本和费用?",
        "利润是否通过现金流、ROIC、负债和稀释校验?",
        "是否选对行业模型,并使用不同逻辑交叉验证?",
        "是否完成悲观、基准、乐观情景和敏感性分析?",
        "当前价格隐含的经营假设是否明确且可实现?",
        "结论是否包含合理价值区间、IRR、悲观下行和重新估值触发条件?",
    ])
 
    # Core properties.
    doc.core_properties.title = "如何判断一只股票的价格是否合理"
    doc.core_properties.subject = "股票估值操作手册"
    doc.core_properties.author = "Codex"
    doc.core_properties.keywords = "股票估值, 正常化利润, DCF, 反向DCF, 预期收益率, 信息源"
    doc.core_properties.comments = "版本1.0;通用研究框架;不构成投资建议。"
 
    doc.save(OUTPUT)
    return OUTPUT
 
 
if __name__ == "__main__":
    path = build_document()
    print(path)