From 64fd8c78f9217a60ac2b537043c1fdfe63c4c13d Mon Sep 17 00:00:00 2001
From: Thomas Walker Lynch
+ Two scopes of the same counter may be cut at a single boundary: a section and a subsection within it, both spanning the break. The far side then carries two make tags bearing
+ The consequence for implementation is that a scope must record which counter it will exit, not which machine object. Holding a reference taken at enter time applies the exit to a machine that a nested restoration has since displaced; the live machine never receives that exit, and its list is left one level too deep. The next sibling then increments within the stale level rather than at its own, giving one point two where two was meant. +
++ Resolving the counter name at exit time applies the exit to whichever machine is current. Where nothing displaces the entry the two are identical, which is why the fault appears only under nested cuts and is invisible to any test that splits a single scope. +
++ This is the same hazard as the rule against holding references to snapshots, in a different guise: the live machine is a moving target, and anything that means to act on the current one must look it up when it acts. +
+diff --git a/developer/authored/Manuscript.copy/Layout/counter.js b/developer/authored/Manuscript.copy/Layout/counter.js index 238b826..36c4cfa 100644 --- a/developer/authored/Manuscript.copy/Layout/counter.js +++ b/developer/authored/Manuscript.copy/Layout/counter.js @@ -351,7 +351,20 @@ if(node.nodeType !== Node.ELEMENT_NODE) return; const tag = (node.tagName || '').toLowerCase(); - let machine_to_exit = null; + /* The counter NAME is captured ,not the machine object. + + A nested continuation legitimately replaces ns.dict_instance[name]: the + inner make tag carrying 'continues' restores its own saved state over + the live entry. Holding an object reference from enter time would then + apply this scope's exit to a machine that is no longer current ,and the + live machine would never receive the exit. Two scopes of one counter cut + at the same boundary is exactly that case. + + Resolving the name at exit time applies the exit to whichever machine is + current. Where nothing replaces the entry ,this is identical to holding + the reference. + */ + let counter_to_exit = null; if(tag === 'rt·counter·make'){ const name = node.getAttribute('counter'); @@ -405,7 +418,7 @@ active_machine.count.set_name(step_name); } } - machine_to_exit = active_machine; + counter_to_exit = name; } }else if(tag === 'rt·counter·snapshot'){ const counter_name = node.getAttribute('counter'); @@ -428,7 +441,10 @@ child = child.nextElementSibling; } - if(machine_to_exit){ + if(counter_to_exit){ + // Resolve now ,not at enter time: a nested continuation may have + // replaced the live machine for this counter. + const machine_to_exit = ns.dict_instance[counter_to_exit]; const is_continued = node.getAttribute('continued') === 'true'; if(!is_continued){ machine_to_exit.exit(); @@ -436,6 +452,15 @@ const split_id = node.getAttribute('split-id'); if(split_id){ ns.dict_serial[split_id] = machine_to_exit.clone(); + }else{ + /* A soft close with no split id cannot be resumed: nothing names the + parked state ,so no continuation fragment can restore it and the + scope's exit never runs. Report it and exit normally rather than + suspending the scope permanently. */ + RT.Debug.error('counter' + ,"soft close with no split-id on counter '" + node.getAttribute('counter') + + "'. Scope cannot be resumed; exiting normally."); + machine_to_exit.exit(); } } } diff --git a/developer/authored/Manuscript.copy/Layout/paginate.js b/developer/authored/Manuscript.copy/Layout/paginate.js index f53b2ac..ac81af9 100644 --- a/developer/authored/Manuscript.copy/Layout/paginate.js +++ b/developer/authored/Manuscript.copy/Layout/paginate.js @@ -269,10 +269,28 @@ return { first: null ,rest: el ,firstHeight: 0 }; } + /* Decide whether a remainder exists BEFORE marking the fragment. + + A fragment marked 'continued' is soft closed: the counter walk suppresses + its exit and parks the machine under the split id, to be resumed by the + matching continuation fragment. If no remainder is produced there is no + continuation fragment ,so the scope would be suspended permanently ,its + exit never running. The next sibling step would then enter from status + 'preamble' rather than 'between' ,indenting instead of incrementing. + + This path is reached whenever a splitable element fits entirely ,which is + the common case: the caller invokes the splitter for every splitable + element without first testing whether it overflows. + */ + const has_rest = (best_count < children.length) || !!split_child_result || forced_break; + const first = el.cloneNode(false); - first.setAttribute('continued' ,'true'); const split_id = 'split_' + Math.random().toString(36).substr(2 ,9); - first.setAttribute('split-id' ,split_id); + + if(has_rest){ + first.setAttribute('continued' ,'true'); + first.setAttribute('split-id' ,split_id); + } for(let i = 0; i < best_count; i++){ first.appendChild(children[i].cloneNode(true)); @@ -280,7 +298,7 @@ if(split_child_result) first.appendChild(split_child_result.first); let rest = null; - if(best_count < children.length || split_child_result || forced_break){ + if(has_rest){ rest = el.cloneNode(false); rest.setAttribute('continuation' ,'true'); @@ -362,6 +380,35 @@ if(splitter){ const remaining = page_height_limit - current_h; + + /* A break belongs to this element only if it falls strictly inside it. + + When the element fits in the space remaining ,any break falls after + it â in the gap between this scope and the next sibling â and that + gap belongs to the parent ,not to this element. Neither sibling is + cut ,so neither should be soft closed; the counter walk simply + enters and exits each intact scope in turn ,across the page + boundary ,and the numbering follows. + + Splitting here regardless is what produced sibling top level + sections numbered 1 then 1.1: the first was marked continued ,its + exit suppressed ,and with no remainder there was no continuation + fragment to resume it. The scope stayed open ,so the next sibling + entered from 'preamble' and indented instead of incrementing. + + An interior forced break still requires splitting even when the + element fits ,since the break is genuinely inside it. + */ + const el_h = get_el_height(el); + const has_interior_break = !!el.querySelector('RT·page-break, RT·page-break-primitive'); + + if(el_h <= remaining && !has_interior_break){ + current_batch_seq.push(el); + current_h += el_h; + i++; + continue; + } + const { first ,rest ,firstHeight } = splitter(remaining); if(first){ -- 2.20.1