k-l-lambda's picture
added node-addon-lilypond
f65fe85
# output-distance.py
# -*- coding: utf-8 -*-
#
# This file is part of LilyPond, the GNU music typesetter.
#
# Copyright (C) 2006--2020 Han-Wen Nienhuys <[email protected]>
#
# LilyPond is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# LilyPond is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with LilyPond. If not, see <http://www.gnu.org/licenses/>.
import codecs
import difflib
import errno
import functools
import glob
import html
import math
import optparse
import os
import re
import subprocess
import sys
import tempfile
import time
# so we can call directly as scripts/build/output-distance.py
me_path = os.path.abspath(os.path.split(sys.argv[0])[0])
sys.path.insert(0, me_path + '/../../python/')
# Keep our includes after adapting sys.path above.
import midi
X_AXIS = 0
Y_AXIS = 1
INFTY = 1e6
ORPHAN_GROB_PENALTY = 1
options = None
def log_terse(s):
if not options.verbose:
print(s)
def log_verbose(s):
if options.verbose:
print(s)
################################################################
# system interface.
temp_dir = None
class TempDirectory:
def __init__(self):
self.dir = tempfile.mkdtemp()
log_verbose('dir is %s' % self.dir)
def __del__(self):
log_verbose('rm -rf %s' % self.dir)
os.system('rm -rf %s' % self.dir)
def __call__(self):
return self.dir
def get_temp_dir():
global temp_dir
if not temp_dir:
temp_dir = TempDirectory()
return temp_dir()
def read_pipe(c):
log_verbose('pipe %s' % c)
return os.popen(c).read()
def system(c):
log_verbose('system %s' % c)
# explicitly use bash, so we don't get dash on Ubuntu.
subprocess.run(["/bin/bash", "-c", c.encode('utf-8')])
def system_allow_exit1(x):
log_verbose('invoking %s' % x)
stat = os.system(x)
# This return value convention is sick.
assert (stat == 0) or (stat == 256)
def shorten_string(s, threshold=15):
if len(s) > 2*threshold:
s = s[:threshold] + '..' + s[-threshold:]
return s
def max_distance(x1, x2):
dist = 0.0
for (p, q) in zip(x1, x2):
dist = max(abs(p-q), dist)
return dist
def compare_png_images(old, new, dest_dir):
def png_dims(f):
m = re.search('([0-9]+) x ([0-9]+)', read_pipe('file %s' % f))
return tuple(map(int, m.groups()))
dest = os.path.join(dest_dir, new.replace('.png', '.compare.jpeg'))
try:
dims1 = png_dims(old)
dims2 = png_dims(new)
except AttributeError:
# hmmm. what to do?
system('touch %(dest)s' % locals())
return
dims = (min(dims1[0], dims2[0]),
min(dims1[1], dims2[1]))
dir = get_temp_dir()
# Removing the ICC profile with -strip suppresses the warning
# "RGB color space not permitted on grayscale PNG."
system('convert -strip -depth 8 -crop %dx%d+0+0 %s %s/crop1.png' %
(dims + (old, dir)))
system('convert -strip -depth 8 -crop %dx%d+0+0 %s %s/crop2.png' %
(dims + (new, dir)))
system_allow_exit1(
'compare -depth 8 -dissimilarity-threshold 1 %(dir)s/crop1.png %(dir)s/crop2.png %(dir)s/diff.png' % locals())
system("convert -depth 8 %(dir)s/diff.png -blur 0x3 -negate -channel alpha,blue -type TrueColorMatte -fx 'intensity' %(dir)s/matte.png" % locals())
system("composite -compose atop -quality 65 %(dir)s/matte.png %(new)s %(dest)s" % locals())
################################################################
# interval/bbox arithmetic.
empty_interval = (INFTY, -INFTY)
empty_bbox = (empty_interval, empty_interval)
def interval_is_empty(i):
return i[0] > i[1]
def interval_length(i):
return max(i[1]-i[0], 0)
def interval_union(i1, i2):
return (min(i1[0], i2[0]),
max(i1[1], i2[1]))
def interval_intersect(i1, i2):
return (max(i1[0], i2[0]),
min(i1[1], i2[1]))
def bbox_is_empty(b):
return (interval_is_empty(b[0])
or interval_is_empty(b[1]))
def bbox_union(b1, b2):
return (interval_union(b1[X_AXIS], b2[X_AXIS]),
interval_union(b1[Y_AXIS], b2[Y_AXIS]))
def bbox_intersection(b1, b2):
return (interval_intersect(b1[X_AXIS], b2[X_AXIS]),
interval_intersect(b1[Y_AXIS], b2[Y_AXIS]))
def bbox_area(b):
return interval_length(b[X_AXIS]) * interval_length(b[Y_AXIS])
def bbox_diameter(b):
return max(interval_length(b[X_AXIS]),
interval_length(b[Y_AXIS]))
def difference_area(a, b):
return bbox_area(a) - bbox_area(bbox_intersection(a, b))
class GrobSignature:
"""A (grob-name, bbox) tuple"""
def __init__(self, exp_list):
(self.name, bbox_x, bbox_y) = tuple(exp_list)
self.bbox = (bbox_x, bbox_y)
self.centroid = ((bbox_x[0] + bbox_x[1])/2.0,
(bbox_y[0] + bbox_y[1])/2.0)
def __repr__(self):
return '%s: (%.2f,%.2f), (%.2f,%.2f)\n' % (self.name,
self.bbox[0][0],
self.bbox[0][1],
self.bbox[1][0],
self.bbox[1][1])
def centroid_distance(self, other, scale):
return max_distance(self.centroid, other.centroid) / scale
def bbox_distance(self, other):
divisor = bbox_area(self.bbox) + bbox_area(other.bbox)
if divisor:
return (difference_area(self.bbox, other.bbox) +
difference_area(other.bbox, self.bbox)) / divisor
else:
return 0.0
class SystemSignature:
"""Signature for a single System.
Abstracts away from the precise appearance to a list of grob-type => list<GrobSignature>.
"""
def __init__(self, grob_sigs):
d = {}
for g in grob_sigs:
val = d.setdefault(g.name, [])
val += [g]
self.grob_dict = d
self.bbox = empty_bbox
for g in grob_sigs:
self.bbox = bbox_union(g.bbox, self.bbox)
def closest(self, grob_name, centroid):
min_d = INFTY
min_g = None
try:
grobs = self.grob_dict[grob_name]
for g in grobs:
d = max_distance(g.centroid, centroid)
if d < min_d:
min_d = d
min_g = g
return min_g
except KeyError:
return None
def grobs(self):
return functools.reduce(lambda x, y: x+y, list(self.grob_dict.values()), [])
class SystemLink:
"""Compares two Systems through their SystemSignatures."""
def __init__(self, system1, system2):
"""Init from two SystemSignature instances"""
self.system1 = system1
self.system2 = system2
self.link_list_dict = {}
# pairs
self.orphans = []
# pair -> distance
self.geo_distances = {}
# pairs
self.expression_changed = []
self._geometric_distance = None
self._expression_change_count = None
# maps GrobSignature in system1 to its hopefully existing twin in system2.
self.back_link_dict = {}
if self.system1 and self.system2:
# This is quadratic, because closest() has no geometric
# structure to speed up searching. For small snippets,
# this is acceptable.
for g in system1.grobs():
# skip empty bboxes.
if bbox_is_empty(g.bbox):
continue
closest = system2.closest(g.name, g.centroid)
self.link_list_dict.setdefault(closest, [])
self.link_list_dict[closest].append(g)
if closest is not None:
self.back_link_dict[g] = closest
else:
self.orphans.append((g, None))
# find grobs in system2 but not in system1
for g in system2.grobs():
if bbox_is_empty(g.bbox):
continue
if g not in self.link_list_dict:
closest = system1.closest(g.name, g.centroid)
if closest is None:
self.orphans.append((None, g))
def calc_geometric_distance(self):
if self.system1 and self.system2:
total = 0.0
else:
total = 100.0 * (self.system1 != self.system2)
for (g1, g2) in list(self.back_link_dict.items()):
d = g1.bbox_distance(g2)
if d:
self.geo_distances[(g1, g2)] = d
total += d
self._geometric_distance = total
def geo_details_string(self):
results = [(d, g1, g2)
for ((g1, g2), d) in list(self.geo_distances.items())]
# Only compare distances.
results.sort(key=lambda x: -x[0])
return ', '.join(['%s: %f' % (g1.name, d) for (d, g1, g2) in results])
def orphan_details_string(self):
return ', '.join(['%s' % g1.name for (g1, g2) in self.orphans if g2 is None])
def geometric_distance(self):
if self._geometric_distance is None:
self.calc_geometric_distance()
return self._geometric_distance
def orphan_count(self):
return len(self.orphans)
def distance_tuple(self):
return (self.orphan_count(),
self.geometric_distance())
def scheme_float(s):
if 'nan' not in s:
return float(s)
return float(s.split('.')[0])
def read_signature_file(name):
"""Returns SystemSignature or None if file doesn't exist."""
log_verbose('reading %s' % name)
try:
f = open(name, encoding='utf8')
except IOError as e:
if e.errno == errno.ENOENT:
return None
else:
raise
entries = f.read().split('\n')
def string_to_tup(s):
return tuple(map(scheme_float, s.split(' ')))
def string_to_entry(s):
fields = s.split('@')
# Backward compatibility; remove this once we stop comparing
# againsts older versions
if len(fields) == 5:
fields = (fields[0], string_to_tup(
fields[2]), string_to_tup(fields[3]))
else:
fields[1] = string_to_tup(fields[1])
fields[2] = string_to_tup(fields[2])
return tuple(fields)
entries = [string_to_entry(e) for e in entries
if e and not e.startswith('#')]
grob_sigs = [GrobSignature(e) for e in entries]
sig = SystemSignature(grob_sigs)
return sig
################################################################
# different systems of a .ly file.
hash_to_original_name = {}
class FileLink:
"""Base class of files that should be compared."""
def __init__(self, f1, f2):
self._distance = None
self.file_names = (f1, f2)
def text_record_string(self):
return '%-30f %-20s\n' % (self.distance(),
self.name()
+ os.path.splitext(self.file_names[1])[1]
)
def calc_distance(self):
return 0.0
def distance(self):
if self._distance is None:
self._distance = self.calc_distance()
return self._distance
def source_file(self):
"""Returns the corresponding .ly file."""
for ext in ('.ly', '.ly.txt'):
base = os.path.splitext(self.file_names[1])[0]
f = base + ext
if os.path.exists(f):
return f
return ''
def directories(self):
"""Directories of the two files"""
return list(map(os.path.dirname, self.file_names))
def name(self):
"""Returns the \\sourcefilename for this test file"""
base = os.path.basename(self.file_names[1])
base = os.path.splitext(base)[0]
base = hash_to_original_name.get(base, base)
base = os.path.splitext(base)[0]
return os.path.join(self.prefix(), base)
def prefix(self):
return os.path.commonpath(self.file_names)
def extension(self):
return os.path.splitext(self.file_names[1])[1]
def link_files_for_html(self, dest_dir):
for f in self.file_names:
link_file(f, os.path.join(dest_dir, f))
def get_distance_details(self, dest_file):
return ''
def get_cell(self, oldnew):
return '', ''
def get_file(self, oldnew):
return self.file_names[oldnew]
def html_record_string(self, dest_dir):
dist = self.distance()
details = self.get_distance_details(self.file_names[1])
if details:
details_base = os.path.splitext(self.file_names[1])[0]
details_base += '.details.html'
fn = dest_dir + '/' + details_base
open_write_file(fn).write(details)
details = '<br>(<a href="%(details_base)s">details</a>)' % locals()
name = self.name() + self.extension()
cells = ['', '']
for oldnew in (0, 1):
file = self.get_file(oldnew)
class_attr, cell = self.get_cell(oldnew)
if class_attr:
class_attr = ' class="%s"' % class_attr
if cell or os.path.exists(file):
cells[oldnew] = '''<figure%(class_attr)s>
%(cell)s
<figcaption><a href="%(file)s">%(name)s</a></figcaption>
</figure>''' % locals()
cell1 = cells[0]
cell2 = cells[1]
return '''<tr>
<td>
%(dist)f
%(details)s
</td>
<td>%(cell1)s</td>
<td>%(cell2)s</td>
</tr>''' % locals()
class FileCompareLink (FileLink):
def __init__(self, f1, f2):
FileLink.__init__(self, f1, f2)
self.contents = (self.get_content(self.file_names[0]),
self.get_content(self.file_names[1]))
def calc_distance(self):
if self.contents[0] == self.contents[1]:
return 0.0
else:
return 100.0
def get_content(self, name):
log_verbose('reading %s' % name)
try:
return codecs.open(name, 'r', 'utf-8').read()
except IOError as e:
if e.errno == errno.ENOENT:
return None
else:
raise
class GitFileCompareLink (FileCompareLink):
def get_cell(self, oldnew):
str = self.contents[oldnew]
# truncate long lines
if str:
str = '\n'.join([l[:80] for l in str.split('\n')])
if str:
str = '<pre>%s</pre>' % html.escape(str)
if not str:
str = ''
return '', str
def calc_distance(self):
if self.contents[0] == self.contents[1]:
d = 0.0
else:
d = 1.0001 * options.threshold
return d
class TextFileCompareLink (FileCompareLink):
snippet_fn_re = re.compile(r"`\./([0-9a-f]{2}/lily-[0-9a-f]{8}).eps'")
def calc_distance(self):
if self.contents[0] == self.contents[1]:
return 0
if (self.contents[0] is None) != (self.contents[1] is None):
# Just one side available. Don't show a diff. If the user
# wants to see the content, they can click through the link.
self.diff_lines = []
return 100
# Extract the old and the new hashed snippet names from the log file
# and replace the old by the new, so file name changes don't show
# up as log differences...
cont0 = self.contents[0].strip()
cont1 = self.contents[1].strip()
m0 = re.search(TextFileCompareLink.snippet_fn_re, cont0)
m1 = re.search(TextFileCompareLink.snippet_fn_re, cont1)
if (m0 and m1 and (m0.group(1) != m1.group(1))):
cont0 = cont0.replace(m0.group(1), m1.group(1))
diff = difflib.unified_diff(cont0.split('\n'),
cont1.split('\n'),
fromfiledate=self.file_names[0],
tofiledate=self.file_names[1]
)
self.diff_lines = [l for l in diff]
self.diff_lines = self.diff_lines[2:]
return math.sqrt(float(len([l for l in self.diff_lines if l[0] in '-+'])))
def get_cell(self, oldnew):
str = ''
if oldnew == 1:
str = '\n'.join([d.replace('\n', '') for d in self.diff_lines])
if str:
str = '<pre>%s</pre>' % html.escape(str)
return '', str
class LogFileCompareLink (TextFileCompareLink):
def get_content(self, name):
c = TextFileCompareLink.get_content(self, name)
if c:
c = re.sub("\nProcessing `[^\n]+'\n", '', c)
return c
class MidiFileLink (TextFileCompareLink):
def get_content(self, name):
try:
f = open(name, 'rb')
except IOError as e:
if e.errno == errno.ENOENT:
return None
else:
raise
data = f.read()
midi_data = midi.parse(data)
tracks = midi_data[1]
str = ''
j = 0
for t in tracks:
str += 'track %d' % j
j += 1
for e in t:
ev_str = repr(e)
if re.search('LilyPond [0-9.]+', ev_str):
continue
str += ' ev %s\n' % repr(e)
return str
class SignatureFileLink (FileLink):
def __init__(self, f1, f2):
FileLink.__init__(self, f1, f2)
self.system_links = {}
def add_system_link(self, link, number):
self.system_links[number] = link
def calc_distance(self):
d = 0.0
orphan_distance = 0.0
for l in list(self.system_links.values()):
d = max(d, l.geometric_distance())
orphan_distance += l.orphan_count()
return d + orphan_distance
def add_file_compare(self, f1, f2):
system_index = []
def note_system_index(m):
system_index.append(int(m.group(1)))
return ''
base1 = re.sub("-([0-9]+).signature", note_system_index, f1)
base2 = re.sub("-([0-9]+).signature", note_system_index, f2)
self.base_names = (os.path.normpath(base1),
os.path.normpath(base2))
s1 = read_signature_file(f1)
s2 = read_signature_file(f2)
link = SystemLink(s1, s2)
self.add_system_link(link, system_index[0])
def create_images(self, dest_dir):
"""Returns a (OLD-FILES, NEW-FILES) tuple."""
files_created = [[], []]
for oldnew in (0, 1):
pat = self.base_names[oldnew] + '.eps'
# EPS files generated for regression tests don't contain fonts
# to save disk space. Instead, paths to the fonts are stored in
# the files that are loaded by Ghostscript's `.loadfont'
# operator later on.
#
# In gub builds, these paths get massaged to be relative to the
# location of the particular EPS files. Since gs doesn't
# provide an option to adjust the font lookup paths for
# `.loadfont', we enter the directory so that the relative paths
# are valid.
(dir, base) = os.path.split(pat)
out_dir = os.path.abspath(dest_dir + '/' + dir)
mkdir(out_dir)
abs_dir = os.path.abspath(dir)
cur_dir = os.getcwd()
log_verbose('entering directory %s' % abs_dir)
os.chdir(dir)
data_option = ''
if options.local_data_dir:
data_option = ('-slilypond-datadir=%s/share/lilypond/current '
% abs_dir)
driver = open('batch.ps', 'w', encoding='utf8')
for f in glob.glob(base):
outfile = (out_dir + '/' + f).replace('.eps', '.png')
driver.write('''
mark /OutputFile (%s)
/GraphicsAlphaBits 4 /TextAlphaBits 4
/HWResolution [101 101]
(png16m) finddevice putdeviceprops setdevice
(%s) run
''' % (outfile, f))
files_created[oldnew].append(outfile)
driver.close()
cmd = ('gs '
' -dNOSAFER'
' -dEPSCrop'
' -q'
' -dNOPAUSE'
' -dNODISPLAY'
' -dAutoRotatePages=/None'
' -dPrinted=false'
' batch.ps'
' -c quit')
system(cmd)
log_verbose('leaving directory %s' % abs_dir)
os.chdir(cur_dir)
return files_created
def link_files_for_html(self, dest_dir):
FileLink.link_files_for_html(self, dest_dir)
to_compare = self.create_images(dest_dir)
for (old, new) in zip(to_compare[0], to_compare[1]):
compare_png_images(old, new, dest_dir)
def get_cell(self, oldnew):
def empty_cell():
return '', ''
def static_img_cell(img):
return '', ('''
<div><a href="%(img)s"><img src="%(img)s" alt=""/></a></div>
''' % locals())
def reactive_img_cell(oldimg, newimg):
return 'reactive_img', ('''
<div style="background-image: url(\'%(oldimg)s\')"><a href="%(newimg)s"><img src="%(newimg)s" alt=""/></a></div>
''' % locals())
def multi_img_cell(imgs):
imgs_str = '\n'.join(['''<a href="%s"><img src="%s" alt=""/></a>''' % (img, img)
for img in imgs])
return '', imgs_str
# If we have systems, we expect that images have been or will
# be created.
num_systems = (sum(1 for x in list(self.system_links.values()) if x.system1),
sum(1 for x in list(self.system_links.values()) if x.system2))
expect_compare = num_systems[0] and oldnew
base = os.path.splitext(self.file_names[oldnew])[0]
if expect_compare:
ext = '.compare.jpeg'
else:
ext = '.png'
# TODO: this is broken; no regtest outputs a page[0-9].{eps,png} file
pages = glob.glob(base + '-page*' + ext)
if pages:
return multi_img_cell(sorted(pages))
img = base + ext
if expect_compare:
oldimg = os.path.splitext(self.file_names[0])[0] + '.png'
return reactive_img_cell(oldimg, img)
elif num_systems[oldnew]:
return static_img_cell(img)
else:
return empty_cell()
def get_distance_details(self, dest_file):
systems = sorted(self.system_links.items())
rel_top = os.path.relpath(os.path.curdir, os.path.dirname(dest_file))
style_href = os.path.join(rel_top, 'style.css')
html = ""
for (c, link) in systems:
e = '<td>%d</td>' % c
for d in link.distance_tuple():
e += '<td>%f</td>' % d
e = '<tr>%s</tr>' % e
html += e
e = '<td>%d</td>' % c
for s in (link.orphan_details_string(),
link.geo_details_string()):
e += "<td>%s</td>" % s
e = '<tr>%s</tr>' % e
html += e
original = self.name()
html = '''<!DOCTYPE html>
<html lang="en">
<head>
<title>comparison details for %(original)s</title>
<link rel="stylesheet" type="text/css" href="%(style_href)s"/>
<meta charset="UTF-8">
</head>
<body>
<table>
<tr>
<th>system</th>
<th>orphan</th>
<th>geo</th>
</tr>
%(html)s
</table>
</body>
</html>
''' % locals()
return html
################################################################
# Files/directories
def compare_signature_files(f1, f2):
s1 = read_signature_file(f1)
s2 = read_signature_file(f2)
return SystemLink(s1, s2).distance_tuple()
def paired_files(dir1, dir2, pattern):
"""
Search DIR1 and DIR2 for PATTERN.
Return (PAIRED, MISSING-FROM-DIR1, MISSING-FROM-DIR2)
"""
files = []
for d in (dir1, dir2):
found = [os.path.split(f)[1] for f in glob.glob(d + '/' + pattern)]
found = dict((f, 1) for f in found)
files.append(found)
pairs = []
missing = []
for f in files[0]:
try:
files[1].pop(f)
pairs.append(f)
except KeyError:
missing.append(f)
return (pairs, list(files[1].keys()), missing)
class ComparisonData:
"""All the comparison data; may span several directories"""
def __init__(self):
self.result_dict = {}
self.missing = []
self.added = []
self.file_links = {}
def read_sources(self):
# ugh: drop the .ly.txt
for (key, val) in list(self.file_links.items()):
def note_original(match, ln=val):
key = ln.name()
hash_to_original_name[key] = match.group(1)
return ''
sf = val.source_file()
if sf:
re.sub(r'\\sourcefilename "([^"]+)"',
note_original, codecs.open(sf, 'r', 'utf-8').read())
else:
print('no source for', val.file_names[1])
def compare_trees(self, dir1, dir2):
self.compare_directories(dir1, dir2)
try:
(root, dirs, files) = next(os.walk(dir1))
except StopIteration:
if dir1.endswith("-baseline"):
sys.stderr.write(
"Failed to walk through %s. This can be caused by forgetting to run make test-baseline.\n" % dir1)
else:
sys.stderr.write(
"Failed to walk through %s; please check it exists.\n" % dir1)
sys.exit(1)
for d in dirs:
# don't walk the share folders
if d.startswith("share"):
continue
d1 = os.path.join(dir1, d)
d2 = os.path.join(dir2, d)
if os.path.islink(d1) or os.path.islink(d2):
continue
if os.path.isdir(d2):
self.compare_trees(d1, d2)
def compare_directories(self, dir1, dir2):
log_terse('comparing %s' % dir1)
log_terse(' to %s' % dir2)
total_compared = 0
for ext in ['signature',
'midi',
'log',
'gittxt']:
(paired, missing1, missing2) = paired_files(dir1, dir2, '*.' + ext)
self.missing += [(dir2, m) for m in missing2]
self.added += [(dir2, m) for m in missing1]
# we sort the file names for easier debugging
to_compare = sorted(paired + missing1 + missing2)
if to_compare:
total_compared += len(to_compare)
log_terse('%6d %s' % (len(to_compare), ext))
for p in to_compare:
if (options.max_count
and len(self.file_links) > options.max_count):
continue
f2 = dir2 + '/' + p
f1 = dir1 + '/' + p
self.compare_files(f1, f2)
log_terse('%6d total' % total_compared)
def compare_files(self, f1, f2):
if f1.endswith('signature'):
self.compare_signature_files(f1, f2)
else:
ext = os.path.splitext(f1)[1]
klasses = {
'.midi': MidiFileLink,
'.log': LogFileCompareLink,
'.gittxt': GitFileCompareLink,
}
if ext in klasses:
self.compare_general_files(klasses[ext], f1, f2)
def compare_general_files(self, klass, f1, f2):
prefix = os.path.commonprefix([f1, f2])
name = os.path.split(f1)[1]
name = os.path.join(prefix, name)
file_link = klass(f1, f2)
self.file_links[name] = file_link
def compare_signature_files(self, f1, f2):
prefix = os.path.commonprefix([f1, f2])
name = os.path.split(f1)[1]
name = re.sub('-[0-9]+.signature', '', name)
name = os.path.join(prefix, name)
file_link = None
try:
file_link = self.file_links[name]
except KeyError:
generic_f1 = re.sub('-[0-9]+.signature', '.ly', f1)
generic_f2 = re.sub('-[0-9]+.signature', '.ly', f2)
file_link = SignatureFileLink(generic_f1, generic_f2)
self.file_links[name] = file_link
file_link.add_file_compare(f1, f2)
def write_changed(self, dest_dir, threshold):
(changed, below, unchanged) = self.thresholded_results(threshold)
non_ext = [os.path.splitext(link.file_names[1])[0] for link in changed]
str = '\n'.join(sorted(set(non_ext)))
if str:
str += '\n'
fn = dest_dir + '/changed.txt'
open_write_file(fn).write(str)
def thresholded_results(self, threshold):
# todo: support more scores.
results = [(link.distance(), link)
for link in list(self.file_links.values())]
# Only compare distances.
results.sort(key=lambda x: -x[0])
unchanged = [r for (d, r) in results if d == 0.0]
below = [r for (d, r) in results if threshold >= d > 0.0]
changed = [r for (d, r) in results if d > threshold]
assert len(results) == len(unchanged) + len(below) + len(changed)
return (changed, below, unchanged)
def write_text_result_page(self, filename, threshold):
out = None
verbose = True
if filename == '':
out = sys.stdout
verbose = options.verbose
else:
out = open_write_file(filename)
(changed, below, unchanged) = self.thresholded_results(threshold)
if verbose:
for link in changed:
out.write(link.text_record_string())
for m in self.missing:
out.write('in baseline only: %s\n' % m[1])
out.write('\n\n')
else:
out.write('output-distance summary:\n')
out.write('%6d changed\n' % len(changed))
out.write('%6d in baseline only\n' % len(self.missing))
out.write('%6d below threshold\n' % len(below))
out.write('%6d unchanged\n' % len(unchanged))
def create_text_result_page(self, dest_dir, threshold):
self.write_text_result_page(dest_dir + '/index.txt', threshold)
def create_html_result_page(self, dest_dir, threshold):
(changed, below, unchanged) = self.thresholded_results(threshold)
table_rows = '''
<tr>
<th>distance</th>
<th>before</th>
<th>after</th>
</tr>
'''
for link in changed:
table_rows += link.html_record_string(dest_dir)
def make_row(label, value):
return '<tr><td>%d</td><td>%s</td></tr>' % (value, label)
def make_nz_row(label, value):
if value:
return make_row(label, value)
else:
return ''
summary = '<table id="summary">'
summary += make_nz_row('in baseline only', len(self.missing))
summary += make_nz_row('newly added', len(self.added))
summary += make_nz_row('below threshold', len(below))
summary += make_row('unchanged', len(unchanged))
summary += '</table>'
me = sys.argv[0]
open_write_file(dest_dir + '/style.css').write('''
:root {
background-color: white;
color: black;
--line-color: blue;
--link-color: blue;
}
hr, table, tr, th, td {
border: 1px solid var(--line-color);
}
a {
color: var(--link-color);
}
figcaption {
margin-top: 0.5rem;
}
figcaption button {
float: right;
}
figure {
display: inline-block;
margin: 0rem;
padding: 0rem;
}
figure > div:first-child {
background-color: white;
background-repeat: no-repeat;
border: 0.5rem solid white;
border-radius: 0.5rem;
color: black;
}
figure.reactive_img.active > div:first-child img {
opacity: 0;
}
figure img {
border: none;
}
table {
border-collapse: collapse;
margin: 1rem 0.25rem;
}
#summary td:first-child {
text-align: right;
}
td, th {
padding: 0.5rem;
}
td {
vertical-align: top;
}
table.ruled_rows td,
table.ruled_rows th {
border-style: solid hidden;
}
td:empty {
background-image: repeating-linear-gradient(-45deg, rgba(127,127,0,.1), rgba(127,127,0,.1) 3rem, rgba(255,255,0,.2) 3rem, rgba(255,255,0,.2) 6rem);
}
@media (prefers-color-scheme: dark) {
:root {
background-color: #1c1c1c;
color: #ffffff;
--line-color: #838383;
--link-color: #59a0e0;
}
}
''')
html = '''<!DOCTYPE html>
<html lang="en">
<head>
<title>LilyPond regression test results</title>
<link rel="stylesheet" type="text/css" href="style.css"/>
<meta charset="UTF-8">
<meta name="author" content="This file was autogenerated by %(me)s"/>
<script>
// <![CDATA[
function showOnlyMatchingRows(substring) {
var table = document.getElementById("test_cases");
for (row of table.getElementsByTagName("tr")) {
html = row.innerHTML;
row.style.display =
((html.indexOf('>distance<') != -1) ||
(html.indexOf(substring + '">') != -1)) ? "" : "none";
}
}
function addControls() {
function makeMomentaryButton(label, object) {
function activate() { object.classList.add("active"); }
function revert() { object.classList.remove("active"); }
var button = document.createElement("button");
button.appendChild(document.createTextNode(label));
button.addEventListener("mousedown", activate);
button.addEventListener("mouseup", revert);
button.addEventListener("mouseout", revert);
return button;
}
for (fig of document.getElementsByClassName("reactive_img")) {
var caps = fig.getElementsByTagName("figcaption");
if (caps.length > 0) {
caps[0].appendChild(makeMomentaryButton("Flip", fig));
}
}
}
// ]]>
</script>
</head>
<body onload="addControls()">
<p>
click to filter rows by type:
<a href="#" onClick="showOnlyMatchingRows('.ly')">ly</a> /
<a href="#" onClick="showOnlyMatchingRows('.profile')">profiling</a> /
<a href="#" onClick="showOnlyMatchingRows('.signature')">signature</a> /
<a href="#" onClick="showOnlyMatchingRows('.midi')">midi</a> /
<a href="#" onClick="showOnlyMatchingRows('.log')">log</a> /
<a href="#" onClick="showOnlyMatchingRows('.gittxt')">gittxt</a> /
<a href="#" onClick="showOnlyMatchingRows('')">reset to all</a>
</p>
<hr />
%(summary)s
<table id="test_cases" class="ruled_rows">
%(table_rows)s
</table>
</body>
</html>''' % locals()
dest_file = dest_dir + '/index.html'
open_write_file(dest_file).write(html)
for link in changed:
link.link_files_for_html(dest_dir)
def print_results(self, threshold):
self.write_text_result_page('', threshold)
def compare_tree_pairs(tree_pairs, dest_dir, threshold):
"""Compare a list of directories."""
data = ComparisonData()
for dir1, dir2 in tree_pairs:
data.compare_trees(dir1, dir2)
data.read_sources()
if os.path.isdir(dest_dir):
system('rm -rf %s ' % dest_dir)
data.write_changed(dest_dir, threshold)
data.create_html_result_page(dest_dir, threshold)
data.create_text_result_page(dest_dir, threshold)
data.print_results(threshold)
################################################################
# TESTING
def mkdir(x):
if not os.path.isdir(x):
log_verbose('mkdir %s' % x)
os.makedirs(x)
def link_file(x, y):
mkdir(os.path.split(y)[0])
try:
log_verbose('%s -> %s' % (x, y))
os.link(x, y)
except OSError as z:
if z.errno == errno.ENOENT:
pass
else:
print('OSError', x, y, z)
raise
def open_write_file(x):
log_verbose('writing %s' % x)
d = os.path.split(x)[0]
mkdir(d)
return open(x, 'w', encoding='utf-8')
def test_paired_files():
print(paired_files(os.environ["HOME"] + "/src/lilypond/scripts/",
os.environ["HOME"] + "/src/lilypond-stable/scripts/build/", '*.py'))
def test_compare_tree_pairs():
system('rm -rf dir1 dir2')
system('mkdir dir1 dir2')
system('cp 19-1.signature 19.sub-1.signature')
system('cp 19.ly 19.sub.ly')
system('cp 19.profile 19.sub.profile')
system('cp 19.log 19.sub.log')
system('cp 19-1.eps 19.sub-1.eps')
system('cp 20multipage* dir1')
system('cp 20multipage* dir2')
system('mkdir -p dir1/subdir/ dir2/subdir/')
system('cp 19.sub{-*.signature,.ly,-1.eps,.log,.profile} dir1/subdir/')
system('cp 19.sub{-*.signature,.ly,-1.eps,.log,.profile} dir2/subdir/')
# Make sure we have unicode text in the HTML
system(u'echo HEAD is 人人的乐谱软件 > dir1/tree.gittxt')
system('echo HEAD is 2 > dir2/tree.gittxt')
# introduce differences
system('cp 20-1.signature dir2/subdir/19.sub-1.signature')
system("sed 's/: /: 1/g' 20.profile > dir2/subdir/19.sub.profile")
# radical diffs.
system('cp 20grob{-*.signature,.ly,.eps,-?.eps,.log,.profile} dir1/')
system('cp 19-1.signature dir2/20grob-1.signature')
system('cp 19-1.signature dir2/20grob-2.signature')
system('cp 19-1.eps dir2/20grob-1.eps')
system('cp 19-1.eps dir2/20grob-2.eps')
system('cp 19.eps dir2/20grob.eps')
system('cp 19.log dir2/20grob.log')
system('cp 20{.ly,.profile,.log} dir2/')
system('cp 19multipage.midi dir1/midi-differ.midi')
system('cp 20multipage.midi dir2/midi-differ.midi')
system('cp 19multipage.log dir1/log-differ.log')
system('cp 19multipage.log dir2/log-differ.log && echo different >> dir2/log-differ.log && echo different >> dir2/log-differ.log')
system('echo "removed" > dir1/removed.log')
system('echo "added" > dir2/added.log')
compare_tree_pairs([('dir1', 'dir2')],
'compare-dir1dir2', options.threshold)
for f in [
"index.html",
"index.txt",
"changed.txt",
"dir2/20grob.compare.jpeg",
"dir2/20grob.png",
"dir1/20grob.png",
"style.css",
]:
fn = os.path.join("compare-dir1dir2", f)
assert os.path.exists(fn), fn
html = open("compare-dir1dir2/index.html", encoding='utf-8').read()
assert "removed.log" in html
assert "added.log" in html
def test_basic_compare():
ly_template = r"""
\version "2.10.0"
#(define default-toplevel-book-handler
print-book-with-defaults-as-systems )
#(ly:set-option (quote no-point-and-click))
\sourcefilename "my-source.ly"
%(papermod)s
\header { tagline = ##f }
\score {
<<
\new Staff \relative c' {
c4^"%(userstring)s" %(extragrob)s
}
\new Staff \relative c' {
c4^"%(userstring)s" %(extragrob)s
}
>>
\layout{}
}
"""
dicts = [{'papermod': '',
'name': '20',
'extragrob': '',
'userstring': '20'},
{'papermod': '#(set-global-staff-size 19.5)',
'name': '19',
'extragrob': '',
'userstring': '191919'},
{'papermod': '',
'name': '20grob',
'extragrob': 'r2. \\break c1',
'userstring': 'test'},
]
for d in dicts:
open(d['name'] + '.ly', 'w', encoding='utf8').write(ly_template % d)
simple_names = [d['name'] for d in dicts]
multipage_str = r'''
#(set-default-paper-size "a6")
\book {
\score {
\relative c' { c1 \pageBreak c1 }
\layout {}
\midi {}
}
\paper {}
}
'''
open('20multipage.ly', 'w', encoding='utf8').write(multipage_str.replace('c1', 'd1'))
open('19multipage.ly', 'w', encoding='utf8').write(
'#(set-global-staff-size 19.5)\n' + multipage_str)
names = simple_names + ["20multipage", "19multipage"]
binary = os.environ.get("LILYPOND_BINARY", "lilypond")
system('%s -dbackend=eps --formats=ps -dseparate-log-files -dinclude-eps-fonts -dgs-load-fonts --header=texidoc -dcheck-internal-types -ddump-signatures -danti-alias-factor=1 %s' % (binary, ' '.join(names)))
test_compare_signatures(simple_names)
def test_compare_signatures(names, timing=False):
times = 1
if timing:
times = 100
t0 = time.time()
count = 0
for t in range(0, times):
sigs = dict((n, read_signature_file('%s-1.signature' % n))
for n in names)
count += 1
if timing:
print('elapsed', (time.clock() - t0)/count)
t0 = time.time()
count = 0
combinations = {}
links = {}
for (n1, s1) in list(sigs.items()):
for (n2, s2) in list(sigs.items()):
key = '%s-%s' % (n1, n2)
link = SystemLink(s1, s2)
links[key] = link
combinations[key] = link.distance_tuple()
count += 1
if timing:
print('elapsed', (time.clock() - t0)/count)
results = sorted(combinations.items())
if options.verbose:
for k, v in results:
print('%-20s' % k, v)
oc_forward = links["20grob-20"].orphan_count()
oc_reverse = links["20-20grob"].orphan_count()
assert oc_forward == oc_reverse
assert oc_forward > 0
assert combinations['20-20'] == (0.0, 0.0)
assert combinations['20-19'][1] < 10.0
assert combinations['20-19'][1] > 0.0
assert combinations['20grob-20'][0] > 0
def run_tests():
testdir = os.path.join(options.output_dir, 'test-output-distance')
print('test results in ', testdir)
system('rm -rf ' + testdir)
system('mkdir ' + testdir)
os.chdir(testdir)
test_basic_compare()
test_compare_tree_pairs()
system('rm -rf ' + testdir)
def main():
p = optparse.OptionParser(
"output-distance - compare LilyPond formatting runs")
p.usage = 'output-distance.py [options] tree1 tree2 [tree3 tree4]...'
p.add_option('', '--test-self',
dest="run_test",
action="store_true",
help='run test method')
p.add_option('--max-count',
dest="max_count",
metavar="COUNT",
type="int",
default=0,
action="store",
help='only analyze COUNT signature pairs')
p.add_option('', '--threshold',
dest="threshold",
default=0.3,
action="store",
type="float",
help='threshold for geometric distance')
p.add_option('--local-datadir',
dest="local_data_dir",
default=False,
action="store_true",
help='whether to use the share/lilypond/ directory in the test directory')
p.add_option('-o', '--output-dir',
dest="output_dir",
default=None,
action="store",
type="string",
help='where to put the test results [tree2/compare-tree1tree2]')
p.add_option('-v', '--verbose',
dest="verbose",
default=False,
action="store_true",
help='log progress verbosely')
global options
(options, args) = p.parse_args()
if options.run_test:
run_tests()
sys.exit(0)
if len(args) % 2 == 1:
p.print_usage()
sys.exit(2)
out = options.output_dir
if not out:
out = args[0].replace('/', '')
out = os.path.join(args[1], 'compare-' + shorten_string(out))
compare_tree_pairs(
list(zip(args[0::2], args[1::2])), out, options.threshold)
if __name__ == '__main__':
main()