442 lines
13 KiB
Python
442 lines
13 KiB
Python
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
|