@@ -234,13 +234,28 @@ export function generateMultiPageZplWithMap(
234234 // blocks didn't set. `undefined` means nothing declared one yet, so a block
235235 // inheriting its ^JM from outside this export gets the declaration back.
236236 let wireJm : JmDensity | undefined ;
237+ // A recalled format sets whatever density it stored, so the block after it declares its own.
238+ let wireUnknown = false ;
237239 const ledger = uploadLedger ( pages ) ;
238240 const shippedFonts = new Set < string > ( ) ;
239241 pages . forEach ( ( p , pageIndex ) => {
240242 const pageLabel = pageLabelConfig ( label , p ) ;
241243 const carried = new Set ( ( p . overlay ?. segments ?? [ ] ) . flatMap ( ( seg ) => ( seg . kind === 'upload' ? [ seg . key ] : [ ] ) ) ) ;
242244 const policy = ledger . policyFor ( carried ) ;
243245 const regenPage = ( ) => generateZplBlock ( pageLabel , p . objects , variables , undefined , policy . omit , shippedFonts ) ;
246+ const recallPath = recallOnlyPath ( pageLabel ) ;
247+ if ( recallPath !== undefined ) {
248+ // The printer holds the format, so the job keeps only the uploads it still ships and fills every slot.
249+ const regen = regenPage ( ) ;
250+ ledger . settle ( carried , regen . uploads ?? [ ] ) ;
251+ if ( out . length > 0 && ! out . endsWith ( '\n' ) ) out += '\n' ;
252+ const base = out . length ;
253+ const { recalls } = planRecalls ( pageLabel , p . objects , variables , NO_ROWS , NO_BINDINGS ) ;
254+ out += `${ regen . preamble ?? '' } ${ recallBlock ( recallPath , recallFields ( recalls ) ) } ` ;
255+ blocks . push ( { start : base , end : out . length } ) ;
256+ wireUnknown = true ;
257+ return ;
258+ }
244259 let emitted : PageBlock ;
245260 try {
246261 emitted = emitPageBlock ( pageLabel , p , variables , policy , shippedFonts ) ;
@@ -252,13 +267,13 @@ export function generateMultiPageZplWithMap(
252267 }
253268 // Unset means full density, so a page after a ^JMB page resets the wire;
254269 // while nothing is declared yet there is nothing to reset.
255- const target = pageLabel . jmDensity ?? ( wireJm ? 'A' : undefined ) ;
270+ const target = pageLabel . jmDensity ?? ( wireJm || wireUnknown ? 'A' : undefined ) ;
256271 // A replayed block whose head the parser never recorded (or whose bytes
257272 // moved) has no place to splice a declaration; a density-only scan of its
258273 // bytes (cold path) decides instead of always regenerating.
259274 if ( ! emitted . head ) {
260275 const cold = reconstructBlockHead ( emitted . block ) ;
261- const action = target === undefined ? 'keep' : headlessAction ( cold . density , target , wireJm ) ;
276+ const action = target === undefined ? 'keep' : headlessAction ( cold . density , target , wireUnknown ? undefined : wireJm ) ;
262277 // Bytes that disagree on the ^DF regenerate too: there is no head to splice.
263278 if ( action === 'regenerate' || cold . storedFormat ?. path !== pageLabel . storedFormatPath ) {
264279 emitted = regenPage ( ) ;
@@ -269,11 +284,12 @@ export function generateMultiPageZplWithMap(
269284 }
270285 const stored = applyStoredFormat ( emitted , pageLabel . storedFormatPath ) ;
271286 emitted = { ...emitted , block : stored . block , head : stored . head } ;
272- const applied = applyJmDensity ( emitted , pageLabel . jmDensity , target === wireJm ? undefined : target ) ;
287+ const applied = applyJmDensity ( emitted , pageLabel . jmDensity , ! wireUnknown && target === wireJm ? undefined : target ) ;
273288 const block = applied . block ;
274289 // Track what the block actually carries, not the intent: an unreachable
275290 // head would leave the next block believing a density the wire never got.
276291 wireJm = applied . wireJm ?? wireJm ;
292+ wireUnknown = false ;
277293 // An overlay page already carries the inter-block separator captured at
278294 // import (the splitter folds it into the preceding block). Only insert a
279295 // newline when the previous block didn't end with one (a fresh or
@@ -305,6 +321,8 @@ interface PageBlock {
305321 block : string ;
306322 head : FormatHead | undefined ;
307323 spans : ObjectSpan [ ] ;
324+ /** Every line ahead of ^XA, each with its newline, so a recall job can keep them and drop the rest. */
325+ preamble ?: string ;
308326 replayed ?: true ;
309327 /** Keys of the graphic uploads the block's bytes ship, replayed or emitted. */
310328 uploads ?: readonly string [ ] ;
@@ -784,21 +802,60 @@ function emitPageBlock(
784802/** Where a batch stores its template when the design names none ^XF could recall: R: is volatile RAM, matching a single run. */
785803const BATCH_TEMPLATE_PATH = 'R:LBL.ZPL' ;
786804
787- /** Store template via ^DF then emit one ^XA^XF...^XZ recall block per
788- * CSV row. Unmapped variables fall back to the template's ^FD default. */
789- export function generateBatchZpl (
790- // Page-resolved: batch recalls one page's objects, so the caller must hand
791- // in the label that page prints at.
805+ /** The path a job recalls instead of storing, once the page says so and ^XF can read the name. */
806+ export function recallOnlyPath ( page : Pick < PageLabel , 'storedFormatPath' | 'storedFormatDelivery' > ) : string | undefined {
807+ const path = page . storedFormatPath ;
808+ return page . storedFormatDelivery !== undefined && path !== undefined && isRecallableFormatPath ( path ) ? path : undefined ;
809+ }
810+
811+ /** Storage keys more than one page stores its format under, since the printer keeps one format per name. */
812+ export function contestedFormatKeys ( pages : readonly Pick < PageLabel , 'storedFormatPath' > [ ] ) : Set < string > {
813+ const seen = new Set < string > ( ) ;
814+ const contested = new Set < string > ( ) ;
815+ for ( const p of pages ) {
816+ if ( p . storedFormatPath === undefined ) continue ;
817+ const key = storageKey ( p . storedFormatPath ) ;
818+ if ( seen . has ( key ) ) contested . add ( key ) ;
819+ seen . add ( key ) ;
820+ }
821+ return contested ;
822+ }
823+
824+ export type RecallWayIssue = 'longName' | 'contested' ;
825+
826+ /** Why a page may not recall its format: ^XF cannot read the name, or another page stores under it. */
827+ export function recallWayIssue ( page : Pick < PageLabel , 'storedFormatPath' > , pages : readonly Pick < PageLabel , 'storedFormatPath' > [ ] ) : RecallWayIssue | undefined {
828+ const path = page . storedFormatPath ;
829+ if ( path === undefined ) return undefined ;
830+ if ( ! isRecallableFormatPath ( path ) ) return 'longName' ;
831+ return contestedFormatKeys ( pages ) . has ( storageKey ( path ) ) ? 'contested' : undefined ;
832+ }
833+
834+ function recallBlock ( path : string , fields : readonly string [ ] ) : string {
835+ return [ '^XA' , `^XF${ path } ` , ...fields , '^XZ' ] . join ( '\n' ) ;
836+ }
837+
838+ interface RecallEntry {
839+ fn : number ;
840+ colIdx : number ;
841+ transform : ( s : string ) => string ;
842+ defaultValue : string ;
843+ }
844+
845+ const NO_ROWS = { headers : [ ] as readonly string [ ] , rows : [ ] as readonly ( readonly string [ ] ) [ ] } ;
846+ const NO_BINDINGS = { bindings : { } as Record < string , string > } ;
847+
848+ /** The stored template and the slots a recall supplies: the mapped ones while the job stores the format itself,
849+ * every slot once the printer holds it, since a stored ^FD seals its slot against the recall (ZD230). */
850+ function planRecalls (
792851 label : PageLabel ,
793852 objects : LabelObject [ ] ,
794853 variables : readonly Variable [ ] ,
795- dataset : {
796- headers : readonly string [ ] ;
797- rows : readonly ( readonly string [ ] ) [ ] ;
798- } ,
854+ dataset : { headers : readonly string [ ] ; rows : readonly ( readonly string [ ] ) [ ] } ,
799855 columnMapping : { bindings : Record < string , string > } ,
800- ) : string {
856+ ) : { template : PageBlock ; path : string ; recalls : RecallEntry [ ] } {
801857 const identity = ( s : string ) => s ;
858+ const recallOnly = recallOnlyPath ( label ) !== undefined ;
802859 // Only leaves that emit an ^FN in the stored template may donate their
803860 // transform: an excluded or home-dropped leaf or a rotated QR shipped as
804861 // ^GFA would stamp a foreign encoding onto the surviving co-consumer.
@@ -814,10 +871,10 @@ export function generateBatchZpl(
814871 const modeDFns = batchBuckets . modeDExclusive ;
815872 const plain128Fns = batchBuckets . plainExclusive ;
816873 const plainSharedFns = batchBuckets . plainShared ;
817- const overrides : { fn : number ; colIdx : number ; transform : ( s : string ) => string } [ ] = [ ] ;
874+ const entries : RecallEntry [ ] = [ ] ;
818875 for ( const v of variables ) {
819876 const colIdx = boundColumnIndex ( v , dataset , columnMapping ) ;
820- if ( colIdx === - 1 ) continue ;
877+ if ( colIdx === - 1 && ! recallOnly ) continue ;
821878 // Apply the bound field's ^FD transform (QR prefix, UPC-E compaction, GS1
822879 // escaping) to each row value, matching the single-format export so the
823880 // recall doesn't overwrite ^FN with an untransformed payload. The bound
@@ -844,29 +901,48 @@ export function generateBatchZpl(
844901 : plain128Fns . has ( v . fnNumber )
845902 ? ( s : string ) => planCode128Fd ( s , 'templateValue' ) . fd
846903 : identity ) ;
847- overrides . push ( { fn : v . fnNumber , colIdx, transform } ) ;
904+ entries . push ( { fn : v . fnNumber , colIdx, transform, defaultValue : v . defaultValue } ) ;
848905 }
849906
850- // Mapped slots stay bare in the stored format; the recall supplies them.
851- const mappedFns = new Set ( overrides . map ( ( o ) => o . fn ) ) ;
852- const templatePath = label . storedFormatPath && isRecallableFormatPath ( label . storedFormatPath ) ? label . storedFormatPath : BATCH_TEMPLATE_PATH ;
853- const templateStored = generateZplBlock ( { ...label , storedFormatPath : templatePath } , objects , variables , mappedFns ) . block ;
907+ // Supplied slots stay bare in the stored format; the recall fills them.
908+ const bareFns = new Set ( entries . map ( ( e ) => e . fn ) ) ;
909+ const path = recallOnlyPath ( label ) ?? ( label . storedFormatPath && isRecallableFormatPath ( label . storedFormatPath ) ? label . storedFormatPath : BATCH_TEMPLATE_PATH ) ;
910+ const template = generateZplBlock ( { ...label , storedFormatPath : path } , objects , variables , bareFns ) ;
854911 // A recall for a slot the stored format never declares prints as a stray field (ZD230).
855- const storedFnSlots = new Set ( [ ...templateStored . matchAll ( / \^ F N ( \d + ) / g) ] . map ( ( m ) => Number ( m [ 1 ] ) ) ) ;
856- const recalls = overrides . filter ( ( o ) => storedFnSlots . has ( o . fn ) ) ;
857-
858- const recallBlocks = dataset . rows . map ( ( row ) => {
859- const lines : string [ ] = [ '^XA' , `^XF${ templatePath } ` ] ;
860- for ( const { fn, colIdx, transform } of recalls ) {
861- const value = row [ colIdx ] ?? '' ;
862- // fdField applies ^FH hex-escape for ^/~ so fields don't terminate early.
863- lines . push ( `^FN${ fn } ${ fdField ( transform ( value ) ) } ` ) ;
864- }
865- lines . push ( '^XZ' ) ;
866- return lines . join ( '\n' ) ;
867- } ) ;
912+ const storedFnSlots = new Set ( [ ...template . block . matchAll ( / \^ F N ( \d + ) / g) ] . map ( ( m ) => Number ( m [ 1 ] ) ) ) ;
913+ return { template, path, recalls : entries . filter ( ( e ) => storedFnSlots . has ( e . fn ) ) } ;
914+ }
868915
869- return [ templateStored , ...recallBlocks ] . join ( '\n' ) ;
916+ /** A slot takes its row value, or its default when no row or no column supplies one. */
917+ function recallFields ( recalls : readonly RecallEntry [ ] , row ?: readonly string [ ] ) : string [ ] {
918+ // fdField applies ^FH hex-escape for ^/~ so fields don't terminate early.
919+ return recalls . map ( ( { fn, colIdx, transform, defaultValue } ) =>
920+ `^FN${ fn } ${ fdField ( transform ( row !== undefined && colIdx !== - 1 ? row [ colIdx ] ?? '' : defaultValue ) ) } ` ) ;
921+ }
922+
923+ /** The ^DF frame the setup script stores for a page, every slot bare and the preamble stripped. */
924+ export function generateStoredFormatBlock ( label : PageLabel , objects : LabelObject [ ] , variables : readonly Variable [ ] = [ ] ) : string {
925+ const { template } = planRecalls ( label , objects , variables , NO_ROWS , NO_BINDINGS ) ;
926+ return template . block . slice ( template . preamble ?. length ?? 0 ) ;
927+ }
928+
929+ /** One ^XA^XF...^XZ recall block per CSV row, led by the ^DF template unless the page says the printer holds it. */
930+ export function generateBatchZpl (
931+ // Page-resolved: batch recalls one page's objects, so the caller must hand
932+ // in the label that page prints at.
933+ label : PageLabel ,
934+ objects : LabelObject [ ] ,
935+ variables : readonly Variable [ ] ,
936+ dataset : {
937+ headers : readonly string [ ] ;
938+ rows : readonly ( readonly string [ ] ) [ ] ;
939+ } ,
940+ columnMapping : { bindings : Record < string , string > } ,
941+ ) : string {
942+ const { template, path, recalls } = planRecalls ( label , objects , variables , dataset , columnMapping ) ;
943+ const recallBlocks = dataset . rows . map ( ( row ) => recallBlock ( path , recallFields ( recalls , row ) ) ) ;
944+ const lead = recallOnlyPath ( label ) === undefined ? template . block : ( template . preamble ?? '' ) . trimEnd ( ) ;
945+ return [ ...( lead === '' ? [ ] : [ lead ] ) , ...recallBlocks ] . join ( '\n' ) ;
870946}
871947
872948// ^FT graphics anchor at a bottom corner (spec p.205), so the drop test uses
@@ -1014,6 +1090,7 @@ function generateZplBlock(
10141090 // ~JS is immediate/transient like ~SD; emit before ^XA.
10151091 if ( label . backfeedSequence ) lines . push ( `~JS${ label . backfeedSequence } ` ) ;
10161092
1093+ const preambleLines = lines . length ;
10171094 lines . push ( '^XA' ) ;
10181095 const lineStart = ( ) => lines . reduce ( ( n , l ) => n + l . length + 1 , 0 ) ;
10191096 // Offset just past the ^XA: every preceding line plus its newline.
@@ -1172,6 +1249,7 @@ function generateZplBlock(
11721249 block : aliased . join ( '\n' ) ,
11731250 head : { caret : '^' , at : headAt , jmSpans, ...( dfSpans ? { dfSpans } : { } ) } ,
11741251 spans,
1252+ preamble : aliased . slice ( 0 , preambleLines ) . map ( ( l ) => `${ l } \n` ) . join ( '' ) ,
11751253 uploads : uploads . keys ,
11761254 } ;
11771255}
0 commit comments