这里是一份 LaTeX 引擎跨平台基准测试脚本,用于对比各个编译引擎的编译耗时以及增量编译效率,并生成一份测试报告。

测试报告示例

测试内容

  • 纯英文:比较 pdfLaTeX、XeLaTeX、LuaLaTeX;
  • 中文:比较 XeLaTeX、LuaLaTeX,不测试 pdfLaTeX;
  • 从空辅助目录开始的完整编译;
  • 修改少量源码并保留 .aux.toc 后的增量编译;
  • 自动输出包含逐轮测量、汇总统计和环境元数据的 JSON,以及编译日志和测试 PDF;
  • 自动生成一页式中文性能简报,重点比较相同工作负载下不同引擎的速度、排名和相对差距;
  • 图表包含 P50、样本区间误差线,并将冷启动到增量构建的加速比作为附带指标;
  • 测试结束后在控制台直接输出各文档、各构建模式下的引擎排名和主要结论。

脚本只使用 Python 标准库。测试机器需要安装 Python 3.8 或更高版本以及 TeX Live,并确保 latexmkpdflatexxelatexlualatex 位于 PATH。TeX Live 还应包含 ctex、Fandol 字体、pgfplotsbooktabshyperref 等常用组件。

正式计时开始前,脚本会统一检查 Python 版本、latexmkkpsewhich、实际
使用的编译引擎,以及当前测试和报告所需的宏包、文档类与 Fandol 字体。
检查失败会立即退出;检查通过后会在控制台显示 Python 和 TeX Live 版本。
TeX Live 版本也会写入 results.json 和最终 PDF 报告。

运行

Linux:

1
python3 benchmark.py

Windows PowerShell:

1
python .\benchmark.py

使用 uv:

1
uv run python benchmark.py

默认每个组合预热 1 次,再测试 5 次;每份测试文档包含 40 个重复的固定内容单元。

快速检查:

1
python3 benchmark.py --runs 1 --units 2 --no-warmup

常用选项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 增加样本数
python3 benchmark.py --runs 10

# 只测试中文;脚本会自动排除 pdflatex
python3 benchmark.py --documents chinese

# 只测试英文
python3 benchmark.py --documents english

# 只测试指定引擎
python3 benchmark.py --engines xelatex lualatex

# 指定结果目录
python3 benchmark.py --output /path/to/results

# 使用已有 results.json 重新生成报告,不重新测试
python3 benchmark.py --output /path/to/results --report-only

--report-only 同样会在控制台输出主要结论,且不会重新执行计时测试。

完整参数:

1
python3 benchmark.py --help

测量方式

每个有效组合默认先预热一次,预热时间不计入统计。

从零编译时,脚本创建新的工作目录并动态写入 main.texunits.texmutable.tex--units 控制文档中重复的固定内容单元数量;每个单元采用相同的段落、公式和表格结构,以形成稳定且可调节的排版负载。随后计时 latexmk 完成所有必要编译遍数,文件创建时间不计入编译时间。

增量编译复用上一轮辅助文件,只修改 mutable.tex 中的修订号,再计时重新编译。TeX 通常仍会重新排版整份文档,因此这里主要衡量减少目录和交叉引用编译遍数所带来的收益,并不是页面级局部编译。

输出

默认写入脚本旁的 results/

1
2
3
4
5
6
7
results/
├── results.json
├── latex-engine-benchmark-report.pdf
├── report/latex-engine-benchmark-report.tex
├── test-pdfs/
├── logs/
└── work/

跨机器汇总时建议保留 results.json 和报告 PDF。为保证结果可比,
不同机器应使用相同 TeX Live 版本、--runs--units 参数,并尽量关闭后台
任务和节能模式。

源码

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
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
#!/usr/bin/env python3
"""Portable pdfLaTeX/XeLaTeX/LuaLaTeX benchmark and report generator."""

# =============================================================================
# 使用说明
# =============================================================================
#
# 一、用途
# 本脚本会在运行时自动生成英文和中文 LaTeX 测试文档,无需携带任何
# .tex、图片或数据文件。英文比较 pdfLaTeX、XeLaTeX 和 LuaLaTeX;中文
# 比较 XeLaTeX 和 LuaLaTeX(不测试 pdfLaTeX)。每种有效组合分别测量:
#
# 1. 从零编译:清空辅助目录后,由 latexmk 完成所有必要编译遍数;
# 2. 增量编译:保留辅助文件,只修改一个很小的被包含文件后重新编译。
#
# 报告重点是在相同语言、相同构建模式下横向比较不同引擎的速度。增量
# 加速比仅作为附带指标,不代表页面级局部编译。
#
# 二、环境要求
# - Python 3.8 或更高版本,仅使用标准库;也可通过 uv 提供 Python。
# - TeX Live,以及 PATH 中可用的 latexmk、kpsewhich 和待测编译引擎。
# - TeX Live 应包含 ctex、Fandol 字体、pgfplots、booktabs、hyperref 等。
#
# 正式计时前会检查 Python 版本、命令、引擎、宏包、文档类和字体;缺少
# 必要组件时立即退出。控制台、results.json 和 PDF 均会记录 TeX Live
# 版本或相关环境信息。
#
# 三、基本运行
# Linux:
# python3 benchmark.py
#
# Windows PowerShell:
# python .\benchmark.py
#
# 使用 uv:
# uv run python benchmark.py
#
# 快速检查(少量内容、单次测量且不预热):
# python3 benchmark.py --runs 1 --units 2 --no-warmup
#
# 四、常用选项
# python3 benchmark.py --runs 10
# python3 benchmark.py --documents chinese
# python3 benchmark.py --documents english
# python3 benchmark.py --engines xelatex lualatex
# python3 benchmark.py --output /path/to/results
# python3 benchmark.py --output /path/to/results --report-only
# python3 benchmark.py --help
#
# --report-only 读取已有 results.json,重新输出控制台结论并生成报告,不会
# 重新执行计时测试。--no-report 只生成 JSON 和测试产物,不生成中文报告。
#
# 五、默认测试规模与测量含义
# 默认每个有效组合先预热 1 次(不计时),再正式测试 5 次。每份测试文档
# 包含 40 个重复的固定内容单元;--units 控制其数量。每个内容单元采用相同
# 的段落、公式和表格结构,以形成稳定且可调节的排版负载。动态创建测试
# 文件的时间不计入编译时间。
#
# 六、输出
# 默认输出到脚本旁的 results/:
# results.json 完整数据源:环境、汇总和逐轮测量
# latex-engine-benchmark-report.pdf 一页式中文性能报告
# report/ 报告源码和构建日志
# test-pdfs/ 各测试组合生成的最终 PDF
# logs/ 每轮编译日志
# work/ 测试源码、辅助文件和工作目录
#
# JSON 是唯一的结构化数据文件。跨机器汇总时建议保留 results.json 和报告
# PDF;比较不同机器时应固定 TeX Live 版本、--runs、--units 和文档类型,
# 并尽量关闭后台任务和节能模式。
#
# =============================================================================

