Index: /trunk/src/wp-admin/includes/privacy-tools.php
===================================================================
--- /trunk/src/wp-admin/includes/privacy-tools.php	(revision 47277)
+++ /trunk/src/wp-admin/includes/privacy-tools.php	(revision 47278)
@@ -217,4 +217,5 @@
  *
  * @since 4.9.6
+ * @since 5.4.0 Added the `$group_id` and `$groups_count` parameters.
  *
  * @param array $group_data {
@@ -233,8 +234,12 @@
  *     }
  * }
- * @return string The HTML for this group and its items.
- */
-function wp_privacy_generate_personal_data_export_group_html( $group_data ) {
-	$group_html  = '<h2>';
+ * @param string  $group_id     The group identifier.
+ * @param int     $groups_count The number of all groups
+ * @return string $group_html   The HTML for this group and its items.
+ */
+function wp_privacy_generate_personal_data_export_group_html( $group_data, $group_id = '', $groups_count = 1 ) {
+	$group_id_attr = sanitize_title_with_dashes( $group_data['group_label'] . '-' . $group_id );
+
+	$group_html  = '<h2 id="' . esc_attr( $group_id_attr ) . '">';
 	$group_html .= esc_html( $group_data['group_label'] );
 
@@ -271,4 +276,10 @@
 		$group_html .= '</tbody>';
 		$group_html .= '</table>';
+	}
+
+	if ( 1 < $groups_count ) {
+		$group_html .= '<div class="return_to_top">';
+		$group_html .= '<a href="#top">' . esc_html__( '&uarr; Return to top' ) . '</a>';
+		$group_html .= '</div>';
 	}
 
@@ -374,4 +385,6 @@
 	$groups = array_merge( array( 'about' => $about_group ), $groups );
 
+	$groups_count = count( $groups );
+
 	// Convert the groups to JSON format.
 	$groups_json = wp_json_encode( $groups );
@@ -411,4 +424,5 @@
 	fwrite( $file, 'td { padding: 5px; }' );
 	fwrite( $file, 'tr:nth-child(odd) { background-color: #fafafa; }' );
+	fwrite( $file, '.return_to_top { text-align:right; }' );
 	fwrite( $file, '</style>' );
 	fwrite( $file, '<title>' );
@@ -417,9 +431,29 @@
 	fwrite( $file, "</head>\n" );
 	fwrite( $file, "<body>\n" );
-	fwrite( $file, '<h1>' . esc_html__( 'Personal Data Export' ) . '</h1>' );
+	fwrite( $file, '<h1 id="top">' . esc_html__( 'Personal Data Export' ) . '</h1>' );
+
+	// Create TOC.
+	if ( 1 < $groups_count ) {
+		fwrite( $file, '<div id="table_of_contents">' );
+		fwrite( $file, '<h2>' . esc_html__( 'Table of Contents' ) . '</h2>' );
+		fwrite( $file, '<ul>' );
+		foreach ( (array) $groups as $group_id => $group_data ) {
+			$group_label       = esc_html( $group_data['group_label'] );
+			$group_id_attr     = sanitize_title_with_dashes( $group_data['group_label'] . '-' . $group_id );
+			$group_items_count = count( (array) $group_data['items'] );
+			if ( $group_items_count > 1 ) {
+				$group_label .= sprintf( ' <span class="count">(%d)</span>', $group_items_count );
+			}
+			fwrite( $file, '<li>' );
+			fwrite( $file, '<a href="#' . esc_attr( $group_id_attr ) . '">' . $group_label . '</a>' );
+			fwrite( $file, '</li>' );
+		}
+		fwrite( $file, '</ul>' );
+		fwrite( $file, '</div>' );
+	}
 
 	// Now, iterate over every group in $groups and have the formatter render it in HTML.
 	foreach ( (array) $groups as $group_id => $group_data ) {
-		fwrite( $file, wp_privacy_generate_personal_data_export_group_html( $group_data ) );
+		fwrite( $file, wp_privacy_generate_personal_data_export_group_html( $group_data, $group_id, $groups_count ) );
 	}
 
Index: /trunk/tests/phpunit/tests/privacy/wpPrivacyGeneratePersonalDataExportFile.php
===================================================================
--- /trunk/tests/phpunit/tests/privacy/wpPrivacyGeneratePersonalDataExportFile.php	(revision 47277)
+++ /trunk/tests/phpunit/tests/privacy/wpPrivacyGeneratePersonalDataExportFile.php	(revision 47278)
@@ -261,6 +261,6 @@
 		$request         = wp_get_user_request( self::$export_request_id );
 
-		$this->assertContains( '<h1>Personal Data Export</h1>', $report_contents );
-		$this->assertContains( '<h2>About</h2>', $report_contents );
+		$this->assertContains( '<h1 id="top">Personal Data Export</h1>', $report_contents );
+		$this->assertContains( '<h2 id="about-about">About</h2>', $report_contents );
 		$this->assertContains( $request->email, $report_contents );
 	}
