__init__.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. import argparse
  2. import os
  3. import sys
  4. import shutil
  5. import subprocess
  6. from typing import Iterable, Iterator, List, Optional, Text, Tuple
  7. from .color import color_unified_diff_line
  8. from .diff import run_external_diff, u_diff
  9. from .utils import file_exists, get_tables_argument_list
  10. def pipe_output(output: str) -> None:
  11. """Pipes output to a pager if stdout is a TTY and a pager is available."""
  12. if not output:
  13. return
  14. if not sys.stdout.isatty():
  15. sys.stdout.write(output)
  16. return
  17. pager = os.getenv("PAGER") or shutil.which("less")
  18. if not pager:
  19. sys.stdout.write(output)
  20. return
  21. pager_cmd = [pager]
  22. if "less" in os.path.basename(pager):
  23. pager_cmd.append("-R")
  24. proc = subprocess.Popen(pager_cmd, stdin=subprocess.PIPE, text=True)
  25. try:
  26. proc.stdin.write(output)
  27. proc.stdin.close()
  28. proc.wait()
  29. except (BrokenPipeError, KeyboardInterrupt):
  30. # Pager process was terminated before all output was written.
  31. # This is not an error. The main exception handler will deal with it.
  32. if proc.stdin:
  33. proc.stdin.close()
  34. # The process might still be running, but we have closed our side of the
  35. # pipe. The Popen destructor will send a SIGKILL to the child.
  36. except Exception:
  37. if proc.stdin:
  38. proc.stdin.close()
  39. raise
  40. def _is_gnu_diff(diff_tool: str) -> bool:
  41. """Returns True if the provided diff executable is GNU diff."""
  42. try:
  43. proc = subprocess.run(
  44. [diff_tool, "--version"],
  45. stdout=subprocess.PIPE,
  46. stderr=subprocess.PIPE,
  47. text=True,
  48. )
  49. except OSError:
  50. return False
  51. version_output = (proc.stdout or "") + (proc.stderr or "")
  52. return "GNU diffutils" in version_output
  53. def _iter_filtered_table_tags(
  54. tags: Iterable[str],
  55. include_tables: Optional[List[str]] = None,
  56. exclude_tables: Optional[List[str]] = None,
  57. ) -> Iterator[str]:
  58. for tag in tags:
  59. if exclude_tables and tag in exclude_tables:
  60. continue
  61. if include_tables and tag not in include_tables:
  62. continue
  63. yield tag
  64. def summarize(
  65. file1: str,
  66. file2: str,
  67. include_tables: Optional[List[str]] = None,
  68. exclude_tables: Optional[List[str]] = None,
  69. font_number_1: int = -1,
  70. font_number_2: int = -1,
  71. ) -> Tuple[bool, str]:
  72. from fontTools.ttLib import TTFont
  73. with (
  74. TTFont(file1, lazy=True, fontNumber=font_number_1) as font1,
  75. TTFont(file2, lazy=True, fontNumber=font_number_2) as font2,
  76. ):
  77. tags1 = {str(tag) for tag in font1.reader.keys()}
  78. tags2 = {str(tag) for tag in font2.reader.keys()}
  79. all_tags = sorted(
  80. set(
  81. _iter_filtered_table_tags(
  82. tags1 | tags2,
  83. include_tables=include_tables,
  84. exclude_tables=exclude_tables,
  85. )
  86. )
  87. )
  88. only1 = [tag for tag in all_tags if tag in tags1 and tag not in tags2]
  89. only2 = [tag for tag in all_tags if tag in tags2 and tag not in tags1]
  90. both = [tag for tag in all_tags if tag in tags1 and tag in tags2]
  91. identical = True
  92. lines: List[str] = []
  93. lines.append(f"Binary table summary:\n")
  94. lines.append(f" file1: {file1}\n")
  95. lines.append(f" file2: {file2}\n")
  96. if only1:
  97. identical = False
  98. lines.append(f"\nTables only in file1 ({len(only1)}):\n")
  99. for tag in only1:
  100. lines.append(f"- {tag} ({len(font1.reader[tag])} bytes)\n")
  101. if only2:
  102. identical = False
  103. lines.append(f"\nTables only in file2 ({len(only2)}):\n")
  104. for tag in only2:
  105. lines.append(f"+ {tag} ({len(font2.reader[tag])} bytes)\n")
  106. lines.append(f"\nTables in both ({len(both)}):\n")
  107. for tag in both:
  108. data1 = font1.reader[tag]
  109. data2 = font2.reader[tag]
  110. if data1 == data2:
  111. lines.append(f" {tag}: SAME ({len(data1)} bytes)\n")
  112. else:
  113. identical = False
  114. lines.append(f"* {tag}: DIFF ({len(data1)} vs {len(data2)} bytes)\n")
  115. if identical:
  116. lines.append("\nResult: SAME\n")
  117. else:
  118. lines.append("\nResult: DIFFERENT\n")
  119. return identical, "".join(lines)
  120. def get_binary_exclude_tables(
  121. file1: str,
  122. file2: str,
  123. include_tables: Optional[List[str]] = None,
  124. exclude_tables: Optional[List[str]] = None,
  125. font_number_1: int = -1,
  126. font_number_2: int = -1,
  127. ) -> Tuple[bool, str]:
  128. from fontTools.ttLib import TTFont
  129. with (
  130. TTFont(file1, lazy=True, fontNumber=font_number_1) as font1,
  131. TTFont(file2, lazy=True, fontNumber=font_number_2) as font2,
  132. ):
  133. tags1 = {str(tag) for tag in font1.reader.keys()}
  134. tags2 = {str(tag) for tag in font2.reader.keys()}
  135. all_tags = sorted(
  136. set(
  137. _iter_filtered_table_tags(
  138. tags1 | tags2,
  139. include_tables=include_tables,
  140. exclude_tables=exclude_tables,
  141. )
  142. )
  143. )
  144. both = [tag for tag in all_tags if tag in tags1 and tag in tags2]
  145. out = set()
  146. for tag in both:
  147. data1 = font1.reader[tag]
  148. data2 = font2.reader[tag]
  149. if data1 == data2:
  150. out.add(tag)
  151. return out
  152. def main():
  153. """Compare two fonts for differences"""
  154. # try/except block rationale:
  155. # handles "premature" socket closure exception that is
  156. # raised by Python when stdout is piped to tools like
  157. # the `head` executable and socket is closed early
  158. # see: https://docs.python.org/3/library/signal.html#note-on-sigpipe
  159. ret = 0
  160. try:
  161. ret = run(sys.argv[1:])
  162. except KeyboardInterrupt:
  163. pass
  164. except BrokenPipeError:
  165. # Python flushes standard streams on exit; redirect remaining output
  166. # to devnull to avoid another BrokenPipeError at shutdown
  167. devnull = os.open(os.devnull, os.O_WRONLY)
  168. os.dup2(devnull, sys.stdout.fileno())
  169. return ret
  170. def run(argv: List[Text]):
  171. # ------------------------------------------
  172. # argparse command line argument definitions
  173. # ------------------------------------------
  174. parser = argparse.ArgumentParser(
  175. description="An OpenType table diff tool for fonts."
  176. )
  177. parser.add_argument(
  178. "-l",
  179. "--summary",
  180. action="store_true",
  181. help="Report table presence and binary equality only",
  182. )
  183. parser.add_argument(
  184. "-U",
  185. "--lines",
  186. type=int,
  187. default=3,
  188. help="Number of context lines for unified diff (default: 3)",
  189. )
  190. parser.add_argument(
  191. "-t",
  192. "--include",
  193. type=str,
  194. nargs="+",
  195. default=None,
  196. help="Font tables to include. Multiple options are allowed.",
  197. )
  198. parser.add_argument(
  199. "-x",
  200. "--exclude",
  201. type=str,
  202. nargs="+",
  203. default=None,
  204. help="Font tables to exclude. Multiple options are allowed.",
  205. )
  206. parser.add_argument(
  207. "--diff", type=str, help="Run external diff tool command (default: diff)"
  208. )
  209. parser.add_argument(
  210. "--diff-arg",
  211. type=str,
  212. default=None,
  213. help="External diff tool arguments (default: -u)",
  214. )
  215. parser.add_argument(
  216. "--color",
  217. choices=["auto", "never", "always"],
  218. default="auto",
  219. help="Whether to colorize output (default: auto)",
  220. )
  221. parser.add_argument(
  222. "--y1",
  223. type=int,
  224. default=-1,
  225. metavar="NUMBER",
  226. help="Select font number for TrueType Collection (.ttc/.otc) FILE1, starting from 0",
  227. )
  228. parser.add_argument(
  229. "--y2",
  230. type=int,
  231. default=-1,
  232. metavar="NUMBER",
  233. help="Select font number for TrueType Collection (.ttc/.otc) FILE2, starting from 0",
  234. )
  235. parser.add_argument(
  236. "-a",
  237. "--always",
  238. action="store_true",
  239. help="Compare tables even if binary identical",
  240. )
  241. parser.add_argument(
  242. "-b",
  243. "--binary",
  244. action="store_true",
  245. help="Compare tables only if binaries differ (default)",
  246. )
  247. parser.add_argument(
  248. "-q", "--quiet", action="store_true", help="Suppress all output"
  249. )
  250. parser.add_argument("FILE1", help="Font file path 1")
  251. parser.add_argument("FILE2", help="Font file path 2")
  252. args: argparse.Namespace = parser.parse_args(argv)
  253. # /////////////////////////////////////////////////////////
  254. #
  255. # Validations
  256. #
  257. # /////////////////////////////////////////////////////////
  258. # ----------------------------------
  259. # Incompatible argument validations
  260. # ----------------------------------
  261. if args.always and args.binary:
  262. if not args.quiet:
  263. sys.stderr.write(
  264. f"[*] Error: --always and --binary are mutually exclusive options. "
  265. f"Please use ONLY one of these options in your command.{os.linesep}"
  266. )
  267. return 2
  268. if not args.always:
  269. args.binary = True
  270. # -------------------------------
  271. # File path argument validations
  272. # -------------------------------
  273. if not file_exists(args.FILE1):
  274. if not args.quiet:
  275. sys.stderr.write(
  276. f"[*] ERROR: The file path '{args.FILE1}' can not be found.{os.linesep}"
  277. )
  278. return 2
  279. if not file_exists(args.FILE2):
  280. if not args.quiet:
  281. sys.stderr.write(
  282. f"[*] ERROR: The file path '{args.FILE2}' can not be found.{os.linesep}"
  283. )
  284. return 2
  285. # /////////////////////////////////////////////////////////
  286. #
  287. # Command line logic
  288. #
  289. # /////////////////////////////////////////////////////////
  290. # parse explicitly included or excluded tables in
  291. # the command line arguments
  292. # set as a Python list if it was defined on the command line
  293. # or as None if it was not set on the command line
  294. include_list: Optional[List[Text]] = get_tables_argument_list(args.include)
  295. exclude_list: Optional[List[Text]] = get_tables_argument_list(args.exclude)
  296. if args.summary:
  297. try:
  298. identical, output = summarize(
  299. args.FILE1,
  300. args.FILE2,
  301. include_tables=include_list,
  302. exclude_tables=exclude_list,
  303. font_number_1=args.y1,
  304. font_number_2=args.y2,
  305. )
  306. if not args.quiet:
  307. sys.stdout.write(output)
  308. return 0 if identical else 1
  309. except Exception as e:
  310. if not args.quiet:
  311. sys.stderr.write(f"[*] ERROR: {e}{os.linesep}")
  312. return 2
  313. if args.binary:
  314. excluded_binary_tables = get_binary_exclude_tables(
  315. args.FILE1,
  316. args.FILE2,
  317. include_tables=include_list,
  318. exclude_tables=exclude_list,
  319. font_number_1=args.y1,
  320. font_number_2=args.y2,
  321. )
  322. if include_list is not None:
  323. include_list = [
  324. tag for tag in include_list if tag not in excluded_binary_tables
  325. ]
  326. else:
  327. if exclude_list is None:
  328. exclude_list = []
  329. exclude_list.extend(sorted(excluded_binary_tables))
  330. diff_tool = args.diff
  331. color_output = args.color == "always" or (
  332. args.color == "auto" and sys.stdout.isatty
  333. )
  334. if diff_tool is None:
  335. diff_tool = shutil.which("diff")
  336. elif diff_tool:
  337. diff_tool = shutil.which(diff_tool)
  338. if diff_tool is None:
  339. if not args.quiet:
  340. sys.stderr.write(
  341. f"[*] ERROR: The external diff tool executable "
  342. f"'{args.diff}' was not found.{os.linesep}"
  343. )
  344. return 2
  345. try:
  346. if diff_tool:
  347. diff_arg = args.diff_arg
  348. if diff_arg is None:
  349. if args.lines == 3:
  350. diff_arg = ["-u"]
  351. else:
  352. diff_arg = ["-u{}".format(args.lines)]
  353. if _is_gnu_diff(diff_tool):
  354. diff_arg.append(r"-F^\s\s<")
  355. else:
  356. diff_arg = diff_arg.split()
  357. output = run_external_diff(
  358. diff_tool,
  359. diff_arg,
  360. args.FILE1,
  361. args.FILE2,
  362. include_tables=include_list,
  363. exclude_tables=exclude_list,
  364. font_number_a=args.y1,
  365. font_number_b=args.y2,
  366. use_multiprocess=True,
  367. )
  368. else:
  369. output = u_diff(
  370. args.FILE1,
  371. args.FILE2,
  372. context_lines=args.lines,
  373. include_tables=include_list,
  374. exclude_tables=exclude_list,
  375. font_number_a=args.y1,
  376. font_number_b=args.y2,
  377. use_multiprocess=True,
  378. )
  379. if color_output:
  380. output = [color_unified_diff_line(line) for line in output]
  381. output = "".join(output)
  382. if not args.quiet:
  383. pipe_output(output)
  384. return 1 if output else 0
  385. except Exception as e:
  386. if not args.quiet:
  387. sys.stderr.write(f"[*] ERROR: {e}{os.linesep}")
  388. return 2