from __future__ import annotations

import argparse
import datetime as dt
import json
import os
import platform
import re
import shutil
import statistics
import subprocess
import sys
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable


ROOT = Path(__file__).resolve().parent
DOCUMENTS = ("english", "chinese")
DOCUMENT_LABELS = {"english": "纯英文", "chinese": "中文"}
MODE_LABELS = {"cold": "从零编译", "incremental": "增量编译"}
ENGINE_FLAGS = {
"pdflatex": "-pdf",
"xelatex": "-pdfxe",
"lualatex": "-pdflua",
}
SUPPORTED_ENGINES = {
"english": ("pdflatex", "xelatex", "lualatex"),
"chinese": ("xelatex", "lualatex"),
}

ENGLISH_TEMPLATE = r"""\documentclass[11pt]{article}
\usepackage[a4paper,margin=24mm]{geometry}
\usepackage{lmodern}
\usepackage[T1]{fontenc}
\usepackage{amsmath,amssymb}
\usepackage{booktabs}
\usepackage{hyperref}
\input{units.tex}
\input{mutable.tex}

\newcount\benchmarkunit
\newcommand{\benchmarktext}{%
A portable benchmark should exercise paragraph construction, line breaking,
mathematical typesetting, table layout, cross references, and PDF output.
This paragraph is intentionally repeated so that the measured document is
large enough to reduce timer noise without relying on external data files.}

\begin{document}
\title{English LaTeX Engine Benchmark}
\author{Portable benchmark suite -- revision \BenchmarkVariant}
\date{}
\maketitle
\tableofcontents
\benchmarkunit=1
\loop
\section{Benchmark unit \the\benchmarkunit}
\label{sec:unit-\the\benchmarkunit}
\benchmarktext\ \benchmarktext\ \benchmarktext
\subsection{Mathematics}
\begin{equation}
\sum_{k=1}^{n} k^2 = \frac{n(n+1)(2n+1)}{6},
\qquad
\int_0^1 x^m(1-x)^2\,dx = \frac{2}{(m+1)(m+2)(m+3)}.
\end{equation}
\begin{align}
A_{ij} &= \frac{1}{1+i+j}, & 1 \leq i,j \leq 8,\\
y_i &= \sum_{j=1}^{8} A_{ij}x_j, &
\lVert y\rVert_2 &\leq \lVert A\rVert_2\lVert x\rVert_2.
\end{align}
\subsection{Table and cross reference}
Table~\ref{tab:unit-\the\benchmarkunit} and
Section~\ref{sec:unit-\the\benchmarkunit} exercise auxiliary-file handling.
\begin{table}[htbp]
\centering
\begin{tabular}{rrrr}
\toprule
Index & Linear & Quadratic & Cubic \\
\midrule
1 & 1 & 1 & 1 \\
2 & 2 & 4 & 8 \\
3 & 3 & 9 & 27 \\
4 & 4 & 16 & 64 \\
5 & 5 & 25 & 125 \\
\bottomrule
\end{tabular}
\caption{Synthetic values for unit \the\benchmarkunit.}
\label{tab:unit-\the\benchmarkunit}
\end{table}
\benchmarktext\ \benchmarktext
\clearpage
\advance\benchmarkunit by 1
\ifnum\benchmarkunit<\numexpr\BenchmarkUnits+1\relax
\repeat
\end{document}
"""