@@ -295,3 +295,363 @@
 		$this->assertContains( '"about"', $report_contents_json );
 	}
+
+	/**
+	 * Test the export HTML file containing one export group has no table of contents.
+	 *
+	 * @ticket 46894
+	 */
+	public function test_single_group_export_no_toc_or_return_to_top() {
+		$this->expectOutputString( '' );
+		wp_privacy_generate_personal_data_export_file( self::$export_request_id );
+		$this->assertTrue( file_exists( $this->export_file_name ) );
+
+		$report_dir = trailingslashit( self::$exports_dir . 'test_contents' );
+		mkdir( $report_dir );
+
+		$zip        = new ZipArchive();
+		$opened_zip = $zip->open( $this->export_file_name );
+		$this->assertTrue( $opened_zip );
+
+		$zip->extractTo( $report_dir );
+		$zip->close();
+		$this->assertTrue( file_exists( $report_dir . 'index.html' ) );
+
+		$report_contents = file_get_contents( $report_dir . 'index.html' );
+		$request         = wp_get_user_request( self::$export_request_id );
+
+		$this->assertNotContains( '<div id="table_of_contents">', $report_contents );
+		$this->assertNotContains( '<div class="return_to_top">', $report_contents );
+		$this->assertContains( $request->email, $report_contents );
+	}
+
+	/**
+	 * Test the export HTML file containing ore than one export group has a table of contents.
+	 *
+	 * @ticket 46894
+	 */
+	public function test_multiple_group_export_has_toc_and_return_to_top() {
+		$this->expectOutputString( '' );
+
+		// Setup Export Data to contain multiple groups
+		$export_data_grouped = array(
+			'user' => array(
+				'group_label'       => 'User',
+				'group_description' => 'User&#8217;s profile data.',
+				'items'             => array(
+					'user-1' => array(
+						array(
+							'name'  => 'User ID',
+							'value' => 1,
+						),
+						array(
+							'name'  => 'User Login Name',
+							'value' => 'user_login',
+						),
+						array(
+							'name'  => 'User Nice Name',
+							'value' => 'User Name',
+						),
+						array(
+							'name'  => 'User Email',
+							'value' => 'export-requester@example.com',
+						),
+						array(
+							'name'  => 'User Registration Date',
+							'value' => '2020-01-31 19:29:29',
+						),
+						array(
+							'name'  => 'User Display Name',
+							'value' => 'User Name',
+						),
+						array(
+							'name'  => 'User Nickname',
+							'value' => 'User',
+						),
+					),
+				),
+			),
+		);
+		update_post_meta( self::$export_request_id, '_export_data_grouped', $export_data_grouped );
+
+		// Generate Export File
+		wp_privacy_generate_personal_data_export_file( self::$export_request_id );
+		$this->assertTrue( file_exists( $this->export_file_name ) );
+
+		// Cleam-up for subsequent tests
+		update_post_meta( self::$export_request_id, '_export_data_grouped', array() );
+
+		$report_dir = trailingslashit( self::$exports_dir . 'test_contents' );
+		mkdir( $report_dir );
+
+		$zip        = new ZipArchive();
+		$opened_zip = $zip->open( $this->export_file_name );
+		$this->assertTrue( $opened_zip );
+
+		$zip->extractTo( $report_dir );
+		$zip->close();
+		$this->assertTrue( file_exists( $report_dir . 'index.html' ) );
+
+		$report_contents = file_get_contents( $report_dir . 'index.html' );
+		$request         = wp_get_user_request( self::$export_request_id );
+
+		$this->assertContains( '<div id="table_of_contents">', $report_contents );
+		$this->assertContains( '<h2 id="user-user">User</h2>', $report_contents );
+		$this->assertContains( '<div class="return_to_top">', $report_contents );
+		$this->assertContains( $request->email, $report_contents );
+	}
+
+	/**
+	 * Test the export HTML file containing multiple export groups with multiple group items
+	 * has a table of contents with group count.
+	 *
+	 * @ticket 46894
+	 */
+	public function test_multiple_group_export_multiple_items_group_count_in_toc() {
+		$this->expectOutputString( '' );
+
+		// Setup Export Data to contain multiple groups
+		$export_data_grouped = array(
+			'user'     => array(
+				'group_label'       => 'User',
+				'group_description' => 'User&#8217;s profile data.',
+				'items'             => array(
+					'user-1' => array(
+						array(
+							'name'  => 'User ID',
+							'value' => 1,
+						),
+						array(
+							'name'  => 'User Login Name',
+							'value' => 'user_login',
+						),
+						array(
+							'name'  => 'User Nice Name',
+							'value' => 'User Name',
+						),
+						array(
+							'name'  => 'User Email',
+							'value' => 'export-requester@example.com',
+						),
+						array(
+							'name'  => 'User Registration Date',
+							'value' => '2020-01-31 19:29:29',
+						),
+						array(
+							'name'  => 'User Display Name',
+							'value' => 'User Name',
+						),
+						array(
+							'name'  => 'User Nickname',
+							'value' => 'User',
+						),
+					),
+				),
+			),
+			'comments' => array(
+				'group_label'       => 'Comments',
+				'group_description' => 'User&#8217;s comment data.',
+				'items'             => array(
+					'comment-2' => array(
+						array(
+							'name'  => 'Comment Author',
+							'value' => 'User Name',
+						),
+						array(
+							'name'  => 'Comment Author Email',
+							'value' => 'export-requester@example.com',
+						),
+						array(
+							'name'  => 'Comment Author IP',
+							'value' => '::1',
+						),
+						array(
+							'name'  => 'Comment Author User Agent',
+							'value' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36',
+						),
+						array(
+							'name'  => 'Comment Date',
+							'value' => '2020-01-31 19:55:19',
+						),
+						array(
+							'name'  => 'Comment Content',
+							'value' => 'Test',
+						),
+						array(
+							'name'  => 'Comment URL',
+							'value' => '<a href="http://localhost:8888/46894/2020/01/31/hello-world/#comment-2" target="_blank" rel="noreferrer noopener">http://localhost:8888/46894/2020/01/31/hello-world/#comment-2</a>',
+						),
+					),
+					'comment-3' => array(
+						array(
+							'name'  => 'Comment Author',
+							'value' => 'User Name',
+						),
+						array(
+							'name'  => 'Comment Author Email',
+							'value' => 'export-requester@example.com',
+						),
+						array(
+							'name'  => 'Comment Author IP',
+							'value' => '::1',
+						),
+						array(
+							'name'  => 'Comment Author User Agent',
+							'value' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36',
+						),
+						array(
+							'name'  => 'Comment Date',
+							'value' => '2020-01-31 20:55:19',
+						),
+						array(
+							'name'  => 'Comment Content',
+							'value' => 'Test #2',
+						),
+						array(
+							'name'  => 'Comment URL',
+							'value' => '<a href="http://localhost:8888/46894/2020/01/31/hello-world/#comment-3" target="_blank" rel="noreferrer noopener">http://localhost:8888/46894/2020/01/31/hello-world/#comment-3</a>',
+						),
+					),
+				),
+			),
+		);
+		update_post_meta( self::$export_request_id, '_export_data_grouped', $export_data_grouped );
+
+		// Generate Export File
+		wp_privacy_generate_personal_data_export_file( self::$export_request_id );
+		$this->assertTrue( file_exists( $this->export_file_name ) );
+
+		// Cleam-up for subsequent tests
+		update_post_meta( self::$export_request_id, '_export_data_grouped', array() );
+
+		$report_dir = trailingslashit( self::$exports_dir . 'test_contents' );
+		mkdir( $report_dir );
+
+		$zip        = new ZipArchive();
+		$opened_zip = $zip->open( $this->export_file_name );
+		$this->assertTrue( $opened_zip );
+
+		$zip->extractTo( $report_dir );
+		$zip->close();
+		$this->assertTrue( file_exists( $report_dir . 'index.html' ) );
+
+		$report_contents = file_get_contents( $report_dir . 'index.html' );
+		$request         = wp_get_user_request( self::$export_request_id );
+
+		$this->assertContains( '<div id="table_of_contents">', $report_contents );
+		$this->assertContains( '<a href="#comments-comments">Comments <span class="count">(2)</span></a>', $report_contents );
+		$this->assertContains( $request->email, $report_contents );
+	}
+
+	/**
+	 * Test the export HTML file containing multiple export groups with no multiple group items
+	 * has a table of contents without group count.
+	 *
+	 * @ticket 46894
+	 */
+	public function test_multiple_group_export_single_items_no_group_count_in_toc() {
+		$this->expectOutputString( '' );
+
+		// Setup Export Data to contain multiple groups
+		$export_data_grouped = array(
+			'user'     => array(
+				'group_label'       => 'User',
+				'group_description' => 'User&#8217;s profile data.',
+				'items'             => array(
+					'user-1' => array(
+						array(
+							'name'  => 'User ID',
+							'value' => 1,
+						),
+						array(
+							'name'  => 'User Login Name',
+							'value' => 'user_login',
+						),
+						array(
+							'name'  => 'User Nice Name',
+							'value' => 'User Name',
+						),
+						array(
+							'name'  => 'User Email',
+							'value' => 'export-requester@example.com',
+						),
+						array(
+							'name'  => 'User Registration Date',
+							'value' => '2020-01-31 19:29:29',
+						),
+						array(
+							'name'  => 'User Display Name',
+							'value' => 'User Name',
+						),
+						array(
+							'name'  => 'User Nickname',
+							'value' => 'User',
+						),
+					),
+				),
+			),
+			'comments' => array(
+				'group_label'       => 'Comments',
+				'group_description' => 'User&#8217;s comment data.',
+				'items'             => array(
+					'comment-2' => array(
+						array(
+							'name'  => 'Comment Author',
+							'value' => 'User Name',
+						),
+						array(
+							'name'  => 'Comment Author Email',
+							'value' => 'export-requester@example.com',
+						),
+						array(
+							'name'  => 'Comment Author IP',
+							'value' => '::1',
+						),
+						array(
+							'name'  => 'Comment Author User Agent',
+							'value' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36',
+						),
+						array(
+							'name'  => 'Comment Date',
+							'value' => '2020-01-31 19:55:19',
+						),
+						array(
+							'name'  => 'Comment Content',
+							'value' => 'Test',
+						),
+						array(
+							'name'  => 'Comment URL',
+							'value' => '<a href="http://localhost:8888/46894/2020/01/31/hello-world/#comment-2" target="_blank" rel="noreferrer noopener">http://localhost:8888/46894/2020/01/31/hello-world/#comment-2</a>',
+						),
+					),
+				),
+			),
+		);
+		update_post_meta( self::$export_request_id, '_export_data_grouped', $export_data_grouped );
+
+		// Generate Export File
+		wp_privacy_generate_personal_data_export_file( self::$export_request_id );
+		$this->assertTrue( file_exists( $this->export_file_name ) );
+
+		// Cleam-up for subsequent tests
+		update_post_meta( self::$export_request_id, '_export_data_grouped', array() );
+
+		$report_dir = trailingslashit( self::$exports_dir . 'test_contents' );
+		mkdir( $report_dir );
+
+		$zip        = new ZipArchive();
+		$opened_zip = $zip->open( $this->export_file_name );
+		$this->assertTrue( $opened_zip );
+
+		$zip->extractTo( $report_dir );
+		$zip->close();
+		$this->assertTrue( file_exists( $report_dir . 'index.html' ) );
+
+		$report_contents = file_get_contents( $report_dir . 'index.html' );
+		$request         = wp_get_user_request( self::$export_request_id );
+
+		$this->assertContains( '<div id="table_of_contents">', $report_contents );
+		$this->assertNotContains( '<span class="count">', $report_contents );
+		$this->assertContains( $request->email, $report_contents );
+
+	}
 }
