# Copyright 2026 Adi Edelhaus # # terminaltrees is licensed under the LaTeX Project Public # License, version 1.3c or any later version. # # Maintenance status: maintained # Current maintainer: Adi Edelhaus # Issue reports: https://github.com/edel67/terminaltrees/issues # # The files comprising this work are listed in manifest.txt. # See LICENSE for the full license terms. """Check geometry and the README render. Requires Python 3, pdflatex and Poppler.""" import argparse import hashlib import os from pathlib import Path import re import subprocess import tempfile from typing import NamedTuple import xml.etree.ElementTree as ET ROOT = Path(__file__).resolve().parent.parent # Keep each TeX trace record on one log line. ENV = dict(os.environ, max_print_line='1000') LOG_TAIL_LENGTH = 2000 # Allow accumulated TeX/PGF arithmetic error and rounded geometry traces. GEOMETRY_TOLERANCE_PT = .002 # Allow rounding in font traces. The precision cases check exact thresholds. FONT_TRACE_TOLERANCE_PT = .001 # Cairo path coordinates and pdfinfo page sizes have separate rounding budgets. PATH_TOLERANCE_BP = .01 PAGE_SIZE_TOLERANCE_BP = .01 # Compare the reduction factors inferred from preview text widths. DISPLAY_SCALE_TOLERANCE = .001 # An inch contains 72.27 TeX points (pt) or 72 PDF points (bp). TEX_PT_TO_BP = 72 / 72.27 TEX_PT_PER_MM = 72.27 / 25.4 BP_PER_MM = TEX_PT_PER_MM * TEX_PT_TO_BP def run(*command): return subprocess.run(command, cwd=ROOT, env=ENV, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) def normalize_svg_size(svg_bytes): """Make Cairo's PDF-point dimensions explicit without rewriting its drawing.""" root_end = svg_bytes.index(b'>', svg_bytes.index(b'= left_column - GEOMETRY_TOLERANCE_PT, 'Label escapes left columns' assert node.right + padding <= right_column + GEOMETRY_TOLERANCE_PT, 'Label escapes right columns' for other in nodes[index + 1:]: overlap_x = min(node.right, other.right) - max(node.left, other.left) overlap_y = min(node.top, other.top) - max(node.bottom, other.bottom) assert overlap_x <= GEOMETRY_TOLERANCE_PT or overlap_y <= GEOMETRY_TOLERANCE_PT, ( 'Label collision') for child_index, parent_index in parents.items(): child, parent = nodes[child_index], nodes[parent_index] start = (parent.x, parent.bottom) end = (child.x, child.top) siblings = [nodes[index] for index, ancestor in parents.items() if ancestor == parent_index] clearance = min(parent.bottom - sibling.top for sibling in siblings) assert child.fork_distance > 0 assert abs(child.fork_distance - clearance / 2) < GEOMETRY_TOLERANCE_PT side_runs = [abs(sibling.x - parent.x) for sibling in siblings if sibling.x != parent.x] radius = 0 if child.x != parent.x: vertical_limit = max(0, min(roundness * child.fork_distance, child.fork_distance - arrow_inset)) radius = min(vertical_limit, *(distance / 2 for distance in side_runs)) assert abs(child.edge_radius - radius) < GEOMETRY_TOLERANCE_PT fork_y = parent.bottom - child.fork_distance points = [start, (parent.x, fork_y), (child.x, fork_y), end] for segment_start, segment_end in zip(points, points[1:]): for node in nodes: left = node.left + GEOMETRY_TOLERANCE_PT right = node.right - GEOMETRY_TOLERANCE_PT bottom = node.bottom + GEOMETRY_TOLERANCE_PT top = node.top - GEOMETRY_TOLERANCE_PT start_x, start_y = segment_start end_x, end_y = segment_end if start_x == end_x: overlap_y = min(max(start_y, end_y), top) - max(min(start_y, end_y), bottom) assert not (left < start_x < right and overlap_y > 0), 'Vertical edge collision' else: overlap_x = min(max(start_x, end_x), right) - max(min(start_x, end_x), left) assert not (bottom < start_y < top and overlap_x > 0), 'Horizontal edge collision' # Labels must clear both the departure and arrival bends. direction = 1 if child.x > parent.x else -1 for x1, x2, bottom, top in ( (parent.x, parent.x + direction * radius, fork_y, fork_y + radius), (child.x - direction * radius, child.x, fork_y - radius, fork_y), ): for node in nodes: overlap_x = min(max(x1, x2), node.right) - max(min(x1, x2), node.left) overlap_y = min(top, node.top) - max(bottom, node.bottom) assert overlap_x <= GEOMETRY_TOLERANCE_PT or overlap_y <= GEOMETRY_TOLERANCE_PT, ( 'Rounded edge envelope collision') return pitches def check_curves(pdf, log): """Check rendered edge paths in a single-page fixture.""" svg = pdf.with_suffix('.svg') result = run('pdftocairo', '-svg', str(pdf), str(svg)) assert result.returncode == 0, result.stdout paths = [path for path in ET.parse(svg).iter('{http://www.w3.org/2000/svg}path') if path.get('fill') == 'none' and path.get('stroke')] radii = [(node.edge_radius, float(re.search(r'ROUNDNESS: ([^\n]+)', tree_log)[1])) for tree_log in log.split('TREE: ')[1:] for node in parse_nodes(tree_log)[1:]] assert len(paths) == len(radii), 'Expected one drawn path per child' departures = {} for path, (radius, roundness) in zip(paths, radii): drawing = path.get('d') # Each rounded edge has one departure bend and one arrival bend. assert drawing.count('C') == (2 if radius else 0), 'Missing or extra rounded bend' coordinates = list(map(float, re.findall(r'-?\d*\.?\d+(?:e[+-]?\d+)?', drawing))) x_coordinates = coordinates[::2] y_coordinates = coordinates[1::2] assert all(start_y >= end_y - PATH_TOLERANCE_BP for start_y, end_y in zip(y_coordinates, y_coordinates[1:])), ( 'Drawn edge doubles back toward its parent') if not radius: if roundness: assert max(x_coordinates) - min(x_coordinates) < PATH_TOLERANCE_BP, ( 'Aligned branch is not vertical') else: # Sibling branches on the same side must share their departure curve. first_curve = re.search(r'\bC\s+([^MLCZ]+)', drawing) curve_end_x, _ = map(float, first_curve[1].split()[-2:]) departure_key = (path.get('transform'), *coordinates[:2], curve_end_x > x_coordinates[0]) departure = drawing[:first_curve.end()] assert departure == departures.setdefault(departure_key, departure), 'Diverging sibling curves' print('drawn curves PASS (two bends, shared departures, straight aligned edges)') def compile_document(build_dir, name, tex_input, *, passes=1, expected_error=None): for _ in range(passes): result = run('pdflatex', '-no-shell-escape', '-interaction=batchmode', '-halt-on-error', f'-output-directory={build_dir}', f'-jobname={name}', str(tex_input)) if result.returncode: break log = (build_dir / f'{name}.log').read_text() if expected_error: assert result.returncode != 0 and expected_error in log, (name, log[-LOG_TAIL_LENGTH:]) else: assert result.returncode == 0 and 'Overfull' not in log, (name, log[-LOG_TAIL_LENGTH:]) return log def check_fitting_comparison(pdf, log, fits, pitches, text): # This fixture should shrink by less than 20% to fit the narrower page. assert len(fits) == 1 and .8 < fits[0].scale < 1 fitted = fits[0] natural_widths = list(map(float, re.findall(r'UNFITTED WIDTH: ([\d.]+)pt', log))) available_widths = list(map(float, re.findall(r'UNFITTED AVAILABLE: ([\d.]+)pt', log))) natural_width = natural_widths[0] assert natural_width > fitted.available_width assert abs(natural_width * fitted.scale - fitted.width) < GEOMETRY_TOLERANCE_PT assert len(natural_widths) == 2 assert abs(natural_widths[0] - natural_widths[1]) < GEOMETRY_TOLERANCE_PT assert available_widths[0] > natural_width > available_widths[1] assert len(pitches) == 3 and max(pitches) - min(pitches) < GEOMETRY_TOLERANCE_PT # These paper sizes and margins are specified in fitting-comparison.tex. paper_widths_mm = (220, 170, 170) paper_height_mm = 140 margin_mm = 35 page_count = len(paper_widths_mm) info = run('pdfinfo', '-f', '1', '-l', str(page_count), str(pdf)).stdout dimensions = re.findall(r'Page\s+\d+ size:\s+([\d.]+) x ([\d.]+) pts', info) assert len(dimensions) == page_count, info page_areas = re.findall(r'PAGE AREA: ([^\n]+)', log) assert len(page_areas) == page_count for area in page_areas: paper_width, left_margin, text_width = [float(value.replace('pt', '')) for value in area.split(';')] assert abs(left_margin - margin_mm * TEX_PT_PER_MM) < GEOMETRY_TOLERANCE_PT assert abs(paper_width - left_margin - text_width - left_margin) < GEOMETRY_TOLERANCE_PT for (width, height), paper_width_mm in zip(dimensions, paper_widths_mm): assert abs(float(width) - paper_width_mm * BP_PER_MM) < PAGE_SIZE_TOLERANCE_BP assert abs(float(height) - paper_height_mm * BP_PER_MM) < PAGE_SIZE_TOLERANCE_BP pages = text.split('\f') assert len(pages) == page_count + 1 and not pages[-1].strip() assert 'Unfitted' in pages[0] and 'Fitted' in pages[1] assert 'When the page is too small' in pages[2] def check_roundness(pdf, log, fits): expected_roundness = [0, .25, .5, .75, 1, 0, 1] assert len(fits) == len(expected_roundness) and all(fit == fits[0] for fit in fits) trees = log.split('TREE: ')[1:] assert [float(re.search(r'ROUNDNESS: ([^\n]+)', tree)[1]) for tree in trees] == expected_roundness nodes_by_tree = [parse_nodes(tree) for tree in trees] geometry = [[node._replace(edge_radius=0) for node in nodes] for nodes in nodes_by_tree] assert all(tree == geometry[0] for tree in geometry), 'Roundness moved nodes' # The final two trees repeat square corners and the fully rounded default. square, _, _, _, full, repeated_square, default = nodes_by_tree assert full == default, 'Default roundness changed or leaked between trees' assert square == repeated_square, 'Square setting leaked between trees' check_curves(pdf, log) def check_case(build_dir, name, expected_error=None, directory='terminaltrees-tests'): source = ROOT / directory / f'{name}.tex' # The page-margin overlay needs a second pass to resolve page anchors. log = compile_document(build_dir, name, source, passes=2 if name == 'fitting-comparison' else 1, expected_error=expected_error) if expected_error: print(name, 'PASS (expected rejection)') return fits = [FitTrace(*(float(value.replace('pt', '')) for value in row.split(';'))) for row in re.findall(r'FIT: ([^\n]+)', log)] assert fits, 'Missing fit trace' for fit in fits: assert 0 < fit.scale <= 1 assert fit.scale * fit.smallest_font >= fit.minimum_font - FONT_TRACE_TOLERANCE_PT assert (fit.width <= fit.available_width + GEOMETRY_TOLERANCE_PT and fit.height <= fit.available_height + GEOMETRY_TOLERANCE_PT), (name, fit) pitches = check_layout(log) pdf = build_dir / f'{name}.pdf' info = run('pdfinfo', str(pdf)).stdout page_size = re.search(r'Page size:\s+([\d.]+) x ([\d.]+) pts', info) assert page_size, info if name != 'fitting-comparison': for actual, expected in zip( map(float, page_size.groups()), (fits[0].paper_width, fits[0].paper_height)): assert abs(actual - expected * TEX_PT_TO_BP) < PAGE_SIZE_TOLERANCE_BP, info text = run('pdftotext', str(pdf), '-').stdout if name == 'fitting-comparison': check_fitting_comparison(pdf, log, fits, pitches, text) elif name == 'fit': # Only the middle tree needs scaling, by less than 20%. assert fits[0].scale == fits[2].scale == 1 and .8 < fits[1].scale < 1 pages = text.split('\f') assert 'stays on the first page' in pages[0] assert 'follows the tree on the next page' in pages[1] elif name == 'height': # This fixture needs a height reduction of less than 30%. assert .7 < fits[0].scale < 1 elif name == 'labels': assert fits[0].smallest_font == 9 and fits[1].smallest_font == 10 elif name == 'adaptive': assert [fit.minimum_font for fit in fits] == [10, 10, 9.5] assert fits[1].smallest_font > fits[0].smallest_font and pitches[1] > pitches[0] assert fits[2].smallest_font == fits[0].smallest_font and pitches[2] == pitches[0] elif name == 'override': assert 7 < fits[0].scale * fits[0].smallest_font < 8 and fits[0].minimum_font == 7 elif name == 'curves': check_curves(pdf, log) elif name == 'roundness': check_roundness(pdf, log, fits) print(name, 'PASS', len(fits), 'trees') def check_svg_sizing(): # Cairo versions emit either bare PDF-point dimensions or explicit pt units. expected_svg = (b'' b'') for units in (b'', b'pt'): converted_svg = expected_svg.replace(b'pt"', units + b'"') assert normalize_svg_size(converted_svg) == expected_svg, 'SVG sizing changed drawing bytes' try: normalize_svg_size(expected_svg.replace(b'pt"', b'px"')) except AssertionError: pass else: raise AssertionError('Unexpected SVG units were accepted') print('SVG sizing PASS (both converter formats, unchanged drawing, unexpected-unit rejection)') def check_fitting_overview(build_dir): # The overview must preserve the relative sizes of the two verified pages. overview = ROOT / 'examples/fitting-overview.tex' compile_document(build_dir, 'fitting-overview', r'\AtBeginDocument{\graphicspath{{' + str(build_dir) + r'/}}}\input{' + str(overview) + '}') info = run('pdfinfo', str(build_dir / 'fitting-overview.pdf')).stdout assert re.search(r'Pages:\s+1\b', info), info label_widths = [] for pdf in ('fitting-comparison', 'fitting-overview'): result = run('pdftotext', '-bbox', str(build_dir / f'{pdf}.pdf'), '-') assert result.returncode == 0, result.stdout words = ET.fromstring(result.stdout).iter('{http://www.w3.org/1999/xhtml}word') label_widths.append([float(word.get('xMax')) - float(word.get('xMin')) for word in words if word.text == 'alpha']) assert list(map(len, label_widths)) == [3, 2], 'Missing comparison labels' display_scales = [displayed / original for original, displayed in zip(*label_widths)] assert abs(display_scales[0] - display_scales[1]) < DISPLAY_SCALE_TOLERANCE, ( 'Unequal overview display scales') print('fitting-overview PASS (verified pages, one shared display scale)') def check_rejections(build_dir): for name, error in ( ('reject-small', 'the 8.0pt minimum'), ('reject-inner', 'requires normal'), ('reject-option', 'must be positive'), ('reject-font', 'Fitting requires 5pt'), ('reject-roundness', 'roundness must be a unitless number'), ): check_case(build_dir, name, error) for suffix, value, error in ( ('above-max', '1.0001', 'roundness must be a unitless number'), ('unit', '1pt', 'roundness must be a unitless number'), ('text', 'banana', 'Package PGF Math Error'), ('nan', 'nan', 'roundness must be a unitless number'), ('infinity', 'inf', 'roundness must be a unitless number'), ): name = f'reject-roundness-{suffix}' compile_document(build_dir, name, r'\def\BadRoundness{' + value + r'}\input{terminaltrees-tests/reject-roundness.tex}', expected_error=error) print(name, 'PASS (expected rejection)') def check_precision(build_dir): # Check the font floor against a 1000pt node at natural and reduced sizes. # Accept equality and reject a minimum one scaled point above the fitted font size. for width, minimum, offsets in ( ('1100pt', '10pt', (-1, 0, 1)), ('500pt', '5pt', (-1, 0, 1)), # Without scale flooring, TeX rounds this width ratio up and the box overflows. ('800.012pt', '1pt', (0,)), ): for offset_sp in offsets: name = f'precision-{width}-{offset_sp}' log = compile_document( build_dir, name, r'\def\FitWidth{' + width + r'}\def\MinimumSize{\dimexpr ' + minimum + f'{offset_sp:+d}' + r'sp\relax}\input{terminaltrees-tests/precision.tex}', expected_error='Fitting requires' if offset_sp > 0 else None) if offset_sp > 0: print(name, 'PASS (expected rejection)') else: assert 'PRECISION PASS' in log, (name, log[-LOG_TAIL_LENGTH:]) print(name, 'PASS') def check_readme_examples(build_dir): examples = re.findall(r'```latex\n(.*?)```', (ROOT / 'README.md').read_text(), re.S) for example_index, example in enumerate(examples): if r'\documentclass' not in example: example = (r'\documentclass{article}\usepackage{terminaltrees}' r'\begin{document}' + example + r'\end{document}') example = example.replace(r'\begin{document}', r'\input{terminaltrees-tests/trace.tex}\begin{document}') source = build_dir / f'guide-{example_index}.tex' source.write_text(example) log = compile_document(build_dir, source.stem, source) check_layout(log) print(f'guide-{example_index} PASS') def check_readme_image(build_dir, options): # Validate the shared comparison before checking or updating its README image. example = (ROOT / 'examples/comparison.tex').read_text().replace( r'\begin{document}', r'\input{terminaltrees-tests/trace.tex}\begin{document}') source = build_dir / 'comparison.tex' source.write_text(example) log = compile_document(build_dir, source.stem, source) check_layout(log) result = run('pdftocairo', '-svg', str(source.with_suffix('.pdf')), str(source.with_suffix('.svg'))) assert result.returncode == 0, result.stdout svg = ET.parse(source.with_suffix('.svg')).getroot() assert list(svg.iter('{http://www.w3.org/2000/svg}path')), 'Missing vector paths' assert not list(svg.iter('{http://www.w3.org/2000/svg}image')), 'README image must remain vector' image = normalize_svg_size(source.with_suffix('.svg').read_bytes()) source.with_suffix('.svg').write_bytes(image) if options.update_readme_image: (ROOT / 'examples/comparison.svg').write_bytes(image) print('comparison PASS (geometry checked, README image updated)') elif options.preview: print('comparison PASS (geometry checked, preview only)') else: assert image == (ROOT / 'examples/comparison.svg').read_bytes(), ( 'README comparison image changed. Run python3 terminaltrees-tests/check.py --update-readme-image') print('comparison PASS (geometry and exact rendered image)') def main(): parser = argparse.ArgumentParser(description=__doc__) image_mode = parser.add_mutually_exclusive_group() image_mode.add_argument('--update-readme-image', action='store_true', help='replace the README image after all rendering checks pass') image_mode.add_argument('--preview', action='store_true', help='validate generated previews without comparing or changing the committed SVG') options = parser.parse_args() build_dir = Path(tempfile.mkdtemp(prefix='terminaltrees-check-')) print('Output directory:', build_dir, flush=True) check_svg_sizing() for name in ('fit', 'height', 'labels', 'adaptive', 'override', 'branches', 'centering', 'curves', 'roundness'): check_case(build_dir, name) check_case(build_dir, 'fitting-comparison', directory='examples') check_fitting_overview(build_dir) check_rejections(build_dir) check_precision(build_dir) check_readme_examples(build_dir) check_readme_image(build_dir, options) for source in (ROOT / 'terminaltrees.sty', ROOT / 'README.md'): print(hashlib.sha256(source.read_bytes()).hexdigest(), source.name) if __name__ == '__main__': main()