CHINESE_TEMPLATE = r"""\documentclass[UTF8,11pt,fontset=fandol]{ctexart}
\usepackage[a4paper,margin=24mm]{geometry}
\usepackage{amsmath,amssymb}
\usepackage{booktabs}
\usepackage{hyperref}
\input{units.tex}
\input{mutable.tex}

\newcount\benchmarkunit
\newcommand{\benchmarktext}{%
可移植的基准测试需要覆盖段落构造、自动断行、数学公式排版、表格布局、
交叉引用以及 PDF 输出。这里有意重复同一段中文,使文档规模足以降低计时
噪声,同时不依赖任何外部数据文件。中文字体固定使用 TeX Live 自带的
Fandol 字体,从而减少不同操作系统默认字体造成的差异。}

\begin{document}
\title{中文 LaTeX 引擎基准测试}
\author{可移植测试套件——修订号 \BenchmarkVariant}
\date{}
\maketitle
\tableofcontents
\benchmarkunit=1
\loop
\section{测试单元 \the\benchmarkunit}
\label{sec:unit-\the\benchmarkunit}
\benchmarktext\ \benchmarktext\ \benchmarktext
\subsection{数学公式}
\begin{equation}
\sum_{k=1}^{n} k^2 = \frac{n(n+1)(2n+1)}{6},
\qquad
\int_0^1 x^m(1-x)^2\,dx = \frac{2}{(m+1)(m+2)(m+3)}.
\end{equation}
\begin{align}
A_{ij} &= \frac{1}{1+i+j}, & 1 \leq i,j \leq 8,\\
y_i &= \sum_{j=1}^{8} A_{ij}x_j, &
\lVert y\rVert_2 &\leq \lVert A\rVert_2\lVert x\rVert_2.
\end{align}
\subsection{表格与交叉引用}
表~\ref{tab:unit-\the\benchmarkunit} 和
第~\ref{sec:unit-\the\benchmarkunit} 节用于测试辅助文件的读写与复用。
\begin{table}[htbp]
\centering
\begin{tabular}{rrrr}
\toprule
序号 & 一次方 & 二次方 & 三次方 \\
\midrule
1 & 1 & 1 & 1 \\
2 & 2 & 4 & 8 \\
3 & 3 & 9 & 27 \\
4 & 4 & 16 & 64 \\
5 & 5 & 25 & 125 \\
\bottomrule
\end{tabular}
\caption{测试单元 \the\benchmarkunit 的合成数据。}
\label{tab:unit-\the\benchmarkunit}
\end{table}
\benchmarktext\ \benchmarktext
\clearpage
\advance\benchmarkunit by 1
\ifnum\benchmarkunit<\numexpr\BenchmarkUnits+1\relax
\repeat
\end{document}
"""

DOCUMENT_TEMPLATES = {
"english": ENGLISH_TEMPLATE,
"chinese": CHINESE_TEMPLATE,
}


def write_text_lf(path: Path, text: str) -> None:
"""Write UTF-8 text with LF endings on Python 3.8+ and all platforms."""
with path.open("w", encoding="utf-8", newline="\n") as stream:
stream.write(text)


@dataclass
class Measurement:
document: str
engine: str
mode: str
run: int
seconds: float
pages: int | None
pdf_bytes: int


@dataclass
class Summary:
document: str
engine: str
mode: str
count: int
mean: float
median: float
minimum: float
maximum: float
stdev: float


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="跨平台测试 pdfLaTeX、XeLaTeX 和 LuaLaTeX 的冷编译与增量编译速度。"
)
parser.add_argument("--runs", type=int, default=5, help="每个测试组合的计时次数(默认:5)")
parser.add_argument(
"--units",
type=int,
default=40,
help="测试文档中重复的固定内容单元数量(默认:40)",
)
parser.add_argument(
"--output",
type=Path,
default=ROOT / "results",
help="结果目录(默认:脚本目录下的 results)",
)
parser.add_argument(
"--engines",
nargs="+",
choices=tuple(ENGINE_FLAGS),
default=list(ENGINE_FLAGS),
help="要测试的引擎",
)
parser.add_argument(
"--documents",
nargs="+",
choices=DOCUMENTS,
default=list(DOCUMENTS),
help="要测试的文档类型",
)
parser.add_argument("--no-warmup", action="store_true", help="跳过每个组合一次不计时的预热")
parser.add_argument("--no-report", action="store_true", help="只输出 JSON,不生成中文 PDF 报告")
parser.add_argument(
"--report-only",
action="store_true",
help="读取输出目录中已有的 results.json,只重新生成中文报告",
)
args = parser.parse_args()
if args.no_report and args.report_only:
parser.error("--no-report 与 --report-only 不能同时使用")
if args.runs < 1:
parser.error("--runs 必须至少为 1")
if args.units < 1:
parser.error("--units 必须至少为 1")
return args


