@@ -56,34 +56,67 @@ function moveDown(y: number, ydelta: number): number {
5656 return y + ydelta ;
5757}
5858
59+ /**
60+ * Prefixes one fragment of a split key with a part header so a QR scanner can reassemble the
61+ * fragments without relying on scan order.
62+ *
63+ * When a key box's payload exceeds {@link QRBinaryMaxLength}, {@link splitKeys} divides it into
64+ * multiple QR codes. Each fragment's QR payload is encoded as a 1-based part header followed by
65+ * the fragment:
66+ *
67+ * "<index>/<total>|<fragment>" e.g. "1/3|<chunk>", "2/3|<chunk>", "3/3|<chunk>"
68+ *
69+ * A single-fragment payload is returned unchanged (no header).
70+ *
71+ * Reassembly contract for a consumer (e.g. a future recovery/scan tool):
72+ * 1. Scan every QR code for a box.
73+ * 2. Split each payload on the FIRST "|": the left side is "<index>/<total>", the right side
74+ * is the fragment.
75+ * 3. Verify parts 1..total are all present (total is identical in every header).
76+ * 4. Concatenate the fragments in ascending index order to recover the full box payload.
77+ * 5. Parse/decrypt as usual (for a vault box: JSON.parse, then decrypt each root value).
78+ *
79+ * Notes:
80+ * - The "|" delimiter is safe: base64 ciphertext, JSON, and base58 xpubs never contain it.
81+ * - This header exists ONLY inside the QR image. The human-readable "Data:" text printed on
82+ * the card is the full, unheadered payload; the PDF-text parser reads that, so this header
83+ * does not affect PDF-based recovery.
84+ */
85+ function encodeQrCodePart ( fragment : string , index : number , total : number ) : string {
86+ return total > 1 ? `${ index + 1 } /${ total } |${ fragment } ` : fragment ;
87+ }
88+
89+ // Draws QR codes down the left column, returning the index of the next QR still to draw
90+ // (for continuation on a later page) and the y-offset just below the drawn QR column (so
91+ // callers can place content, e.g. a note, under the QR codes).
5992function drawOnePageOfQrCodes (
6093 qrImages : HTMLCanvasElement [ ] ,
6194 doc : jsPDF ,
6295 y : number ,
6396 qrSize : number ,
64- startIndex
65- ) : number {
97+ startIndex : number
98+ ) : { nextIndex : number ; endY : number } {
6699 doc . setFont ( 'helvetica' ) ;
67100 let qrIndex : number = startIndex ;
68101 for ( ; qrIndex < qrImages . length ; qrIndex ++ ) {
69102 const image = qrImages [ qrIndex ] ;
70103 const textBuffer = 15 ;
71104 if ( y + qrSize + textBuffer >= doc . internal . pageSize . getHeight ( ) ) {
72- return qrIndex ;
105+ return { nextIndex : qrIndex , endY : y } ;
73106 }
74107
75108 doc . addImage ( image , left ( 0 ) , y , qrSize , qrSize ) ;
76109
77110 if ( qrImages . length === 1 ) {
78- return qrIndex + 1 ;
111+ return { nextIndex : qrIndex + 1 , endY : y + qrSize } ;
79112 }
80113
81114 y = moveDown ( y , qrSize + textBuffer ) ;
82115 doc . setFontSize ( font . body ) . setTextColor ( color . black ) ;
83116 doc . text ( 'Part ' + ( qrIndex + 1 ) . toString ( ) , left ( 0 ) , y ) ;
84117 y = moveDown ( y , 20 ) ;
85118 }
86- return qrIndex + 1 ;
119+ return { nextIndex : qrIndex , endY : y } ;
87120}
88121
89122function computeKeyCardImageDimensions ( keyCardImage : HTMLImageElement ) {
@@ -115,6 +148,7 @@ export async function drawKeycard({
115148 walletLabel,
116149 curve,
117150 pageBreakBeforeIndices = DEFAULT_PAGE_BREAK_INDICES ,
151+ useQrPartHeaders = false ,
118152} : IDrawKeyCard ) : Promise < jsPDF > {
119153 const jsPDFModule = await loadJSPDF ( ) ;
120154
@@ -203,11 +237,26 @@ export async function drawKeycard({
203237
204238 const qrImages : HTMLCanvasElement [ ] = [ ] ;
205239 const keys = splitKeys ( qr . data , QRBinaryMaxLength ) ;
206- for ( const key of keys ) {
207- qrImages . push ( await QRCode . toCanvas ( key , { errorCorrectionLevel : 'L' } ) ) ;
240+ for ( let i = 0 ; i < keys . length ; i ++ ) {
241+ const payload = useQrPartHeaders ? encodeQrCodePart ( keys [ i ] , i , keys . length ) : keys [ i ] ;
242+ qrImages . push ( await QRCode . toCanvas ( payload , { errorCorrectionLevel : 'L' } ) ) ;
208243 }
209244
210- let nextQrIndex = drawOnePageOfQrCodes ( qrImages , doc , y , qrSize , 0 ) ;
245+ const isMultiPart = qr ?. data ?. length > QRBinaryMaxLength ;
246+ const { nextIndex, endY : qrColumnEndY } = drawOnePageOfQrCodes ( qrImages , doc , y , qrSize , 0 ) ;
247+ let nextQrIndex = nextIndex ;
248+
249+ // For a split key, place the "put all Parts together" note directly under the QR codes.
250+ // Wrap it to the QR column width so it stays in the left column and never overlaps the
251+ // Data payload rendered on the right.
252+ let qrColumnBottom = qrColumnEndY ;
253+ if ( isMultiPart ) {
254+ doc . setFontSize ( font . body - 2 ) . setTextColor ( color . darkgray ) ;
255+ const noteLines = doc . splitTextToSize ( 'Note: you will need to put all Parts together for the full key' , qrSize ) ;
256+ const noteY = moveDown ( qrColumnEndY , 15 ) ;
257+ doc . text ( noteLines , left ( 0 ) , noteY ) ;
258+ qrColumnBottom = noteY + noteLines . length * doc . getLineHeight ( ) ;
259+ }
211260
212261 doc . setFontSize ( font . subheader ) . setTextColor ( color . black ) ;
213262 y = moveDown ( y , 10 ) ;
@@ -220,11 +269,6 @@ export async function drawKeycard({
220269 doc . text ( qr . description , textLeft , y ) ;
221270 textHeight += doc . getLineHeight ( ) ;
222271 doc . setFontSize ( font . body - 2 ) ;
223- if ( qr ?. data ?. length > QRBinaryMaxLength ) {
224- y = moveDown ( y , 30 ) ;
225- textHeight += 30 ;
226- doc . text ( 'Note: you will need to put all Parts together for the full key' , textLeft , y ) ;
227- }
228272 y = moveDown ( y , 30 ) ;
229273 textHeight += 30 ;
230274 doc . text ( 'Data:' , textLeft , y ) ;
@@ -242,7 +286,11 @@ export async function drawKeycard({
242286 textHeight = 0 ;
243287 y = 30 ;
244288 topY = y ;
245- nextQrIndex = drawOnePageOfQrCodes ( qrImages , doc , y , qrSize , nextQrIndex ) ;
289+ // Redraw remaining QRs on the new page and update the column bottom to THIS page, so
290+ // the rowHeight math below stays same-page (avoids a stale page-1 coordinate).
291+ const continued = drawOnePageOfQrCodes ( qrImages , doc , y , qrSize , nextQrIndex ) ;
292+ nextQrIndex = continued . nextIndex ;
293+ qrColumnBottom = continued . endY ;
246294 doc . setFont ( 'courier' ) . setFontSize ( 9 ) . setTextColor ( color . black ) ;
247295 }
248296 doc . text ( lines [ line ] , textLeft , y ) ;
@@ -273,9 +321,10 @@ export async function drawKeycard({
273321 }
274322
275323 doc . setFont ( 'helvetica' ) ;
276- // Move down the size of the QR code minus accumulated height on the right side, plus margin
277- // if we have a key that spans multiple pages, then exclude QR code size
278- const rowHeight = Math . max ( qr . data . length > QRBinaryMaxLength ? qrSize + 20 : qrSize , textHeight ) ;
324+ // Move down past the taller of the two columns: the right-side text (textHeight) or the
325+ // left-side QR column (plus the note printed under it for split keys), then add a margin.
326+ const qrColumnHeight = isMultiPart ? qrColumnBottom - topY : qrSize ;
327+ const rowHeight = Math . max ( qrColumnHeight , textHeight ) ;
279328 const marginBottom = 15 ;
280329 y = moveDown ( y , rowHeight - ( y - topY ) + marginBottom ) ;
281330 }
0 commit comments