Index: /trunk/tests/phpunit/tests/privacy/wpPrivacyGeneratePersonalDataExportGroupHtml.php
===================================================================
--- /trunk/tests/phpunit/tests/privacy/wpPrivacyGeneratePersonalDataExportGroupHtml.php	(revision 47277)
+++ /trunk/tests/phpunit/tests/privacy/wpPrivacyGeneratePersonalDataExportGroupHtml.php	(revision 47278)
@@ -40,8 +40,8 @@
 		);
 
-		$actual                = wp_privacy_generate_personal_data_export_group_html( $data );
+		$actual                = wp_privacy_generate_personal_data_export_group_html( $data, 'test-data-group', 2 );
 		$expected_table_markup = '<table><tbody><tr><th>Field 1 Name</th><td>Field 1 Value</td></tr><tr><th>Field 2 Name</th><td>Field 2 Value</td></tr></tbody></table>';
 
-		$this->assertContains( '<h2>Test Data Group</h2>', $actual );
+		$this->assertContains( '<h2 id="test-data-group-test-data-group">Test Data Group</h2>', $actual );
 		$this->assertContains( $expected_table_markup, $actual );
 	}
@@ -80,7 +80,7 @@
 		);
 
-		$actual = wp_privacy_generate_personal_data_export_group_html( $data );
-
-		$this->assertContains( '<h2>Test Data Group', $actual );
+		$actual = wp_privacy_generate_personal_data_export_group_html( $data, 'test-data-group', 2 );
+
+		$this->assertContains( '<h2 id="test-data-group-test-data-group">Test Data Group', $actual );
 		$this->assertContains( '<td>Field 1 Value', $actual );
 		$this->assertContains( '<td>Another Field 1 Value', $actual );
