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