This commit is contained in:
2026-05-06 19:47:31 +07:00
parent 94d8682530
commit 12dbb7731b
9963 changed files with 2747894 additions and 0 deletions
@@ -0,0 +1,441 @@
import argparse
import os
import sys
import shutil
import subprocess
from typing import Iterable, Iterator, List, Optional, Text, Tuple
from .color import color_unified_diff_line
from .diff import run_external_diff, u_diff
from .utils import file_exists, get_tables_argument_list
def pipe_output(output: str) -> None:
"""Pipes output to a pager if stdout is a TTY and a pager is available."""
if not output:
return
if not sys.stdout.isatty():
sys.stdout.write(output)
return
pager = os.getenv("PAGER") or shutil.which("less")
if not pager:
sys.stdout.write(output)
return
pager_cmd = [pager]
if "less" in os.path.basename(pager):
pager_cmd.append("-R")
proc = subprocess.Popen(pager_cmd, stdin=subprocess.PIPE, text=True)
try:
proc.stdin.write(output)
proc.stdin.close()
proc.wait()
except (BrokenPipeError, KeyboardInterrupt):
# Pager process was terminated before all output was written.
# This is not an error. The main exception handler will deal with it.
if proc.stdin:
proc.stdin.close()
# The process might still be running, but we have closed our side of the
# pipe. The Popen destructor will send a SIGKILL to the child.
except Exception:
if proc.stdin:
proc.stdin.close()
raise
def _is_gnu_diff(diff_tool: str) -> bool:
"""Returns True if the provided diff executable is GNU diff."""
try:
proc = subprocess.run(
[diff_tool, "--version"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
except OSError:
return False
version_output = (proc.stdout or "") + (proc.stderr or "")
return "GNU diffutils" in version_output
def _iter_filtered_table_tags(
tags: Iterable[str],
include_tables: Optional[List[str]] = None,
exclude_tables: Optional[List[str]] = None,
) -> Iterator[str]:
for tag in tags:
if exclude_tables and tag in exclude_tables:
continue
if include_tables and tag not in include_tables:
continue
yield tag
def summarize(
file1: str,
file2: str,
include_tables: Optional[List[str]] = None,
exclude_tables: Optional[List[str]] = None,
font_number_1: int = -1,
font_number_2: int = -1,
) -> Tuple[bool, str]:
from fontTools.ttLib import TTFont
with (
TTFont(file1, lazy=True, fontNumber=font_number_1) as font1,
TTFont(file2, lazy=True, fontNumber=font_number_2) as font2,
):
tags1 = {str(tag) for tag in font1.reader.keys()}
tags2 = {str(tag) for tag in font2.reader.keys()}
all_tags = sorted(
set(
_iter_filtered_table_tags(
tags1 | tags2,
include_tables=include_tables,
exclude_tables=exclude_tables,
)
)
)
only1 = [tag for tag in all_tags if tag in tags1 and tag not in tags2]
only2 = [tag for tag in all_tags if tag in tags2 and tag not in tags1]
both = [tag for tag in all_tags if tag in tags1 and tag in tags2]
identical = True
lines: List[str] = []
lines.append(f"Binary table summary:\n")
lines.append(f" file1: {file1}\n")
lines.append(f" file2: {file2}\n")
if only1:
identical = False
lines.append(f"\nTables only in file1 ({len(only1)}):\n")
for tag in only1:
lines.append(f"- {tag} ({len(font1.reader[tag])} bytes)\n")
if only2:
identical = False
lines.append(f"\nTables only in file2 ({len(only2)}):\n")
for tag in only2:
lines.append(f"+ {tag} ({len(font2.reader[tag])} bytes)\n")
lines.append(f"\nTables in both ({len(both)}):\n")
for tag in both:
data1 = font1.reader[tag]
data2 = font2.reader[tag]
if data1 == data2:
lines.append(f" {tag}: SAME ({len(data1)} bytes)\n")
else:
identical = False
lines.append(f"* {tag}: DIFF ({len(data1)} vs {len(data2)} bytes)\n")
if identical:
lines.append("\nResult: SAME\n")
else:
lines.append("\nResult: DIFFERENT\n")
return identical, "".join(lines)
def get_binary_exclude_tables(
file1: str,
file2: str,
include_tables: Optional[List[str]] = None,
exclude_tables: Optional[List[str]] = None,
font_number_1: int = -1,
font_number_2: int = -1,
) -> Tuple[bool, str]:
from fontTools.ttLib import TTFont
with (
TTFont(file1, lazy=True, fontNumber=font_number_1) as font1,
TTFont(file2, lazy=True, fontNumber=font_number_2) as font2,
):
tags1 = {str(tag) for tag in font1.reader.keys()}
tags2 = {str(tag) for tag in font2.reader.keys()}
all_tags = sorted(
set(
_iter_filtered_table_tags(
tags1 | tags2,
include_tables=include_tables,
exclude_tables=exclude_tables,
)
)
)
both = [tag for tag in all_tags if tag in tags1 and tag in tags2]
out = set()
for tag in both:
data1 = font1.reader[tag]
data2 = font2.reader[tag]
if data1 == data2:
out.add(tag)
return out
def main():
"""Compare two fonts for differences"""
# try/except block rationale:
# handles "premature" socket closure exception that is
# raised by Python when stdout is piped to tools like
# the `head` executable and socket is closed early
# see: https://docs.python.org/3/library/signal.html#note-on-sigpipe
ret = 0
try:
ret = run(sys.argv[1:])
except KeyboardInterrupt:
pass
except BrokenPipeError:
# Python flushes standard streams on exit; redirect remaining output
# to devnull to avoid another BrokenPipeError at shutdown
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, sys.stdout.fileno())
return ret
def run(argv: List[Text]):
# ------------------------------------------
# argparse command line argument definitions
# ------------------------------------------
parser = argparse.ArgumentParser(
description="An OpenType table diff tool for fonts."
)
parser.add_argument(
"-l",
"--summary",
action="store_true",
help="Report table presence and binary equality only",
)
parser.add_argument(
"-U",
"--lines",
type=int,
default=3,
help="Number of context lines for unified diff (default: 3)",
)
parser.add_argument(
"-t",
"--include",
type=str,
nargs="+",
default=None,
help="Font tables to include. Multiple options are allowed.",
)
parser.add_argument(
"-x",
"--exclude",
type=str,
nargs="+",
default=None,
help="Font tables to exclude. Multiple options are allowed.",
)
parser.add_argument(
"--diff", type=str, help="Run external diff tool command (default: diff)"
)
parser.add_argument(
"--diff-arg",
type=str,
default=None,
help="External diff tool arguments (default: -u)",
)
parser.add_argument(
"--color",
choices=["auto", "never", "always"],
default="auto",
help="Whether to colorize output (default: auto)",
)
parser.add_argument(
"--y1",
type=int,
default=-1,
metavar="NUMBER",
help="Select font number for TrueType Collection (.ttc/.otc) FILE1, starting from 0",
)
parser.add_argument(
"--y2",
type=int,
default=-1,
metavar="NUMBER",
help="Select font number for TrueType Collection (.ttc/.otc) FILE2, starting from 0",
)
parser.add_argument(
"-a",
"--always",
action="store_true",
help="Compare tables even if binary identical",
)
parser.add_argument(
"-b",
"--binary",
action="store_true",
help="Compare tables only if binaries differ (default)",
)
parser.add_argument(
"-q", "--quiet", action="store_true", help="Suppress all output"
)
parser.add_argument("FILE1", help="Font file path 1")
parser.add_argument("FILE2", help="Font file path 2")
args: argparse.Namespace = parser.parse_args(argv)
# /////////////////////////////////////////////////////////
#
# Validations
#
# /////////////////////////////////////////////////////////
# ----------------------------------
# Incompatible argument validations
# ----------------------------------
if args.always and args.binary:
if not args.quiet:
sys.stderr.write(
f"[*] Error: --always and --binary are mutually exclusive options. "
f"Please use ONLY one of these options in your command.{os.linesep}"
)
return 2
if not args.always:
args.binary = True
# -------------------------------
# File path argument validations
# -------------------------------
if not file_exists(args.FILE1):
if not args.quiet:
sys.stderr.write(
f"[*] ERROR: The file path '{args.FILE1}' can not be found.{os.linesep}"
)
return 2
if not file_exists(args.FILE2):
if not args.quiet:
sys.stderr.write(
f"[*] ERROR: The file path '{args.FILE2}' can not be found.{os.linesep}"
)
return 2
# /////////////////////////////////////////////////////////
#
# Command line logic
#
# /////////////////////////////////////////////////////////
# parse explicitly included or excluded tables in
# the command line arguments
# set as a Python list if it was defined on the command line
# or as None if it was not set on the command line
include_list: Optional[List[Text]] = get_tables_argument_list(args.include)
exclude_list: Optional[List[Text]] = get_tables_argument_list(args.exclude)
if args.summary:
try:
identical, output = summarize(
args.FILE1,
args.FILE2,
include_tables=include_list,
exclude_tables=exclude_list,
font_number_1=args.y1,
font_number_2=args.y2,
)
if not args.quiet:
sys.stdout.write(output)
return 0 if identical else 1
except Exception as e:
if not args.quiet:
sys.stderr.write(f"[*] ERROR: {e}{os.linesep}")
return 2
if args.binary:
excluded_binary_tables = get_binary_exclude_tables(
args.FILE1,
args.FILE2,
include_tables=include_list,
exclude_tables=exclude_list,
font_number_1=args.y1,
font_number_2=args.y2,
)
if include_list is not None:
include_list = [
tag for tag in include_list if tag not in excluded_binary_tables
]
else:
if exclude_list is None:
exclude_list = []
exclude_list.extend(sorted(excluded_binary_tables))
diff_tool = args.diff
color_output = args.color == "always" or (
args.color == "auto" and sys.stdout.isatty
)
if diff_tool is None:
diff_tool = shutil.which("diff")
elif diff_tool:
diff_tool = shutil.which(diff_tool)
if diff_tool is None:
if not args.quiet:
sys.stderr.write(
f"[*] ERROR: The external diff tool executable "
f"'{args.diff}' was not found.{os.linesep}"
)
return 2
try:
if diff_tool:
diff_arg = args.diff_arg
if diff_arg is None:
if args.lines == 3:
diff_arg = ["-u"]
else:
diff_arg = ["-u{}".format(args.lines)]
if _is_gnu_diff(diff_tool):
diff_arg.append(r"-F^\s\s<")
else:
diff_arg = diff_arg.split()
output = run_external_diff(
diff_tool,
diff_arg,
args.FILE1,
args.FILE2,
include_tables=include_list,
exclude_tables=exclude_list,
font_number_a=args.y1,
font_number_b=args.y2,
use_multiprocess=True,
)
else:
output = u_diff(
args.FILE1,
args.FILE2,
context_lines=args.lines,
include_tables=include_list,
exclude_tables=exclude_list,
font_number_a=args.y1,
font_number_b=args.y2,
use_multiprocess=True,
)
if color_output:
output = [color_unified_diff_line(line) for line in output]
output = "".join(output)
if not args.quiet:
pipe_output(output)
return 1 if output else 0
except Exception as e:
if not args.quiet:
sys.stderr.write(f"[*] ERROR: {e}{os.linesep}")
return 2
@@ -0,0 +1,6 @@
import sys
from fontTools.diff import main
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,44 @@
from typing import Dict, Text
ansicolors: Dict[Text, Text] = {
"BLACK": "\033[30m",
"RED": "\033[31m",
"GREEN": "\033[32m",
"YELLOW": "\033[33m",
"BLUE": "\033[34m",
"MAGENTA": "\033[35m",
"CYAN": "\033[36m",
"WHITE": "\033[37m",
"BOLD": "\033[1m",
"RESET": "\033[0m",
}
green_start: Text = ansicolors["GREEN"]
red_start: Text = ansicolors["RED"]
cyan_start: Text = ansicolors["CYAN"]
reset: Text = ansicolors["RESET"]
def color_unified_diff_line(line: Text) -> Text:
"""Returns an ANSI escape code colored string with color based
on the unified diff line type."""
if line[0:2] == "+ ":
return f"{green_start}{line}{reset}"
elif line == "+\n":
# some lines are formatted as hyphen only with no other characters
# this indicates an added empty line
return f"{green_start}{line}{reset}"
elif line[0:2] == "- ":
return f"{red_start}{line}{reset}"
elif line == "-\n":
# some lines are formatted as hyphen only with no other characters
# this indicates a deleted empty line
return f"{red_start}{line}{reset}"
elif line[0:3] == "@@ ":
return f"{cyan_start}{line}{reset}"
elif line[0:4] == "--- ":
return f"{red_start}{line}{reset}"
elif line[0:4] == "+++ ":
return f"{green_start}{line}{reset}"
else:
return line
@@ -0,0 +1,294 @@
import os
import subprocess
import tempfile
from contextlib import contextmanager
from difflib import unified_diff
from multiprocessing import Pool, cpu_count
from typing import Any, Callable, Iterable, Iterator, List, Optional, Text, Tuple
from fontTools.ttLib import TTFont # type: ignore
from .utils import get_file_modtime
#
#
# Private functions
#
#
def _get_fonts_and_save_xml(
filepath_a: Text,
filepath_b: Text,
tmpdirpath: Text,
include_tables: Optional[List[Text]],
exclude_tables: Optional[List[Text]],
font_number_a: int,
font_number_b: int,
use_multiprocess: bool,
) -> Tuple[Text, Text, Text, Text, Text, Text]:
post_pathname, postpath, pre_pathname, prepath = _get_pre_post_paths(
filepath_a, filepath_b
)
# instantiate left and right fontTools.ttLib.TTFont objects
tt_left = TTFont(prepath, fontNumber=font_number_a)
tt_right = TTFont(postpath, fontNumber=font_number_b)
left_ttxpath = os.path.join(tmpdirpath, "left.ttx")
right_ttxpath = os.path.join(tmpdirpath, "right.ttx")
_mp_save_ttx_xml(
tt_left,
tt_right,
left_ttxpath,
right_ttxpath,
exclude_tables,
include_tables,
use_multiprocess,
)
return left_ttxpath, right_ttxpath, pre_pathname, prepath, post_pathname, postpath
def _get_pre_post_paths(
filepath_a: Text,
filepath_b: Text,
) -> Tuple[Text, Text, Text, Text]:
prepath = filepath_a
postpath = filepath_b
pre_pathname = filepath_a
post_pathname = filepath_b
return post_pathname, postpath, pre_pathname, prepath
def _mp_save_ttx_xml(
tt_left: Any,
tt_right: Any,
left_ttxpath: Text,
right_ttxpath: Text,
exclude_tables: Optional[List[Text]],
include_tables: Optional[List[Text]],
use_multiprocess: bool,
) -> None:
if use_multiprocess and cpu_count() > 1:
# Use parallel fontTools.ttLib.TTFont.saveXML dump
# by default on multi CPU systems. This is a performance
# optimization. Profiling demonstrates that this can reduce
# execution time by up to 30% for some fonts
mp_args_list = [
(tt_left, left_ttxpath, include_tables, exclude_tables),
(tt_right, right_ttxpath, include_tables, exclude_tables),
]
with Pool(processes=2) as pool:
pool.starmap(_ttfont_save_xml, mp_args_list)
else:
# use sequential fontTools.ttLib.TTFont.saveXML dumps
# when use_multiprocess is False or single CPU system
# detected
_ttfont_save_xml(tt_left, left_ttxpath, include_tables, exclude_tables)
_ttfont_save_xml(tt_right, right_ttxpath, include_tables, exclude_tables)
def _ttfont_save_xml(
ttf: Any,
filepath: Text,
include_tables: Optional[List[Text]],
exclude_tables: Optional[List[Text]],
) -> bool:
"""Writes TTX specification formatted XML to disk on filepath."""
ttf.saveXML(filepath, tables=include_tables, skipTables=exclude_tables)
return True
@contextmanager
def _saved_ttx_files(
filepath_a: Text,
filepath_b: Text,
include_tables: Optional[List[Text]],
exclude_tables: Optional[List[Text]],
font_number_a: int,
font_number_b: int,
use_multiprocess: bool,
) -> Iterator[Tuple[Text, Text, Text, Text, Text, Text]]:
with tempfile.TemporaryDirectory() as tmpdirpath:
yield _get_fonts_and_save_xml(
filepath_a,
filepath_b,
tmpdirpath,
include_tables,
exclude_tables,
font_number_a,
font_number_b,
use_multiprocess,
)
def _diff_with_saved_ttx_files(
filepath_a: Text,
filepath_b: Text,
include_tables: Optional[List[Text]],
exclude_tables: Optional[List[Text]],
font_number_a: int,
font_number_b: int,
use_multiprocess: bool,
create_differ: Callable[[Text, Text, Text, Text, Text, Text], Iterable[Text]],
) -> Iterator[Text]:
with _saved_ttx_files(
filepath_a,
filepath_b,
include_tables,
exclude_tables,
font_number_a,
font_number_b,
use_multiprocess,
) as (
left_ttxpath,
right_ttxpath,
pre_pathname,
prepath,
post_pathname,
postpath,
):
yield from create_differ(
left_ttxpath,
right_ttxpath,
pre_pathname,
prepath,
post_pathname,
postpath,
)
#
#
# Public functions
#
#
def u_diff(
filepath_a: Text,
filepath_b: Text,
context_lines: int = 3,
include_tables: Optional[List[Text]] = None,
exclude_tables: Optional[List[Text]] = None,
font_number_a: int = -1,
font_number_b: int = -1,
use_multiprocess: bool = True,
) -> Iterator[Text]:
"""Performs a unified diff on a TTX serialized data format dump of font binary data using
a modified version of the Python standard libary difflib module.
filepath_a: (string) pre-file local file path
filepath_b: (string) post-file local file path
context_lines: (int) number of context lines to include in the diff (default=3)
include_tables: (list of str) Python list of OpenType tables to include in the diff
exclude_tables: (list of str) Python list of OpentType tables to exclude from the diff
use_multiprocess: (bool) use multi-processor optimizations (default=True)
include_tables and exclude_tables are mutually exclusive arguments. Only one should
be defined
:returns: Generator of ordered diff line strings that include newline line endings
:raises: KeyError if include_tables or exclude_tables includes a mis-specified table
that is not included in filepath_a OR filepath_b
"""
def _create_unified_diff(
left_ttxpath: Text,
right_ttxpath: Text,
pre_pathname: Text,
prepath: Text,
post_pathname: Text,
postpath: Text,
) -> Iterable[Text]:
with open(left_ttxpath) as ff:
fromlines = ff.readlines()
with open(right_ttxpath) as tf:
tolines = tf.readlines()
fromdate = get_file_modtime(prepath)
todate = get_file_modtime(postpath)
yield from unified_diff(
fromlines,
tolines,
pre_pathname,
post_pathname,
fromdate,
todate,
n=context_lines,
)
yield from _diff_with_saved_ttx_files(
filepath_a,
filepath_b,
include_tables,
exclude_tables,
font_number_a,
font_number_b,
use_multiprocess,
_create_unified_diff,
)
def run_external_diff(
diff_tool: Text,
diff_args: List[Text],
filepath_a: Text,
filepath_b: Text,
include_tables: Optional[List[Text]] = None,
exclude_tables: Optional[List[Text]] = None,
font_number_a: int = -1,
font_number_b: int = -1,
use_multiprocess: bool = True,
) -> Iterator[Text]:
"""Performs a unified diff on a TTX serialized data format dump of font binary data using
an external diff executable that is requested by the caller via `command`
diff_tool: (string) command line executable string
diff_args: (list of strings) arguments for the diff tool
filepath_a: (string) pre-file local file path
filepath_b: (string) post-file local file path
include_tables: (list of str) Python list of OpenType tables to include in the diff
exclude_tables: (list of str) Python list of OpentType tables to exclude from the diff
use_multiprocess: (bool) use multi-processor optimizations (default=True)
include_tables and exclude_tables are mutually exclusive arguments. Only one should
be defined
:returns: Generator of ordered diff line strings that include newline line endings
:raises: KeyError if include_tables or exclude_tables includes a mis-specified table
that is not included in filepath_a OR filepath_b
:raises: IOError if exception raised during execution of `command` on TTX files
"""
def _create_external_diff(
left_ttxpath: Text,
right_ttxpath: Text,
_pre_pathname: Text,
_prepath: Text,
_post_pathname: Text,
_postpath: Text,
) -> Iterable[Text]:
command = [diff_tool] + diff_args + [left_ttxpath, right_ttxpath]
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
encoding="utf8",
)
for line in process.stdout:
yield line
err = process.stderr.read()
if err:
raise IOError(err)
yield from _diff_with_saved_ttx_files(
filepath_a,
filepath_b,
include_tables,
exclude_tables,
font_number_a,
font_number_b,
use_multiprocess,
_create_external_diff,
)
@@ -0,0 +1,28 @@
import os
from datetime import datetime, timezone
from typing import List, Optional, Text, Union
def file_exists(path: Union[bytes, str, "os.PathLike[Text]"]) -> bool:
"""Validates file path as existing local file"""
return os.path.isfile(path)
def get_file_modtime(path: Union[bytes, str, "os.PathLike[Text]"]) -> Text:
"""Returns ISO formatted file modification time in local system timezone"""
return (
datetime.fromtimestamp(os.stat(path).st_mtime, timezone.utc)
.astimezone()
.isoformat()
)
def get_tables_argument_list(table_list: Optional[List[Text]]) -> Optional[List[Text]]:
"""Converts a list of OpenType table string into a Python list or
return None if the table_list was not defined (i.e., it was not included
in an option on the command line). Tables that are composed of three
characters must be right padded with a space."""
if table_list is None:
return None
else:
return [table.ljust(4) for table in table_list]