@@ -118,5 +118,5 @@
 		);
 
-		$actual = wp_privacy_generate_personal_data_export_group_html( $data );
+		$actual = wp_privacy_generate_personal_data_export_group_html( $data, 'test-data-group', 2 );
 
 		$this->assertContains( '<a href="http://wordpress.org">http://wordpress.org</a>', $actual );
@@ -132,11 +132,11 @@
 	public function test_group_labels_escaped() {
 		$data = array(
-			'group_label' => '<div>Escape HTML in group lavels</div>',
+			'group_label' => '<div>Escape HTML in group labels</div>',
 			'items'       => array(),
 		);
 
-		$actual = wp_privacy_generate_personal_data_export_group_html( $data );
-
-		$this->assertContains( '<h2>&lt;div&gt;Escape HTML in group lavels&lt;/div&gt;</h2>', $actual );
+		$actual = wp_privacy_generate_personal_data_export_group_html( $data, 'escape-html-in-group-labels', 2 );
+
+		$this->assertContains( '<h2 id="escape-html-in-group-labels-escape-html-in-group-labels">&lt;div&gt;Escape HTML in group labels&lt;/div&gt;</h2>', $actual );
 	}
 
@@ -163,6 +163,5 @@
 		);
 
-		$actual = wp_privacy_generate_personal_data_export_group_html( $data );
-
+		$actual = wp_privacy_generate_personal_data_export_group_html( $data, 'test-data-group', 2 );
 		$this->assertContains( $data['items'][0]['links']['value'], $actual );
 		$this->assertContains( $data['items'][0]['formatting']['value'], $actual );
