1
+ /*
2
+ * Copyright (C) 2025 Apple Inc. All rights reserved.
3
+ *
4
+ * Redistribution and use in source and binary forms, with or without
5
+ * modification, are permitted provided that the following conditions
6
+ * are met:
7
+ * 1. Redistributions of source code must retain the above copyright
8
+ * notice, this list of conditions and the following disclaimer.
9
+ * 2. Redistributions in binary form must reproduce the above copyright
10
+ * notice, this list of conditions and the following disclaimer in the
11
+ * documentation and/or other materials provided with the distribution.
12
+ *
13
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
14
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
15
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
16
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
17
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
19
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
22
+ * ARISING IN ANY WAY OUT of THE USE of THIS SOFTWARE, EVEN IF ADVISED of
23
+ * THE POSSIBILITY of SUCH DAMAGE.
24
+ */
25
+
26
+ // === LOREM IPSUM GENERATOR ===
27
+
28
+ const LoremIpsum = {
29
+ _words : [
30
+ 'lorem' , 'ipsum' , 'dolor' , 'sit' , 'amet' , 'consectetur' , 'adipiscing' , 'elit' , 'curabitur' , 'vel' , 'hendrerit' , 'libero' ,
31
+ 'eleifend' , 'blandit' , 'nunc' , 'ornare' , 'odio' , 'ut' , 'orci' , 'gravida' , 'imperdiet' , 'nullam' , 'purus' , 'lacinia' , 'a' ,
32
+ 'pretium' , 'quis' , 'congue' , 'praesent' , 'sagittis' , 'laoreet' , 'auctor' , 'mauris' , 'non' , 'velit' , 'eros' , 'dictum' ,
33
+ 'proin' , 'accumsan' , 'sapien' , 'nec' , 'massa' , 'volutpat' , 'venenatis' , 'sed' , 'eu' , 'molestie' , 'lacus' , 'quisque' ,
34
+ 'porttitor' , 'ligula' , 'dui' , 'mollis' , 'tempus' , 'at' , 'magna' , 'vestibulum' , 'turpis' , 'ac' , 'diam' , 'tincidunt' ,
35
+ 'id' , 'condimentum' , 'enim' , 'sodales' , 'in' , 'hac' , 'habitasse' , 'platea' , 'dictumst' , 'aenean' , 'neque' , 'fusce' ,
36
+ 'augue' , 'leo' , 'eget' , 'semper' , 'mattis' , 'tortor' , 'scelerisque' , 'nulla' , 'interdum' , 'tellus' , 'malesuada' ,
37
+ 'rhoncus' , 'porta' , 'sem' , 'aliquet' , 'et' , 'nam' , 'suspendisse' , 'potenti' , 'vivamus' , 'luctus' , 'fringilla' , 'erat' ,
38
+ ] ,
39
+
40
+ generate ( wordCount ) {
41
+ let words = [ ] ;
42
+ let sentenceIndex = 0 ;
43
+ while ( words . length < wordCount ) {
44
+ const sentenceLength = Math . floor ( Pseudo . random ( ) * 12 ) + 8 ; // 8 to 20 words
45
+ const paragraphLength = Math . floor ( Pseudo . random ( ) * 5 ) + 5 ; // 5 to 10 sentences │
46
+ for ( let p = 0 ; p < paragraphLength && words . length < wordCount ; p ++ ) {
47
+ for ( let i = 0 ; i < sentenceLength && words . length < wordCount ; i ++ ) {
48
+ let word = this . _words [ Math . floor ( Pseudo . random ( ) * this . _words . length ) ] ;
49
+ if ( i === 0 ) {
50
+ word = word . charAt ( 0 ) . toUpperCase ( ) + word . slice ( 1 ) ;
51
+ }
52
+ if ( i === sentenceLength - 1 ) {
53
+ word += '.' ;
54
+ }
55
+ const endOfParagraph = ( i === sentenceLength - 1 ) && ( p === paragraphLength - 1 ) ;
56
+ words . push ( { word, endOfParagraph, sentenceIndex } ) ;
57
+ }
58
+ sentenceIndex ++ ;
59
+ }
60
+ }
61
+ return words ;
62
+ }
63
+ } ;
64
+
65
+ // === TEXT LAYOUT ===
66
+
67
+ class TextLayout {
68
+ constructor ( words , context , pageWidth , pageHeight , fontSize ) {
69
+ this . words = words ;
70
+ this . context = context ;
71
+ this . pageWidth = pageWidth ;
72
+ this . pageHeight = pageHeight ;
73
+ this . pageMargin = 20 ;
74
+ this . lineHeight = 1.2 ;
75
+ this . fontSize = fontSize ;
76
+ this . pages = this . _layoutPages ( ) ;
77
+ }
78
+
79
+ _layoutPages ( ) {
80
+ const pages = [ ] ;
81
+ const drawableWidth = this . pageWidth - this . pageMargin * 2 ;
82
+ const drawableHeight = this . pageHeight - this . pageMargin * 2 ;
83
+
84
+ if ( this . words . length === 0 )
85
+ return pages ;
86
+
87
+ let currentPageWords = [ ] ;
88
+ let x = this . pageMargin ;
89
+ let y = this . pageMargin + this . fontSize ;
90
+
91
+ for ( const wordData of this . words ) {
92
+ let fontStyle = '' ;
93
+ if ( wordData . style === 'bold' ) fontStyle = 'bold ' ;
94
+ if ( wordData . style === 'italic' ) fontStyle = 'italic ' ;
95
+ this . context . font = `${ fontStyle } ${ this . fontSize } px sans-serif` ;
96
+
97
+ const word = wordData . word ;
98
+ const wordWidth = this . context . measureText ( word + ' ' ) . width ;
99
+
100
+ if ( x + wordWidth > drawableWidth + this . pageMargin ) {
101
+ x = this . pageMargin ;
102
+ y += this . fontSize * this . lineHeight ;
103
+ }
104
+
105
+ if ( y > drawableHeight ) {
106
+ pages . push ( currentPageWords ) ;
107
+ currentPageWords = [ ] ;
108
+ x = this . pageMargin ;
109
+ y = this . pageMargin + this . fontSize ;
110
+ }
111
+
112
+ currentPageWords . push ( { text : word , x, y, width : wordWidth , sentenceIndex : wordData . sentenceIndex , style : wordData . style } ) ;
113
+ x += wordWidth ;
114
+
115
+ if ( wordData . endOfParagraph ) {
116
+ x = this . pageMargin ;
117
+ y += this . fontSize * this . lineHeight * 2 ;
118
+ }
119
+ }
120
+
121
+ if ( currentPageWords . length > 0 ) {
122
+ pages . push ( currentPageWords ) ;
123
+ }
124
+
125
+ return pages ;
126
+ }
127
+ }
128
+
129
+
130
+ // === STAGE ===
131
+
132
+ class TextRenderingStage extends Stage {
133
+ async initialize ( benchmark , options ) {
134
+ await super . initialize ( benchmark , options ) ;
135
+
136
+ this . context = this . element . getContext ( '2d' ) ;
137
+ this . context . scale ( this . devicePixelRatio , this . devicePixelRatio ) ;
138
+
139
+ Pseudo . resetRandomSeed ( ) ;
140
+ this . words = LoremIpsum . generate ( 100000 ) ;
141
+ this . _complexity = 0 ;
142
+ this . numPagesToRender = 0 ;
143
+
144
+ // Assign highlight colors and styles to each word
145
+ const highlightColors = [ '#FFFFFF' , '#FFFFFF' , '#FFFFFF' , '#FFFF99' , '#99FF99' , '#99FFFF' , '#FF99FF' ] ;
146
+ const styles = [ 'bold' , 'italic' , 'underline' ] ;
147
+ this . sentenceColors = [ ] ;
148
+
149
+ this . words . forEach ( word => {
150
+ // Assign sentence color
151
+ if ( ! this . sentenceColors [ word . sentenceIndex ] ) {
152
+ this . sentenceColors [ word . sentenceIndex ] = highlightColors [ Math . floor ( Pseudo . random ( ) * highlightColors . length ) ] ;
153
+ }
154
+
155
+ // Assign word style
156
+ if ( Pseudo . random ( ) < 0.75 ) {
157
+ word . style = 'normal' ;
158
+ } else {
159
+ word . style = styles [ Math . floor ( Pseudo . random ( ) * styles . length ) ] ;
160
+ }
161
+ } ) ;
162
+
163
+ // Virtual dimensions
164
+ this . virtualDPI = 96 ;
165
+ this . virtualPageWidth = 8.5 * this . virtualDPI ;
166
+ this . virtualPageHeight = 11 * this . virtualDPI ;
167
+ this . virtualFontSize = ( 8 / 72 ) * this . virtualDPI ; // 8pt font
168
+
169
+ // Perform a single, full layout on the virtual pages.
170
+ this . virtualLayout = new TextLayout ( this . words , this . context , this . virtualPageWidth , this . virtualPageHeight , this . virtualFontSize ) ;
171
+ }
172
+
173
+ tune ( count ) {
174
+ this . _complexity = Math . max ( 0 , this . _complexity + count ) ;
175
+
176
+ let wordsCounted = 0 ;
177
+ let pages = 0 ;
178
+ for ( const page of this . virtualLayout . pages ) {
179
+ wordsCounted += page . length ;
180
+ pages ++ ;
181
+ if ( wordsCounted >= this . _complexity )
182
+ break ;
183
+ }
184
+ this . numPagesToRender = pages ;
185
+ }
186
+
187
+ animate ( ) {
188
+ const context = this . context ;
189
+ const stageSize = this . size ;
190
+
191
+ // Determine grid and page dimensions
192
+ let bestGrid = { cols : 0 , rows : 0 , aspectRatioDiff : Infinity } ;
193
+ const stageAspectRatio = stageSize . x / stageSize . y ;
194
+ const pageAspectRatio = this . virtualPageWidth / this . virtualPageHeight ;
195
+ const gapToPageHeightRatio = 0.05 ;
196
+ const numPages = this . numPagesToRender ;
197
+
198
+ if ( numPages === 0 ) {
199
+ context . clearRect ( 0 , 0 , stageSize . x , stageSize . y ) ;
200
+ return ;
201
+ }
202
+
203
+ for ( let cols = 1 ; cols <= numPages ; cols ++ ) {
204
+ const rows = Math . ceil ( numPages / cols ) ;
205
+ const gridAspectRatio = ( cols * pageAspectRatio + ( cols + 1 ) * gapToPageHeightRatio * pageAspectRatio ) / ( rows + ( rows + 1 ) * gapToPageHeightRatio ) ;
206
+ const aspectRatioDiff = Math . abs ( gridAspectRatio - stageAspectRatio ) ;
207
+ if ( aspectRatioDiff < bestGrid . aspectRatioDiff ) {
208
+ bestGrid = { cols, rows, aspectRatioDiff } ;
209
+ }
210
+ }
211
+
212
+ const { cols, rows } = bestGrid ;
213
+
214
+ let actualPageHeight , actualPageWidth , gap ;
215
+ const gridAspectRatio = ( cols * pageAspectRatio + ( cols + 1 ) * gapToPageHeightRatio * pageAspectRatio ) / ( rows + ( rows + 1 ) * gapToPageHeightRatio ) ;
216
+ if ( stageAspectRatio > gridAspectRatio ) { // Height is constrained
217
+ actualPageHeight = stageSize . y / ( rows + ( rows + 1 ) * gapToPageHeightRatio ) ;
218
+ } else { // Width is constrained
219
+ actualPageHeight = stageSize . x / ( cols * pageAspectRatio + ( cols + 1 ) * gapToPageHeightRatio * pageAspectRatio ) ;
220
+ }
221
+ actualPageWidth = actualPageHeight * pageAspectRatio ;
222
+ gap = actualPageHeight * gapToPageHeightRatio ;
223
+
224
+ const scale = actualPageHeight / this . virtualPageHeight ;
225
+ const scaledFontSize = this . virtualFontSize * scale ;
226
+ const scaledLineHeight = this . virtualLayout . lineHeight * scaledFontSize ;
227
+
228
+ // Draw background
229
+ context . fillStyle = 'lightgray' ;
230
+ context . fillRect ( 0 , 0 , stageSize . x , stageSize . y ) ;
231
+
232
+ const totalGridWidth = cols * actualPageWidth + ( cols - 1 ) * gap ;
233
+ const totalGridHeight = rows * actualPageHeight + ( rows - 1 ) * gap ;
234
+ const startX = ( stageSize . x - totalGridWidth ) / 2 ;
235
+ const startY = ( stageSize . y - totalGridHeight ) / 2 ;
236
+
237
+ let wordsDrawn = 0 ;
238
+ for ( let i = 0 ; i < numPages ; i ++ ) {
239
+ const pageData = this . virtualLayout . pages [ i ] ;
240
+ const pageColumn = i % cols ;
241
+ const pageRow = Math . floor ( i / cols ) ;
242
+ const pageX = startX + pageColumn * ( actualPageWidth + gap ) ;
243
+ const pageY = startY + pageRow * ( actualPageHeight + gap ) ;
244
+
245
+ // Draw page
246
+ context . fillStyle = 'white' ;
247
+ context . fillRect ( pageX , pageY , actualPageWidth , actualPageHeight ) ;
248
+ context . strokeStyle = 'black' ;
249
+ context . lineWidth = 1 ;
250
+ context . strokeRect ( pageX , pageY , actualPageWidth , actualPageHeight ) ;
251
+
252
+ // Draw text and highlights
253
+ for ( const word of pageData ) {
254
+ if ( wordsDrawn >= this . _complexity ) break ;
255
+
256
+ const scaledX = pageX + word . x * scale ;
257
+ const scaledY = pageY + word . y * scale ;
258
+ const scaledWidth = word . width * scale ;
259
+
260
+ // Highlight
261
+ context . fillStyle = this . sentenceColors [ word . sentenceIndex ] ;
262
+ context . fillRect ( scaledX , scaledY - scaledFontSize , scaledWidth , scaledLineHeight ) ;
263
+
264
+ // Text
265
+ let fontStyle = '' ;
266
+ if ( word . style === 'bold' ) fontStyle = 'bold ' ;
267
+ if ( word . style === 'italic' ) fontStyle = 'italic ' ;
268
+ context . font = `${ fontStyle } ${ scaledFontSize } px sans-serif` ;
269
+ context . fillStyle = 'black' ;
270
+ context . fillText ( word . text , scaledX , scaledY ) ;
271
+
272
+ // Underline
273
+ if ( word . style === 'underline' ) {
274
+ const underlineHeight = 1 * scale ;
275
+ context . fillRect ( scaledX , scaledY + 2 * scale , scaledWidth , underlineHeight ) ;
276
+ }
277
+
278
+ wordsDrawn ++ ;
279
+ }
280
+ if ( wordsDrawn >= this . _complexity ) break ;
281
+ }
282
+ }
283
+
284
+ complexity ( ) {
285
+ return this . _complexity ;
286
+ }
287
+ }
288
+
289
+ // === BENCHMARK ===
290
+
291
+ class TextRenderingBenchmark extends Benchmark {
292
+ constructor ( options ) {
293
+ super ( new TextRenderingStage ( ) , options ) ;
294
+ }
295
+ }
296
+
297
+ window . benchmarkClass = TextRenderingBenchmark ;
0 commit comments