def command_version(command: str) -> str:
try:
result = subprocess.run(
[command, "--version"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
except OSError:
return "不可用"
text = result.stdout or result.stderr
return text.splitlines()[0].strip() if text else "未知"


def latexmk_version() -> str:
result = subprocess.run(
["latexmk", "-v"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
for line in (result.stdout + result.stderr).splitlines():
if "Latexmk" in line and "Version" in line:
return line.strip()
return "latexmk(版本未知)"


def cpu_name() -> str:
name = platform.processor() or os.environ.get("PROCESSOR_IDENTIFIER", "")
if name:
return name.strip()
cpuinfo = Path("/proc/cpuinfo")
if cpuinfo.exists():
for line in cpuinfo.read_text(encoding="utf-8", errors="replace").splitlines():
if line.lower().startswith("model name"):
return line.split(":", 1)[-1].strip()
return "未知"


def require_commands(engines: Iterable[str], report: bool) -> None:
commands = ["latexmk", "kpsewhich", *engines]
if report and "xelatex" not in commands:
commands.append("xelatex")
missing = [command for command in commands if shutil.which(command) is None]
if missing:
raise SystemExit("缺少必需命令:" + ", ".join(missing))


def detect_tex_live_version(version_texts: Iterable[str]) -> str:
for text in version_texts:
match = re.search(r"TeX Live\s+(\d{4})", text, flags=re.IGNORECASE)
if match:
return "TeX Live " + match.group(1)
return "TeX Live(版本未知)"


def required_tex_files(documents: Iterable[str], report: bool) -> list[str]:
selected = set(documents)
files = {
"amsmath.sty",
"amssymb.sty",
"booktabs.sty",
"geometry.sty",
"hyperref.sty",
}
if "english" in selected:
files.update({"fontenc.sty", "lmodern.sty"})
if "chinese" in selected or report:
files.update({"ctexart.cls", "FandolSong-Regular.otf"})
if report:
files.update({"enumitem.sty", "pgfplots.sty", "tikz.sty"})
return sorted(files)


def check_tex_files(files: Iterable[str]) -> None:
missing = []
for filename in files:
result = subprocess.run(
["kpsewhich", filename],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
if result.returncode != 0 or not result.stdout.strip():
missing.append(filename)
if missing:
raise SystemExit("TeX Live 缺少必需文件:" + ", ".join(missing))


def preflight_environment(
engines: list[str], documents: list[str], report: bool
) -> dict[str, object]:
if sys.version_info < (3, 8):
raise SystemExit(
"Python 版本过低:需要 3.8 或更高版本,当前为 "
+ platform.python_version()
)
require_commands(engines, report)
version_engines = list(engines)
if report and "xelatex" not in version_engines:
version_engines.append("xelatex")
engine_versions = {
engine: command_version(engine) for engine in version_engines
}
latexmk_text = latexmk_version()
tex_files = required_tex_files(documents, report)
check_tex_files(tex_files)
tex_live = detect_tex_live_version(
list(engine_versions.values()) + [latexmk_text]
)

print("=== 环境预检 ===", flush=True)
print(
f"Python: {platform.python_version()} ({sys.executable})", flush=True
)
print("命令: latexmk, kpsewhich, " + ", ".join(version_engines), flush=True)
print(f"发行版: {tex_live}", flush=True)
print(f"TeX 文件: {len(tex_files)} 项检查通过", flush=True)
print("================", flush=True)
return {
"latexmk": latexmk_text,
"engine_versions": engine_versions,
"tex_live": tex_live,
"checked_tex_files": tex_files,
}


def safe_remove_tree(path: Path, output_root: Path) -> None:
resolved = path.resolve()
root = output_root.resolve()
if resolved == root or root not in resolved.parents:
raise RuntimeError(f"拒绝删除结果目录之外的路径:{resolved}")
if resolved.exists():
shutil.rmtree(resolved)


def safe_remove_file(path: Path, output_root: Path) -> None:
resolved = path.resolve()
root = output_root.resolve()
if root != resolved.parent and root not in resolved.parents:
raise RuntimeError(f"拒绝删除结果目录之外的文件:{resolved}")
if resolved.exists():
resolved.unlink()


def prepare_workspace(
document: str, workspace: Path, output_root: Path, units: int
) -> Path:
safe_remove_tree(workspace, output_root)
source = workspace / "source"
source.mkdir(parents=True)
write_text_lf(source / "main.tex", DOCUMENT_TEMPLATES[document])
write_text_lf(source / "units.tex", f"\\def\\BenchmarkUnits{{{units}}}\n")
write_text_lf(source / "mutable.tex", "\\def\\BenchmarkVariant{baseline}\n")
return source


def latexmk_command(engine: str, source: Path, workspace: Path, force: bool = False) -> list[str]:
aux = workspace / ".aux"
aux.mkdir(parents=True, exist_ok=True)
command = [
"latexmk",
"-cd",
"-file-line-error",
"-halt-on-error",
"-interaction=nonstopmode",
"-synctex=0",
ENGINE_FLAGS[engine],
f"-auxdir={aux.resolve()}",
f"-outdir={workspace.resolve()}",
]
if force:
command.append("-g")
command.append(str((source / "main.tex").resolve()))
return command


def parse_pages(log_text: str) -> int | None:
matches = re.findall(
r"Output written on .*?\((\d+) pages?",
log_text,
flags=re.IGNORECASE | re.DOTALL,
)
return int(matches[-1]) if matches else None


def run_build(
engine: str,
source: Path,
workspace: Path,
log_path: Path,
force: bool = False,
) -> tuple[float, int | None, int]:
command = latexmk_command(engine, source, workspace, force)
log_path.parent.mkdir(parents=True, exist_ok=True)
started = time.perf_counter()
result = subprocess.run(
command,
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
elapsed = time.perf_counter() - started
combined = result.stdout + "\n" + result.stderr
write_text_lf(log_path, combined)
if result.returncode != 0:
tail = "\n".join(combined.splitlines()[-30:])
raise RuntimeError(f"{engine} 编译失败,日志:{log_path}\n{tail}")
pdf = workspace / "main.pdf"
if not pdf.exists():
raise RuntimeError(f"{engine} 未生成预期 PDF:{pdf}")
engine_log = workspace / ".aux" / "main.log"
engine_log_text = (
engine_log.read_text(encoding="utf-8", errors="replace")
if engine_log.exists()
else combined
)
return elapsed, parse_pages(engine_log_text), pdf.stat().st_size


def summarize(rows: list[Measurement]) -> list[Summary]:
summaries: list[Summary] = []
keys = sorted({(row.document, row.engine, row.mode) for row in rows})
for document, engine, mode in keys:
values = [
row.seconds
for row in rows
if (row.document, row.engine, row.mode) == (document, engine, mode)
]
summaries.append(
Summary(
document=document,
engine=engine,
mode=mode,
count=len(values),
mean=statistics.fmean(values),
median=statistics.median(values),
minimum=min(values),
maximum=max(values),
stdev=statistics.stdev(values) if len(values) > 1 else 0.0,
)
)
return summaries


def tex_escape(value: object) -> str:
text = str(value)
replacements = {
"\\": r"\textbackslash{}",
"&": r"\&",
"%": r"\%",
"$": r"\$",
"#": r"\#",
"_": r"\_",
"{": r"\{",
"}": r"\}",
"~": r"\textasciitilde{}",
"^": r"\textasciicircum{}",
}
return "".join(replacements.get(char, char) for char in text)


def find_summary(
summaries: list[Summary], document: str, engine: str, mode: str
) -> Summary | None:
return next(
(
item
for item in summaries
if (item.document, item.engine, item.mode) == (document, engine, mode)
),
None,
)


def benchmark_findings(
summaries: list[Summary], documents: list[str], engines: list[str]
) -> dict[str, object]:
"""Derive report and console findings from the same summary statistics."""
document_findings = []
speedups = []
for document in documents:
cold_items = [
item
for item in summaries
if item.document == document and item.mode == "cold"
]
incremental_items = [
item
for item in summaries
if item.document == document and item.mode == "incremental"
]
if not cold_items or not incremental_items:
continue
cold_fastest = min(cold_items, key=lambda item: item.median)
incremental_fastest = min(incremental_items, key=lambda item: item.median)
document_speedups = []
for engine in engines:
cold = find_summary(summaries, document, engine, "cold")
incremental = find_summary(summaries, document, engine, "incremental")
if cold and incremental and incremental.median > 0:
value = cold.median / incremental.median
speedups.append(value)
document_speedups.append(value)
document_findings.append(
{
"document": document,
"cold_fastest": cold_fastest,
"incremental_fastest": incremental_fastest,
"speedup_min": min(document_speedups),
"speedup_max": max(document_speedups),
}
)
return {
"documents": document_findings,
"speedup_min": min(speedups) if speedups else 0.0,
"speedup_max": max(speedups) if speedups else 0.0,
}


def print_key_findings(
summaries: list[Summary], documents: list[str], engines: list[str]
) -> None:
findings = benchmark_findings(summaries, documents, engines)
print("\n=== 引擎速度对比(按中位数 P50)===")
for document in documents:
for mode in ("cold", "incremental"):
candidates = sorted(
(
item
for item in summaries
if item.document == document and item.mode == mode
),
key=lambda item: item.median,
)
if not candidates:
continue
fastest = candidates[0].median
ranking = []
for index, item in enumerate(candidates):
gap = 100.0 * (item.median / fastest - 1.0)
suffix = "领先" if index == 0 else f"慢 {gap:.1f}%"
ranking.append(f"{item.engine} {item.median:.3f}s({suffix})")
print(
f"{DOCUMENT_LABELS[document]} / {MODE_LABELS[mode]}:"
+ " < ".join(ranking)
)
if findings["speedup_max"]:
print(
f"附带指标:同一引擎从冷启动到增量构建的加速比为 "
f"{findings['speedup_min']:.2f}--{findings['speedup_max']:.2f}x。"
)
print("说明:结论仅代表本机、本次工作负载;跨机器比较应保持参数与 TeX Live 版本一致。")
print("=" * 35)


def chart_coordinates(
summaries: list[Summary], document: str, engines: list[str], mode: str
) -> str:
coordinates = []
for engine in engines:
item = find_summary(summaries, document, engine, mode)
if item:
coordinates.append(f"({engine},{item.median:.4f})")
return " ".join(coordinates)


def generate_report_tex(
report_tex: Path,
rows: list[Measurement],
summaries: list[Summary],
metadata: dict[str, object],
engines: list[str],
documents: list[str],
) -> None:
report_tex.parent.mkdir(parents=True, exist_ok=True)
display_engines = ("pdflatex", "xelatex", "lualatex")
engine_labels = {
"pdflatex": "pdfLaTeX",
"xelatex": "XeLaTeX",
"lualatex": "LuaLaTeX",
}
result_rows = []
executive_points = []
for document in documents:
for mode in ("cold", "incremental"):
candidates = sorted(
(
item
for item in summaries
if item.document == document and item.mode == mode
),
key=lambda item: item.median,
)
if not candidates:
continue
fastest = candidates[0].median
cells = []
for engine in display_engines:
item = find_summary(summaries, document, engine, mode)
if not item:
cells.append("--")
continue
gap = 100.0 * (item.median / fastest - 1.0)
if gap < 0.05:
cells.append(f"\\textbf{{{item.median:.3f}}}")
else:
cells.append(f"{item.median:.3f} {{\\color{{Muted}}(+{gap:.1f}\\%)}}")
ranking_parts = []
for index, item in enumerate(candidates):
gap = 100.0 * (item.median / fastest - 1.0)
ranking_parts.append(
f"{engine_labels[item.engine]} 领先"
if index == 0
else f"{engine_labels[item.engine]}{gap:.1f}\\%"
)
result_rows.append(
f"{DOCUMENT_LABELS[document]} & {MODE_LABELS[mode]} & "
+ " & ".join(cells)
+ " \\\\"
)
executive_points.append(
f"{DOCUMENT_LABELS[document]}{MODE_LABELS[mode]}:"
+ ";".join(ranking_parts)
+ "。"
)

chart_keys = []
chart_labels = []
engine_coordinates = {engine: [] for engine in display_engines}
language_short = {"english": "英", "chinese": "中"}
mode_short = {"cold": "冷", "incremental": "增"}
for document in documents:
for mode in ("cold", "incremental"):
key = f"c{len(chart_keys)}"
chart_keys.append(key)
chart_labels.append(
f"{language_short[document]}-{mode_short[mode]}"
)
for engine in display_engines:
item = find_summary(summaries, document, engine, mode)
if item:
engine_coordinates[engine].append(
f"({key},{item.median:.4f}) "
f"+= (0,{item.maximum - item.median:.4f}) "
f"-= (0,{item.median - item.minimum:.4f})"
)
created_at = dt.datetime.fromisoformat(str(metadata["timestamp"]))
created = f"{created_at:%Y}{created_at.month}{created_at.day}{created_at:%H:%M:%S}"
engine_versions = metadata["engine_versions"]
version_rows = "\n".join(
f"{engine} & {tex_escape(engine_versions[engine])} \\\\"
for engine in engines
)
result_rows_tex = "\n".join(result_rows)
chart_keys_tex = ",".join(chart_keys)
chart_labels_tex = ",".join(chart_labels)
pdflatex_coordinates_tex = " ".join(engine_coordinates.get("pdflatex", []))
xelatex_coordinates_tex = " ".join(engine_coordinates.get("xelatex", []))
lualatex_coordinates_tex = " ".join(engine_coordinates.get("lualatex", []))
speedup_keys = []
speedup_labels = []
english_speedup_coordinates = []
chinese_speedup_coordinates = []
for document in documents:
for engine in display_engines:
cold = find_summary(summaries, document, engine, "cold")
incremental = find_summary(summaries, document, engine, "incremental")
if cold and incremental and incremental.median:
key = f"s{len(speedup_keys)}"
speedup_keys.append(key)
speedup_labels.append(
f"{language_short[document]}-{engine_labels[engine]}"
)
coordinate = (
f"({cold.median / incremental.median:.4f},{key})"
)
if document == "english":
english_speedup_coordinates.append(coordinate)
else:
chinese_speedup_coordinates.append(coordinate)
speedup_keys_tex = ",".join(speedup_keys)
speedup_labels_tex = ",".join(speedup_labels)
english_speedup_coordinates_tex = " ".join(english_speedup_coordinates)
chinese_speedup_coordinates_tex = " ".join(chinese_speedup_coordinates)
executive_points_tex = "\n".join(
" \\item " + text for text in executive_points
)
python_version_tex = tex_escape(str(metadata["python"]).split()[0])
platform_tex = tex_escape(metadata["platform"])
cpu_tex = tex_escape(metadata["cpu"])
latexmk_value = str(metadata["latexmk"])
latexmk_match = re.search(
r"Version\s+([0-9.]+[A-Za-z]?)", latexmk_value, flags=re.IGNORECASE
)
latexmk_tex = tex_escape(
"latexmk " + latexmk_match.group(1) if latexmk_match else latexmk_value
)
tex_live_tex = tex_escape(
metadata.get("tex_live")
or detect_tex_live_version(metadata.get("engine_versions", {}).values())
)
warmup_tex = "关闭" if metadata["no_warmup"] else "1 次且不计时"
tex = rf"""\documentclass[UTF8,10pt,fontset=fandol]{{ctexart}}
\usepackage[a4paper,top=11mm,bottom=10mm,left=13mm,right=13mm]{{geometry}}
\usepackage{{booktabs,array,tabularx,xcolor,colortbl,float,enumitem}}
\usepackage{{tikz,pgfplots}}
\pgfplotsset{{compat=1.18}}
\definecolor{{Ink}}{{HTML}}{{172033}}
\definecolor{{Muted}}{{HTML}}{{64748B}}
\definecolor{{Rule}}{{HTML}}{{CBD5E1}}
\definecolor{{Panel}}{{HTML}}{{F1F5F9}}
\definecolor{{PdfBlue}}{{HTML}}{{2563EB}}
\definecolor{{XeTeal}}{{HTML}}{{14B8A6}}
\definecolor{{LuaOrange}}{{HTML}}{{F97316}}
\color{{Ink}}
\pagestyle{{empty}}
\setlength{{\parindent}}{{0pt}}
\setlength{{\parskip}}{{0pt}}
\setlength{{\tabcolsep}}{{5pt}}
\renewcommand{{\arraystretch}}{{1.12}}
\setlist{{nosep,leftmargin=1.45em,labelsep=0.45em}}
\newcommand{{\reportsection}}[1]{{\vspace{{0.45em}}\noindent{{\large\bfseries #1}}\par\vspace{{0.15em}}\color{{Rule}}\hrule\color{{Ink}}\vspace{{0.35em}}}}

\begin{{document}}
\begin{{tabularx}}{{\textwidth}}{{@{{}}Xr@{{}}}}
{{\fontsize{{19}}{{22}}\selectfont\bfseries LaTeX Engine Benchmark}} &
{{\small\bfseries PERFORMANCE BRIEF}} \\
{{\large 跨平台编译性能评估}} & {{\footnotesize {created}}}
\end{{tabularx}}
\vspace{{0.35em}}
\color{{PdfBlue}}\hrule height 1.2pt\color{{Ink}}

\reportsection{{执行摘要}}
\colorbox{{Panel}}{{\parbox{{0.975\textwidth}}{{
\small
\begin{{itemize}}
{executive_points_tex}
\end{{itemize}}
}}}}

\reportsection{{测试设计}}
\footnotesize
\begin{{tabularx}}{{\textwidth}}{{@{{}}>{{\bfseries}}p{{0.09\textwidth}}X >{{\bfseries}}p{{0.08\textwidth}}X@{{}}}}
工作负载 & 英文:pdfLaTeX/XeLaTeX/LuaLaTeX;中文:XeLaTeX/LuaLaTeX & 指标 & 墙钟时间,P50 为主指标 \\
协议 & 每组合 {metadata['runs']} 次;文档包含 {metadata['units']} 个重复的固定内容单元;预热 {warmup_tex} & 构建 & 冷启动清空辅助目录;增量仅修改小文件 \\
环境 & {tex_live_tex};Python {python_version_tex}{latexmk_tex} & 主机 & {cpu_tex}{metadata['logical_cpus']} 逻辑核 \\
\end{{tabularx}}
\vspace{{0.2em}}
{{\color{{Muted}}平台:{platform_tex}。脚本生成等价源码,latexmk 完成必要遍数;中文使用 Fandol 字体。}}

\reportsection{{基准记分板}}
\begin{{center}}
\small
\begin{{tabular}}{{llrrr}}
\toprule
工作负载 & 构建模式 & pdfLaTeX / s & XeLaTeX / s & LuaLaTeX / s \\
\midrule
{result_rows_tex}
\bottomrule
\end{{tabular}}
\end{{center}}
\vspace{{-0.35em}}
{{\footnotesize\color{{Muted}}粗体为同一行最快结果;括号为相对最快引擎的耗时增幅。}}

\reportsection{{引擎速度剖面}}
\begin{{figure}}[H]
\centering
\begin{{tikzpicture}}
\begin{{axis}}[
ybar=1.2pt, bar width=17pt, width=0.965\textwidth, height=6.5cm,
ylabel={{P50 耗时 / s}}, symbolic x coords={{{chart_keys_tex}}},
xtick={{{chart_keys_tex}}}, xticklabels={{{chart_labels_tex}}},
xticklabel style={{font=\footnotesize}}, tick label style={{font=\footnotesize}},
ylabel style={{font=\footnotesize}}, ymin=0, enlarge x limits=0.10,
axis line style={{draw=Rule}}, tick style={{draw=Rule}},
ymajorgrids=true, grid style={{Rule,dashed}},
legend style={{at={{(0.5,0.98)}},anchor=north,legend columns=3,
font=\footnotesize,draw=none,fill=white}},
error bars/y dir=both, error bars/y explicit,
error bars/error bar style={{line width=0.6pt,draw=Ink}}
]
\addplot+[fill=PdfBlue,draw=PdfBlue!80!black] coordinates {{{pdflatex_coordinates_tex}}};
\addplot+[fill=XeTeal,draw=XeTeal!70!black] coordinates {{{xelatex_coordinates_tex}}};
\addplot+[fill=LuaOrange,draw=LuaOrange!80!black] coordinates {{{lualatex_coordinates_tex}}};
\legend{{pdfLaTeX,XeLaTeX,LuaLaTeX}}
\end{{axis}}
\end{{tikzpicture}}
\end{{figure}}
\vspace{{-0.9em}}
{{\footnotesize\color{{Muted}}柱高为中位数,误差线覆盖本次样本的最小值至最大值;每个分组直接比较相同语言、相同构建模式下的引擎速度。}}

\reportsection{{增量复用收益(附带指标)}}
\begin{{figure}}[H]
\centering
\begin{{tikzpicture}}
\begin{{axis}}[
xbar, bar width=7pt, width=0.965\textwidth, height=3.75cm,
xlabel={{加速比(冷启动 P50 / 增量 P50)}},
symbolic y coords={{{speedup_keys_tex}}},
ytick={{{speedup_keys_tex}}}, yticklabels={{{speedup_labels_tex}}},
tick label style={{font=\scriptsize}}, xlabel style={{font=\footnotesize}},
xmin=0, xmax=3.05, enlarge y limits=0.16,
axis line style={{draw=Rule}}, tick style={{draw=Rule}},
xmajorgrids=true, grid style={{Rule,dashed}},
legend style={{at={{(0.99,0.03)}},anchor=south east,legend columns=2,
font=\scriptsize,draw=none,fill=white}},
nodes near coords={{\pgfmathprintnumber[fixed,precision=2]{{\pgfplotspointmeta}}$\times$}},
nodes near coords style={{font=\scriptsize\bfseries,anchor=west}}
]
\addplot+[fill=PdfBlue,draw=PdfBlue!80!black]
coordinates {{{english_speedup_coordinates_tex}}};
\addplot+[fill=XeTeal,draw=XeTeal!70!black]
coordinates {{{chinese_speedup_coordinates_tex}}};
\legend{{英文,中文}}
\end{{axis}}
\end{{tikzpicture}}
\end{{figure}}
\vspace{{-0.8em}}
{{\footnotesize\color{{Muted}}数值越大,表示同一引擎复用辅助文件后相对冷启动节省的编译时间越多。}}

\vfill
\color{{Rule}}\hrule\color{{Ink}}\vspace{{0.25em}}
{{\scriptsize\color{{Muted}}数据出处:\texttt{{results.json}},其中包含逐轮测量、汇总统计和环境元数据。本报告不使用合成评分,避免掩盖绝对耗时。}}
\end{{document}}
"""
write_text_lf(report_tex, tex)


def compile_report(report_tex: Path, output_root: Path) -> Path:
pdf_dir = output_root
aux_dir = output_root / "report" / ".aux"
pdf_dir.mkdir(parents=True, exist_ok=True)
aux_dir.mkdir(parents=True, exist_ok=True)
command = [
"latexmk",
"-cd",
"-file-line-error",
"-halt-on-error",
"-interaction=nonstopmode",
"-synctex=0",
"-pdfxe",
f"-auxdir={aux_dir.resolve()}",
f"-outdir={pdf_dir.resolve()}",
str(report_tex.resolve()),
]
result = subprocess.run(
command,
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
log = result.stdout + "\n" + result.stderr
write_text_lf(output_root / "report" / "report-build.log", log)
if result.returncode != 0:
raise RuntimeError("中文报告编译失败:\n" + "\n".join(log.splitlines()[-40:]))
pdf = pdf_dir / f"{report_tex.stem}.pdf"
if not pdf.exists():
raise RuntimeError(f"中文报告 PDF 不存在:{pdf}")
return pdf


def main() -> int:
args = parse_args()
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
safe_remove_file(output / "timings.csv", output)

if args.report_only:
result_file = output / "results.json"
if not result_file.exists():
raise FileNotFoundError(f"找不到已有结果:{result_file}")
payload = json.loads(result_file.read_text(encoding="utf-8"))
metadata = payload["metadata"]
preflight = preflight_environment([], [], True)
metadata_changed = False
if not metadata.get("tex_live"):
historical_versions = list(
metadata.get("engine_versions", {}).values()
)
tex_live = detect_tex_live_version(historical_versions)
if "版本未知" in tex_live:
tex_live = str(preflight["tex_live"])
metadata["tex_live"] = tex_live
payload["metadata"] = metadata
metadata_changed = True
measurements = [Measurement(**item) for item in payload["measurements"]]
summaries = [Summary(**item) for item in payload["summary"]]
engines = list(metadata["engines"])
documents = list(metadata["documents"])
repaired_pages = False
for document in documents:
for engine in engines:
missing = [
row
for row in measurements
if row.document == document and row.engine == engine and row.pages is None
]
if not missing:
continue
engine_log = output / "work" / document / engine / ".aux" / "main.log"
if engine_log.exists():
pages = parse_pages(
engine_log.read_text(encoding="utf-8", errors="replace")
)
if pages is not None:
for row in missing:
row.pages = pages
repaired_pages = True
if repaired_pages or metadata_changed:
payload["measurements"] = [asdict(item) for item in measurements]
write_text_lf(
result_file, json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
)
print_key_findings(summaries, documents, engines)
report_tex = output / "report" / "latex-engine-benchmark-report.tex"
generate_report_tex(
report_tex, measurements, summaries, metadata, engines, documents
)
pdf = compile_report(report_tex, output)
print(f"报告:{pdf}")
return 0

cases = [
(document, engine)
for document in args.documents
for engine in args.engines
if engine in SUPPORTED_ENGINES[document]
]
if not cases:
raise ValueError("没有可运行的组合;中文测试只支持 xelatex 和 lualatex")
tested_engines = [
engine for engine in args.engines if any(case[1] == engine for case in cases)
]
tested_documents = [
document
for document in args.documents
if any(case[0] == document for case in cases)
]
skipped = [
(document, engine)
for document in args.documents
for engine in args.engines
if (document, engine) not in cases
]
for document, engine in skipped:
print(f"跳过:{DOCUMENT_LABELS[document]} / {engine}", flush=True)

preflight = preflight_environment(
tested_engines, tested_documents, not args.no_report
)

metadata: dict[str, object] = {
"timestamp": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
"platform": platform.platform(),
"cpu": cpu_name(),
"logical_cpus": os.cpu_count() or "未知",
"python": sys.version.replace("\n", " "),
"latexmk": preflight["latexmk"],
"engine_versions": preflight["engine_versions"],
"tex_live": preflight["tex_live"],
"checked_tex_files": preflight["checked_tex_files"],
"runs": args.runs,
"units": args.units,
"no_warmup": args.no_warmup,
"engines": tested_engines,
"documents": tested_documents,
}

measurements: list[Measurement] = []
total = len(cases)
case_number = 0
for document, engine in cases:
case_number += 1
print(f"[{case_number}/{total}] {DOCUMENT_LABELS[document]} / {engine}", flush=True)
workspace = output / "work" / document / engine
log_dir = output / "logs" / document / engine
safe_remove_tree(log_dir, output)

if not args.no_warmup:
source = prepare_workspace(document, workspace, output, args.units)
print(" 预热编译", flush=True)
run_build(engine, source, workspace, log_dir / "warmup.log")

for run in range(1, args.runs + 1):
source = prepare_workspace(document, workspace, output, args.units)
print(f" 从零编译 {run}/{args.runs}", flush=True)
elapsed, pages, size = run_build(
engine, source, workspace, log_dir / f"cold-{run}.log"
)
measurements.append(
Measurement(document, engine, "cold", run, elapsed, pages, size)
)

source = workspace / "source"
for run in range(1, args.runs + 1):
variant = source / "mutable.tex"
write_text_lf(
variant, f"\\def\\BenchmarkVariant{{incremental-{run:03d}}}\n"
)
print(f" 增量编译 {run}/{args.runs}", flush=True)
elapsed, pages, size = run_build(
engine,
source,
workspace,
log_dir / f"incremental-{run}.log",
force=True,
)
measurements.append(
Measurement(document, engine, "incremental", run, elapsed, pages, size)
)

final_pdf = output / "test-pdfs" / f"{document}-{engine}.pdf"
final_pdf.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(workspace / "main.pdf", final_pdf)

summaries = summarize(measurements)
payload = {
"metadata": metadata,
"summary": [asdict(item) for item in summaries],
"measurements": [asdict(item) for item in measurements],
}
write_text_lf(
output / "results.json",
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
)
print_key_findings(summaries, tested_documents, tested_engines)

if not args.no_report:
report_tex = output / "report" / "latex-engine-benchmark-report.tex"
generate_report_tex(
report_tex,
measurements,
summaries,
metadata,
tested_engines,
tested_documents,
)
pdf = compile_report(report_tex, output)
print(f"报告:{pdf}")
print(f"数据:{output / 'results.json'}")
return 0


if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\n测试已由用户中断。", file=sys.stderr)
raise SystemExit(130)
except Exception as exc:
print(f"错误:{exc}", file=sys.stderr)
raise SystemExit(1)