@@ -191,5 +190,5 @@
 		);
 
-		$actual = wp_privacy_generate_personal_data_export_group_html( $data );
+		$actual = wp_privacy_generate_personal_data_export_group_html( $data, 'test-data-group', 2 );
 
 		$this->assertNotContains( $data['items'][0]['scripts']['value'], $actual );
@@ -224,7 +223,7 @@
 		);
 
-		$actual = wp_privacy_generate_personal_data_export_group_html( $data );
-
-		$this->assertContains( '<h2>Test Data Group', $actual );
+		$actual = wp_privacy_generate_personal_data_export_group_html( $data, 'test-data-group', 2 );
+
+		$this->assertContains( '<h2 id="test-data-group-test-data-group">Test Data Group', $actual );
 		$this->assertContains( '<span class="count">(2)</span></h2>', $actual );
 		$this->assertSame( 2, substr_count( $actual, '<table>' ) );
@@ -249,7 +248,7 @@
 		);
 
-		$actual = wp_privacy_generate_personal_data_export_group_html( $data );
-
-		$this->assertContains( '<h2>Test Data Group</h2>', $actual );
+		$actual = wp_privacy_generate_personal_data_export_group_html( $data, 'test-data-group', 2 );
+
+		$this->assertContains( '<h2 id="test-data-group-test-data-group">Test Data Group</h2>', $actual );
 		$this->assertNotContains( '<span class="count">', $actual );
 		$this->assertSame( 1, substr_count( $actual, '<table>' ) );
