TOC splitting
authorThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Fri, 7 Aug 2026 07:03:37 +0000 (07:03 +0000)
committerThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Fri, 7 Aug 2026 07:03:37 +0000 (07:03 +0000)
developer/authored/Manuscript.copy/Core/utility.js
developer/authored/Manuscript.copy/Document/design.html
developer/authored/Manuscript.copy/Element/TOC.js

index 42ea4af..905e920 100644 (file)
@@ -261,9 +261,17 @@ window.RT.Utility = {
         lines.set(key ,{ left: r.left ,right: r.right });
       }
     }
+    // Ordered by vertical position, so widths[0] is the first line.
+    const keys = Array.from(lines.keys()).sort((a ,b) => a - b);
+    const widths = keys.map(k => lines.get(k).right - lines.get(k).left);
     let widest = 0;
-    lines.forEach(l => { const w = l.right - l.left; if(w > widest) widest = w; });
-    return { count: lines.size || 1 ,widest: widest };
+    widths.forEach(w => { if(w > widest) widest = w; });
+    return {
+      count: lines.size || 1
+      ,widest: widest
+      ,widths: widths
+      ,first: widths.length ? widths[0] : 0
+    };
   };
 
   window.RT.Utility.Dom.line_metrics = line_metrics;
@@ -309,6 +317,36 @@ window.RT.Utility = {
         }
       }
       el.style.width = best + 'px';
+
+      /* Prefer a first line no shorter than the lines beneath it.
+
+         A block whose opening line is the short one reads as ragged at the top ,
+         where the eye enters. Compositors set the first line long for the same
+         reason a paragraph is not begun with its shortest sentence.
+
+         The narrowest width holding the line count does not always give this ,
+         because line breaking is greedy: the first line takes what fits ,and a
+         long following word can leave it short while later lines pack well. So
+         widen a little and look for a width where the first line is the longest.
+
+         The search is bounded, and it never accepts a width that adds a line.
+         Where no such width exists within the bound the balanced width stands:
+         <strong>a stranded word is worse than a short first line</strong> ,and
+         given the choice we decline to create one.
+      */
+      const ceiling = Math.ceil(m.widest) || max_width;
+      const span = Math.max(0 ,ceiling - best);
+      const step = Math.max(2 ,Math.round(span / 16));
+      for(let w = best + step; w <= ceiling; w += step){
+        el.style.width = w + 'px';
+        const t = line_metrics(el);
+        if(t.count !== target_lines) break;          // never add a line
+        if(t.first >= t.widest - 0.5){               // first line is the longest
+          best = w;
+          break;
+        }
+      }
+      el.style.width = best + 'px';
     }
 
     // Hug the longest line, so it touches the right edge of the box.
index 0978f88..d366957 100644 (file)
         <p>
           Afterwards the longest line touches the right edge of the box, the text is flush left with its ragged edge on the right where a reader expects it, and the box can be placed wherever it belongs.
         </p>
+        <p>
+          <strong>A block's first line should be no shorter than the lines beneath it.</strong> The eye enters at the top, and a block opening on its shortest line reads as ragged where it is least wanted; a column of such blocks looks unset. The balanced width does not always give this, because line breaking is greedy — the first line takes what fits, and one long following word can leave it short while later lines pack well. Widening a little often finds a width where the first line is the longest.
+        </p>
+        <p>
+          The preference yields when it conflicts. Widening far enough to lengthen the first line sometimes costs another line, or strands a word alone at the end. <strong>A stranded word is worse than a short first line</strong>, so the search never accepts a width that adds a line, and where no width within the bound satisfies the preference the balanced width stands unaltered.
+        </p>
         <p>
           Neither width can be written in a style sheet, because neither is known until the lines exist. Both are asked of the browser once they do: a range over the contents reports one rectangle per line box, which gives the line count and the widest line. The search bisects rather than stepping, costing about ten measurements rather than one per em. The work is not particular to grids, so it lives in the utilities as <RT·code>shrink_wrap</RT·code> and is available to any element that sets a label.
         </p>
index c6e0eef..ee6c107 100644 (file)
     a.onmouseout  = () => a.style.color = 'inherit';
   };
 
+
+  /* Splitting a table of contents.
+
+     A contents list is one of the few things in a document that is nearly
+     always too long for a page, and it divides cleanly: entries are siblings in
+     a list, and cutting between two of them loses nothing.
+
+     The title is repeated on the continuation. A page of entries with no
+     heading above them tells the reader nothing about what they are looking at,
+     and a contents list is consulted by people who have turned to it directly
+     rather than arrived by reading. The repeat is marked so a theme can set it
+     apart — "continued" ,or a lighter weight — without the splitter deciding
+     how that should look.
+  */
+  window.RT.Component = window.RT.Component || {};
+  window.RT.Component['RT·TOC'] = {
+    split: function(el ,remaining ,measure_fn){
+      const title = el.querySelector('.RT·TOC-title');
+      const list  = el.querySelector('ul');
+      if(!list) return { first: null ,rest: el ,firstHeight: 0 };
+
+      const items = Array.from(list.children);
+      if(items.length < 2) return { first: null ,rest: el ,firstHeight: 0 };
+
+      const probe = el.cloneNode(false);
+      const probe_list = list.cloneNode(false);
+      if(title) probe.appendChild(title.cloneNode(true));
+      probe.appendChild(probe_list);
+
+      let height = measure_fn(probe);
+      let taken = 0;
+      for(let i = 0; i < items.length; i++){
+        const row = items[i].cloneNode(true);
+        probe_list.appendChild(row);
+        const h = measure_fn(probe);
+        if(h > remaining){ probe_list.removeChild(row); break; }
+        height = h;
+        taken = i + 1;
+      }
+
+      // No entry fits beneath the title, or everything fits: nothing to do.
+      if(taken === 0 || taken >= items.length){
+        return { first: null ,rest: el ,firstHeight: 0 };
+      }
+
+      const build = function(from ,to ,is_continuation){
+        const frag = el.cloneNode(false);
+        if(title){
+          const t = title.cloneNode(true);
+          if(is_continuation) t.setAttribute('data-rt-continued' ,'true');
+          frag.appendChild(t);
+        }
+        const l = list.cloneNode(false);
+        for(let i = from; i < to; i++) l.appendChild(items[i].cloneNode(true));
+        frag.appendChild(l);
+        return frag;
+      };
+
+      return {
+        first: build(0 ,taken ,false)
+        ,rest: build(taken ,items.length ,true)
+        ,firstHeight: height
+      };
+    }
+  };
+
   RT.task_add('element' ,function(){
     const debug = window.RT.Debug || { log: function(){} };
     if(debug.log) debug.log('TOC' ,'Generating table of contents from expanded section steps');
       top_list.style.marginBottom = '0';
       container.appendChild(top_list);
 
+      // Capability plus per instance permission ,as everywhere else.
+      container.setAttribute('data-rt-component' ,'RT·TOC');
+      container.setAttribute('splitable' ,'true');
+
       const list_stack = [top_list];
 
       for(const item of sections){