% 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. \NeedsTeXFormat{LaTeX2e} \ProvidesPackage{terminaltrees}[2026/10/28 v1.0.2 Child-centered terminal trees] \RequirePackage[edges]{forest} \RequirePackage{needspace} \forestset{ declare count register={terminaltrees leaf index}, declare dimen register={leaf pitch}, declare dimen register={terminaltrees fork distance}, declare dimen register={terminaltrees bend radius}, declare dimen register={terminaltrees arrow inset}, declare toks register={roundness}, declare dimen={terminaltrees edge radius}{0pt}, % terminal tree/.style={ baseline=(current bounding box.north), leaf pitch=0pt, roundness=1, for tree={ anchor=center, align=center, parent anchor=south, child anchor=north, edge={thin, ->, >=stealth}, forked edge', }, where n children=0{tier=terminal}{}, % before drawing tree={ % Leave room for the arrow tip so the shaft does not reverse direction. TeX={% Discard whitespace emitted when PGF first instantiates the arrow. \setbox0=\hbox\bgroup \tikzset{thin}\pgfarrowtotallength{stealth}% % Anchors extend beyond the label boxes at both ends. \edef\pgfmathresult{\the\csname pgf@xa\endcsname}% \pgfmathparse{\pgfmathresult+2*\pgfkeysvalueof{/pgf/outer ysep}}% \edef\pgfmathresult{\egroup \noexpand\forestset{terminaltrees arrow inset=\pgfmathresult pt}}% \pgfmathresult }, % % Number leaf columns before centering parents over their children. terminaltrees leaf index=0, for leaves={ x/.pgfmath={terminaltrees_leaf_index()}, terminaltrees leaf index+=1, }, % % Process children first so each midpoint uses their final positions. for tree children-first={ if n children=0{}{ x/.pgfmath={(x("!1")+x("!l"))/2}, }, }, % % Column boundaries lie half a pitch beyond the outer leaf centers. % Fit each label and half the sibling gap within its nearer boundary. for tree={ leaf pitch/.pgfmath={ max(leaf_pitch(), (max(abs(min_x()),abs(max_x()))+s_sep()/2) / min( x()-x("!{first leaf'}")+0.5, x("!{last leaf'}")-x()+0.5 ) ) }, }, for tree={x/.pgfmath={x()*leaf_pitch()}}, % % Place each fork halfway through the clearance above the child labels. for tree={ if n children=0{}{% terminaltrees fork distance/.pgfmath={ (y()+min_y()-aggregate_max("y()+max_y()","children"))/2}, % % Siblings use one radius so same-side branches share a departure curve. terminaltrees bend radius/.pgfmath={ max(0,min( roundness()*terminaltrees_fork_distance(), terminaltrees_fork_distance()-terminaltrees_arrow_inset() )) }, % % The two bends must fit within the horizontal parent-child distance. for children={ if={x()!=x("!u")}{ terminaltrees bend radius/.pgfmath={ min(terminaltrees_bend_radius(),abs(x()-x("!u"))/2) }, }{}, }, % for children={ fork sep/.register=terminaltrees fork distance, terminaltrees edge radius/.pgfmath={ ifthenelse(x()==x("!u"),0,terminaltrees_bend_radius()) }, edge+={rounded corners=\forestoption{terminaltrees edge radius}}, }, }, }, }, }, } \ExplSyntaxOn \msg_new:nnn { terminaltrees } { invalid-roundness } { roundness~must~be~a~unitless~number~from~0~to~1. } \forestset{ roundness/.code={ \pgfmathparse{#1} \ifpgfmathunitsdeclared \msg_fatal:nn { terminaltrees } { invalid-roundness } \fi \fp_compare:nTF {\pgfmathresult >= 0 && \pgfmathresult <= 1} { \forestrlet{roundness}\pgfmathresult } { \msg_fatal:nn { terminaltrees } { invalid-roundness } } }, } \box_new:N \l__terminaltrees_tree_box \dim_new:N \g__terminaltrees_smallest_font_dim \dim_new:N \l__terminaltrees_minimum_font_dim \dim_new:N \l__terminaltrees_available_height_dim \fp_new:N \l__terminaltrees_scale_fp % TeX quantizes dimension multipliers to one scaled point per point. \int_const:Nn \c__terminaltrees_scale_steps_int { \dim_to_decimal_in_sp:n {1pt} } \keys_define:nn { terminaltrees / fit } { minimum~label~size .dim_set:N = \l__terminaltrees_minimum_font_dim } \msg_new:nnn { terminaltrees } { too-small } { Fitting~requires~#1pt~node~text,~below~the~#2~minimum.~ Increase~an~undersized~node~font,~wrap~labels,~or~split~the~tree~manually;~ automatic~pagination~is~not~implemented. } \msg_new:nnn { terminaltrees } { invalid-context } { fittedterminaltree~requires~normal~document~flow,~not~a~box~or~float. } \msg_new:nnn { terminaltrees } { invalid-size } { The~minimum~label~size~and~available~page~dimensions~must~be~positive. } \cs_new_protected:Npn \__terminaltrees_record_smallest_font: { \dim_gset:Nn \g__terminaltrees_smallest_font_dim { \dim_min:nn { \g__terminaltrees_smallest_font_dim } { \f@size pt } } } \cs_new_protected:Npn \__terminaltrees_fit:nn #1#2 { \mode_if_inner:TF { \msg_fatal:nn { terminaltrees } { invalid-context } } { \par \group_begin: \footnotesize \dim_gset:Nn \g__terminaltrees_smallest_font_dim { \f@size pt } \group_end: % \dim_set_eq:NN \l__terminaltrees_minimum_font_dim \g__terminaltrees_smallest_font_dim \keys_set:nn { terminaltrees / fit } {#1} \dim_set:Nn \l__terminaltrees_available_height_dim { \textheight - \baselineskip } % \bool_lazy_or:nnTF { \dim_compare_p:n { \l__terminaltrees_minimum_font_dim <= 0pt } } { \dim_compare_p:n { \dim_min:nn {\linewidth} {\l__terminaltrees_available_height_dim} <= 0pt } } { \msg_fatal:nn { terminaltrees } { invalid-size } } { % Typeset labels once so fitting does not repeat their side effects. % The node hook runs after TikZ selects its font. \dim_gset:Nn \g__terminaltrees_smallest_font_dim { \maxdimen } \hbox_set:Nn \l__terminaltrees_tree_box { \begin{forest} terminal~tree, for~tree={execute~at~begin~node={\__terminaltrees_record_smallest_font:}}, #2 \end{forest} } % % Round down so TeX cannot enlarge the fitted box beyond its limits. \fp_set:Nn \l__terminaltrees_scale_fp { floor( \c__terminaltrees_scale_steps_int * min( 1, \dim_to_fp:n {\linewidth} / \dim_to_fp:n {\box_wd:N \l__terminaltrees_tree_box}, \dim_to_fp:n {\l__terminaltrees_available_height_dim} / \dim_to_fp:n { \box_ht:N \l__terminaltrees_tree_box + \box_dp:N \l__terminaltrees_tree_box } ) ) / \c__terminaltrees_scale_steps_int } % \fp_compare:nTF { \l__terminaltrees_scale_fp * \dim_to_fp:n {\g__terminaltrees_smallest_font_dim} < \dim_to_fp:n {\l__terminaltrees_minimum_font_dim} } { \msg_fatal:nnxx { terminaltrees } { too-small } % Round only the reported font size to two decimal places. { \fp_eval:n { round(\l__terminaltrees_scale_fp * \dim_to_fp:n {\g__terminaltrees_smallest_font_dim},2) } } { \dim_use:N \l__terminaltrees_minimum_font_dim } } { \box_scale:Nnn \l__terminaltrees_tree_box {\fp_use:N \l__terminaltrees_scale_fp} {\fp_use:N \l__terminaltrees_scale_fp} % % Keep the chosen scale when moving the tree to the next page. \Needspace*{\dim_eval:n {\box_ht:N \l__terminaltrees_tree_box + \box_dp:N \l__terminaltrees_tree_box + \baselineskip}} \noindent\makebox[\linewidth][c]{\box_use:N \l__terminaltrees_tree_box}\par } } } } % Parenthesized options cannot consume Forest's opening root bracket. \NewDocumentEnvironment{fittedterminaltree}{ D(){} +b } { \__terminaltrees_fit:nn {#1}{#2} } {} \ExplSyntaxOff \endinput