From ae5c113c241a6926d8df5a893f3ee585561b3a79 Mon Sep 17 00:00:00 2001
From: Marius Dumitru Florea <marius@xwiki.com>
Date: Mon, 20 Feb 2023 00:06:51 +0200
Subject: [PATCH] XWIKI-13977: Generate internal links when exporting multiple
 linked pages to PDF XWIKI-20563: Duplicate headings can lead to broken
 internal links in the generated PDF

(cherry picked from commit 7bd586c075a13b660f167f1eb243e08fd3d5a86b)
---
 xwiki-platform-core/pom.xml                   |  13 +-
 .../browser/AbstractBrowserPDFPrinter.java    |   7 -
 .../xwiki/export/pdf/browser/BrowserTab.java  |   7 -
 .../pdf/internal/job/DocumentRenderer.java    |   5 +-
 .../internal/job/PDFExportIdGenerator.java    |  87 +++++++++++
 .../export/pdf/job/PDFExportJobRequest.java   |  28 ++++
 .../export/pdf/job/PDFExportJobStatus.java    |  37 +++++
 .../pdf/browser/BrowserPDFPrinterTest.java    |   1 -
 .../internal/job/DocumentRendererTest.java    |  12 +-
 .../job/PDFExportIdGeneratorTest.java         |  82 +++++++++++
 .../export/pdf/internal/chrome/ChromeTab.java |  21 ---
 .../DefaultPDFExportJobRequestFactory.java    |  27 +++-
 .../pdf/internal/chrome/ChromeTabTest.java    |  32 +----
 ...DefaultPDFExportJobRequestFactoryTest.java |   5 +
 .../resources/PDFExportIT/Anchors/Child.xml   |  72 ++++++++++
 .../resources/PDFExportIT/Anchors/WebHome.xml |  72 ++++++++++
 .../xwiki/export/pdf/test/ui/PDFExportIT.java | 117 ++++++++++++++-
 .../main/resources/XWiki/PDFExport/Sheet.xml  | 135 ++++++++++++++++--
 18 files changed, 674 insertions(+), 86 deletions(-)
 create mode 100644 xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/internal/job/PDFExportIdGenerator.java
 create mode 100644 xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/test/java/org/xwiki/export/pdf/internal/job/PDFExportIdGeneratorTest.java
 create mode 100644 xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-test/xwiki-platform-export-pdf-test-content/src/main/resources/PDFExportIT/Anchors/Child.xml
 create mode 100644 xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-test/xwiki-platform-export-pdf-test-content/src/main/resources/PDFExportIT/Anchors/WebHome.xml

diff --git a/xwiki-platform-core/pom.xml b/xwiki-platform-core/pom.xml
index d60f6ea5ba0..2b281b85afa 100644
--- a/xwiki-platform-core/pom.xml
+++ b/xwiki-platform-core/pom.xml
@@ -125,8 +125,17 @@
 
                  Single justification example:
             -->
-            
-            
+            <revapi.differences>
+              <justification>Removed unused method from unstable API.</justification>
+              <criticality>documented</criticality>
+              <differences>
+                <item>
+                  <ignore>true</ignore>
+                  <code>java.method.removed</code>
+                  <old>method void org.xwiki.export.pdf.browser.BrowserTab::setBaseURL(java.net.URL) throws java.io.IOException</old>
+                </item>
+              </differences>
+            </revapi.differences>
           </analysisConfiguration>
         </configuration>
       </plugin>
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/browser/AbstractBrowserPDFPrinter.java b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/browser/AbstractBrowserPDFPrinter.java
index c6b7758cc6f..45344940c8c 100644
--- a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/browser/AbstractBrowserPDFPrinter.java
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/browser/AbstractBrowserPDFPrinter.java
@@ -80,13 +80,6 @@ public InputStream print(URL printPreviewURL) throws IOException
                 throw new IOException("Failed to load the print preview URL: " + cookieFilterContext.getTargetURL());
             }
 
-            if (!printPreviewURL.toString().equals(cookieFilterContext.getTargetURL().toString())) {
-                // Make sure the relative URLs are resolved based on the original print preview URL otherwise the user
-                // won't be able to open the links from the generated PDF because they use a host name accessible only
-                // from the browser that generated the PDF. See PDFExportConfiguration#getXWikiHost()
-                browserTab.setBaseURL(printPreviewURL);
-            }
-
             return browserTab.printToPDF(() -> {
                 browserTab.close();
             });
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/browser/BrowserTab.java b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/browser/BrowserTab.java
index 00c2821160e..c972e996ed6 100644
--- a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/browser/BrowserTab.java
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/browser/BrowserTab.java
@@ -94,13 +94,6 @@ default boolean navigate(URL url) throws IOException
      */
     String getSource();
 
-    /**
-     * Sets the base URL for the currently loaded web page.
-     * 
-     * @param baseURL the base URL to set
-     */
-    void setBaseURL(URL baseURL) throws IOException;
-
     /**
      * Print the current web page to PDF.
      * 
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/internal/job/DocumentRenderer.java b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/internal/job/DocumentRenderer.java
index a28ff6903ca..881404eb31e 100644
--- a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/internal/job/DocumentRenderer.java
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/internal/job/DocumentRenderer.java
@@ -41,7 +41,6 @@
 import org.xwiki.rendering.renderer.printer.WikiPrinter;
 import org.xwiki.rendering.syntax.Syntax;
 import org.xwiki.rendering.transformation.RenderingContext;
-import org.xwiki.rendering.util.IdGenerator;
 
 /**
  * Component used to render documents.
@@ -80,7 +79,7 @@ public class DocumentRenderer
     /**
      * Used to generate unique identifiers across multiple rendered documents.
      */
-    private IdGenerator idGenerator = new IdGenerator();
+    private PDFExportIdGenerator idGenerator = new PDFExportIdGenerator();
 
     /**
      * Renders the specified document.
@@ -111,7 +110,7 @@ public DocumentRenderingResult render(DocumentReference documentReference, boole
         DocumentModelBridge document = this.documentAccessBridge.getTranslatedDocumentInstance(documentReference);
         XDOM xdom = display(document, parameters, withTitle);
         String html = renderXDOM(xdom, targetSyntax);
-        return new DocumentRenderingResult(documentReference, xdom, html);
+        return new DocumentRenderingResult(documentReference, xdom, html, this.idGenerator.resetLocalIds());
     }
 
     private XDOM display(DocumentModelBridge document, DocumentDisplayerParameters parameters, boolean withTitle)
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/internal/job/PDFExportIdGenerator.java b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/internal/job/PDFExportIdGenerator.java
new file mode 100644
index 00000000000..bbb8e555595
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/internal/job/PDFExportIdGenerator.java
@@ -0,0 +1,87 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.export.pdf.internal.job;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import org.xwiki.rendering.util.IdGenerator;
+
+/**
+ * The id generator used when rendering wiki pages for PDF export. It collects a map of {@code localId -> globalId} for
+ * each rendered page (see {@link #resetLocalIds()}) that can be used on the client side to refactor external links into
+ * internal links. The local id is the id that is generated when rendering a single page, while the global id is the id
+ * generated when rendering multiple pages (and thus the generated id needs to be unique across all these pages).
+ * 
+ * @version $Id$
+ * @since 14.10.6
+ * @since 15.1RC1
+ */
+public class PDFExportIdGenerator extends IdGenerator
+{
+    private IdGenerator localIdGenerator = new IdGenerator();
+
+    /**
+     * Maps local IDs to global IDs.
+     */
+    private Map<String, String> idMap = new HashMap<>();
+
+    @Override
+    public String generateUniqueId(String prefix, String text)
+    {
+        String globalId = super.generateUniqueId(prefix, text);
+        String localId = this.localIdGenerator.generateUniqueId(prefix, text);
+        this.idMap.put(localId, globalId);
+        return globalId;
+    }
+
+    @Override
+    public void remove(String globalId)
+    {
+        super.remove(globalId);
+        this.idMap.entrySet().stream().filter(entry -> Objects.equals(entry.getValue(), globalId)).findFirst()
+            .ifPresent(entry -> {
+                String localId = entry.getKey();
+                this.localIdGenerator.remove(localId);
+                this.idMap.remove(localId);
+            });
+    }
+
+    @Override
+    public void reset()
+    {
+        super.reset();
+        resetLocalIds();
+    }
+
+    /**
+     * Reset the collected local IDs. Call this before each page rendering.
+     * 
+     * @return the mapping between the local IDs and the global IDs
+     */
+    public Map<String, String> resetLocalIds()
+    {
+        Map<String, String> idMapCopy = new HashMap<>(this.idMap);
+        this.localIdGenerator.reset();
+        this.idMap.clear();
+        return idMapCopy;
+    }
+}
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/job/PDFExportJobRequest.java b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/job/PDFExportJobRequest.java
index a85030950ed..8da4420dea5 100644
--- a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/job/PDFExportJobRequest.java
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/job/PDFExportJobRequest.java
@@ -19,6 +19,7 @@
  */
 package org.xwiki.export.pdf.job;
 
+import java.net.URL;
 import java.util.Collections;
 import java.util.List;
 
@@ -60,6 +61,8 @@ public class PDFExportJobRequest extends AbstractCheckRightsRequest
 
     private static final String PROPERTY_FILE_NAME = "fileName";
 
+    private static final String PROPERTY_BASE_URL = "baseURL";
+
     /**
      * Default constructor.
      */
@@ -254,4 +257,29 @@ public void setFileName(String fileName)
     {
         setProperty(PROPERTY_FILE_NAME, fileName);
     }
+
+    /**
+     * @return the base URL used to resolve relative URLs in the exported content
+     * @since 14.10.6
+     * @since 15.1RC1
+     */
+    public URL getBaseURL()
+    {
+        return getProperty(PROPERTY_BASE_URL);
+    }
+
+    /**
+     * Sets the base URL used to resolve relative URLs in the exported content. When the base URL is not set the
+     * relative URLs are by default resolved relative to the print preview URL which uses the {@code export} action and
+     * has a long query string that is specific to PDF export. This means relative URLs may be resolved using the
+     * {@code export} action and some strange query string if the base URL is not set.
+     * 
+     * @param baseURL the base URL used to resolve URLs in the exported content
+     * @since 14.10.6
+     * @since 15.1RC1
+     */
+    public void setBaseURL(URL baseURL)
+    {
+        setProperty(PROPERTY_BASE_URL, baseURL);
+    }
 }
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/job/PDFExportJobStatus.java b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/job/PDFExportJobStatus.java
index d6d82ca99a8..f5dcd5420c3 100644
--- a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/job/PDFExportJobStatus.java
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/main/java/org/xwiki/export/pdf/job/PDFExportJobStatus.java
@@ -20,8 +20,10 @@
 package org.xwiki.export.pdf.job;
 
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.Map;
 import java.util.UUID;
 
 import org.xwiki.job.DefaultJobStatus;
@@ -58,6 +60,8 @@ public static class DocumentRenderingResult
 
         private final String html;
 
+        private final Map<String, String> idMap;
+
         /**
          * Create a new rendering result for the specified document.
          * 
@@ -66,10 +70,30 @@ public static class DocumentRenderingResult
          * @param html the HTML obtained by rendering the specified document
          */
         public DocumentRenderingResult(DocumentReference documentReference, XDOM xdom, String html)
+        {
+            this(documentReference, xdom, html, Collections.emptyMap());
+        }
+
+        /**
+         * Create a new rendering result for the specified document.
+         * 
+         * @param documentReference the document that has been rendered
+         * @param xdom the XDOM obtained by rendering the specified document
+         * @param html the HTML obtained by rendering the specified document
+         * @param idMap the mapping between local IDs (that would have been generated if the document were rendered
+         *            alone) and global IDs (that were actually generated when the document was rendered together with
+         *            the other documents included in the PDF export); this mapping can be used to convert external
+         *            links into internal links
+         * @since 14.10.6
+         * @since 15.1RC1
+         */
+        public DocumentRenderingResult(DocumentReference documentReference, XDOM xdom, String html,
+            Map<String, String> idMap)
         {
             this.documentReference = documentReference;
             this.xdom = xdom;
             this.html = html;
+            this.idMap = idMap;
         }
 
         /**
@@ -97,6 +121,19 @@ public String getHTML()
         {
             return html;
         }
+
+        /**
+         * @return the mapping between local IDs (that would have been generated if the document were rendered alone)
+         *         and global IDs (that were actually generated when the document was rendered together with the other
+         *         documents included in the PDF export); this mapping can be used to convert external links into
+         *         internal links
+         * @since 14.10.6
+         * @since 15.1RC1
+         */
+        public Map<String, String> getIdMap()
+        {
+            return Collections.unmodifiableMap(this.idMap);
+        }
     }
 
     private final List<DocumentRenderingResult> documentRenderingResults = new LinkedList<>();
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/test/java/org/xwiki/export/pdf/browser/BrowserPDFPrinterTest.java b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/test/java/org/xwiki/export/pdf/browser/BrowserPDFPrinterTest.java
index 9d08975e7b4..321dfdbcd13 100644
--- a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/test/java/org/xwiki/export/pdf/browser/BrowserPDFPrinterTest.java
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/test/java/org/xwiki/export/pdf/browser/BrowserPDFPrinterTest.java
@@ -147,7 +147,6 @@ public InputStream answer(InvocationOnMock invocation) throws Throwable
         assertEquals("172.12.0.3", this.cookieFilterContextCaptor.getValue().getBrowserIPAddress());
         assertEquals(browserPrintPreviewURL, this.cookieFilterContextCaptor.getValue().getTargetURL());
 
-        verify(this.browserTab).setBaseURL(printPreviewURL);
         verify(this.browserTab).close();
     }
 
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/test/java/org/xwiki/export/pdf/internal/job/DocumentRendererTest.java b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/test/java/org/xwiki/export/pdf/internal/job/DocumentRendererTest.java
index 5f2ff7675ac..1aa8f315823 100644
--- a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/test/java/org/xwiki/export/pdf/internal/job/DocumentRendererTest.java
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/test/java/org/xwiki/export/pdf/internal/job/DocumentRendererTest.java
@@ -20,6 +20,9 @@
 package org.xwiki.export.pdf.internal.job;
 
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
 
 import javax.inject.Named;
 
@@ -49,7 +52,6 @@
 import org.xwiki.test.junit5.mockito.MockComponent;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.same;
@@ -115,7 +117,7 @@ void render() throws Exception
                 public XDOM answer(InvocationOnMock invocation) throws Throwable
                 {
                     DocumentDisplayerParameters params = (DocumentDisplayerParameters) invocation.getArgument(1);
-                    assertNotNull(params.getIdGenerator());
+                    params.getIdGenerator().generateUniqueId("H", "heading");
                     return params.isTitleDisplayed() ? titleXDOM : xdom;
                 }
             });
@@ -135,6 +137,7 @@ public Void answer(InvocationOnMock invocation)
         assertEquals(1, xdom.getChildren().size());
         assertSame(xdom, result.getXDOM());
         assertEquals("some content", result.getHTML());
+        assertEquals(Collections.singletonMap("Hheading", "Hheading"), result.getIdMap());
 
         // Now render with title.
         result = this.documentRenderer.render(documentReference, true);
@@ -144,5 +147,10 @@ public Void answer(InvocationOnMock invocation)
         assertEquals("Htest:Some.Page", title.getId());
         assertEquals("test:Some.Page", title.getParameter("data-xwiki-document-reference"));
         assertEquals("title", ((WordBlock) title.getChildren().get(0)).getWord());
+
+        Map<String, String> expectedIdMap = new HashMap<>();
+        expectedIdMap.put("Hheading", "Hheading-1");
+        expectedIdMap.put("Hheading-1", "Hheading-2");
+        assertEquals(expectedIdMap, result.getIdMap());
     }
 }
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/test/java/org/xwiki/export/pdf/internal/job/PDFExportIdGeneratorTest.java b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/test/java/org/xwiki/export/pdf/internal/job/PDFExportIdGeneratorTest.java
new file mode 100644
index 00000000000..d4aa35b83d3
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-api/src/test/java/org/xwiki/export/pdf/internal/job/PDFExportIdGeneratorTest.java
@@ -0,0 +1,82 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.export.pdf.internal.job;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Unit tests for {@link PDFExportIdGenerator}.
+ * 
+ * @version $Id$
+ */
+class PDFExportIdGeneratorTest
+{
+    private PDFExportIdGenerator idGenerator = new PDFExportIdGenerator();
+
+    @Test
+    void removeAndReset()
+    {
+        Map<String, String> expectedIdMap = new HashMap<>();
+
+        this.idGenerator.generateUniqueId("Alice");
+        this.idGenerator.generateUniqueId("Bob");
+
+        expectedIdMap.put("IAlice", "IAlice");
+        expectedIdMap.put("IBob", "IBob");
+        assertEquals(expectedIdMap, this.idGenerator.resetLocalIds());
+
+        this.idGenerator.generateUniqueId("Alice");
+        this.idGenerator.generateUniqueId("Bob");
+        this.idGenerator.generateUniqueId("Carol");
+        this.idGenerator.remove("IBob-1");
+
+        expectedIdMap.clear();
+        expectedIdMap.put("IAlice", "IAlice-1");
+        expectedIdMap.put("ICarol", "ICarol");
+        assertEquals(expectedIdMap, this.idGenerator.resetLocalIds());
+
+        this.idGenerator.remove("IBob");
+        this.idGenerator.generateUniqueId("Alice");
+        this.idGenerator.generateUniqueId("Bob");
+        this.idGenerator.generateUniqueId("Carol");
+
+        expectedIdMap.clear();
+        expectedIdMap.put("IAlice", "IAlice-2");
+        expectedIdMap.put("IBob", "IBob");
+        expectedIdMap.put("ICarol", "ICarol-1");
+        assertEquals(expectedIdMap, this.idGenerator.resetLocalIds());
+
+        this.idGenerator.reset();
+        this.idGenerator.generateUniqueId("Alice");
+        this.idGenerator.generateUniqueId("Bob");
+        this.idGenerator.generateUniqueId("Carol");
+
+        expectedIdMap.clear();
+        expectedIdMap.put("IAlice", "IAlice");
+        expectedIdMap.put("IBob", "IBob");
+        expectedIdMap.put("ICarol", "ICarol");
+        assertEquals(expectedIdMap, this.idGenerator.resetLocalIds());
+    }
+}
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/main/java/org/xwiki/export/pdf/internal/chrome/ChromeTab.java b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/main/java/org/xwiki/export/pdf/internal/chrome/ChromeTab.java
index 3dcc50cf108..d21644339f2 100644
--- a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/main/java/org/xwiki/export/pdf/internal/chrome/ChromeTab.java
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/main/java/org/xwiki/export/pdf/internal/chrome/ChromeTab.java
@@ -36,7 +36,6 @@
 import org.slf4j.LoggerFactory;
 import org.xwiki.export.pdf.browser.BrowserTab;
 
-import com.github.kklisura.cdt.protocol.commands.DOM;
 import com.github.kklisura.cdt.protocol.commands.Network;
 import com.github.kklisura.cdt.protocol.commands.Page;
 import com.github.kklisura.cdt.protocol.commands.Runtime;
@@ -128,26 +127,6 @@ public String getSource()
         return page.getResourceContent(frame.getId(), frame.getUrl()).getContent();
     }
 
-    @Override
-    public void setBaseURL(URL baseURL) throws IOException
-    {
-        LOGGER.debug("Setting base URL [{}].", baseURL);
-        Runtime runtime = this.tabDevToolsService.getRuntime();
-        runtime.enable();
-
-        // Add the BASE tag to the page head. I couldn't find a way to create this node using the DOM domain, so I'm
-        // using JavaScript instead (i.e. the runtime domain).
-        Evaluate evaluate = runtime.evaluate("jQuery('<base/>').prependTo('head').length");
-        checkEvaluation(evaluate, 1, "Failed to insert the BASE tag.", "Unexpected page HTML.");
-
-        DOM dom = this.tabDevToolsService.getDOM();
-        dom.enable();
-
-        // Look for the BASE tag we just added and set its href attribute in order to change the page base URL.
-        Integer baseNodeId = dom.querySelector(dom.getDocument().getNodeId(), "base");
-        dom.setAttributeValue(baseNodeId, "href", baseURL.toString());
-    }
-
     @Override
     public InputStream printToPDF(Runnable cleanup)
     {
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/main/java/org/xwiki/export/pdf/internal/job/DefaultPDFExportJobRequestFactory.java b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/main/java/org/xwiki/export/pdf/internal/job/DefaultPDFExportJobRequestFactory.java
index 2e7cdfff4bb..056a17aa3c8 100644
--- a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/main/java/org/xwiki/export/pdf/internal/job/DefaultPDFExportJobRequestFactory.java
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/main/java/org/xwiki/export/pdf/internal/job/DefaultPDFExportJobRequestFactory.java
@@ -70,6 +70,10 @@ public class DefaultPDFExportJobRequestFactory implements PDFExportJobRequestFac
 {
     private static final String EXPORT = "export";
 
+    private static final String PDF_QUERY_STRING = "pdfQueryString";
+
+    private static final String PDF_HASH = "pdfHash";
+
     @Inject
     private Provider<XWikiContext> xcontextProvider;
 
@@ -134,6 +138,7 @@ private void setContextProperties(PDFExportJobRequest request) throws Exception
         pdfExportContext.put(XWikiContextContextStore.PROP_REQUEST_URL, printPreviewURL);
 
         request.setContext(pdfExportContext);
+        request.setBaseURL(getBaseURL());
     }
 
     private void readPDFExportOptionsFromHTTPRequest(PDFExportJobRequest request)
@@ -178,13 +183,13 @@ private URL getPrintPreviewURL(List<String> jobId)
         XWikiContext xcontext = this.xcontextProvider.get();
         XWikiRequest httpRequest = xcontext.getRequest();
 
-        String originalQueryString = Objects.toString(httpRequest.get("pdfQueryString"), "");
+        String originalQueryString = Objects.toString(httpRequest.get(PDF_QUERY_STRING), "");
         String queryString = getPrintPreviewQueryString(jobId, originalQueryString);
 
         // The request URL hash (fragment identifier) is not sent to the server but in the case of server-side PDF
         // export we need it as it can influence the behavior of the JavaScript code when the PDF template is loaded in
         // the headless web browser. In order to overcome this we receive the hash as a request parameter.
-        String hash = Objects.toString(httpRequest.get("pdfHash"), "");
+        String hash = Objects.toString(httpRequest.get(PDF_HASH), "");
 
         // We want the documents to be rendered with the same parameters (query string) and hash (anchor or fragment
         // identifier) as in print preview mode (what is used to generate the PDF in the end). For this the saved
@@ -209,4 +214,22 @@ private String getPrintPreviewQueryString(List<String> jobId, String originalQue
         );
         return URLEncodedUtils.format(printPreviewParams, StandardCharsets.UTF_8) + '&' + originalQueryString;
     }
+
+    private URL getBaseURL()
+    {
+        XWikiContext xcontext = this.xcontextProvider.get();
+        XWikiRequest httpRequest = xcontext.getRequest();
+
+        // Get the query string and hash that were used when the user triggered the PDF export.
+        String queryString = Objects.toString(httpRequest.get(PDF_QUERY_STRING), "");
+        String hash = Objects.toString(httpRequest.get(PDF_HASH), "");
+
+        // We want the base URL to be the URL from where the user triggered the PDF export.
+        DocumentReference documentReference = xcontext.getDoc().getDocumentReference();
+        return xcontext.getURLFactory().createExternalURL(
+            this.localStringEntityReferenceSerializer.serialize(documentReference.getLastSpaceReference()),
+            // We assume the action was view, since most of the time the PDF export is triggered from view mode.
+            documentReference.getName(), "view", queryString, hash, documentReference.getWikiReference().getName(),
+            xcontext);
+    }
 }
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/test/java/org/xwiki/export/pdf/internal/chrome/ChromeTabTest.java b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/test/java/org/xwiki/export/pdf/internal/chrome/ChromeTabTest.java
index 1cfd9e94e76..19f8d7c7468 100644
--- a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/test/java/org/xwiki/export/pdf/internal/chrome/ChromeTabTest.java
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/test/java/org/xwiki/export/pdf/internal/chrome/ChromeTabTest.java
@@ -35,13 +35,11 @@
 import org.mockito.Mock;
 import org.xwiki.test.junit5.mockito.ComponentTest;
 
-import com.github.kklisura.cdt.protocol.commands.DOM;
 import com.github.kklisura.cdt.protocol.commands.IO;
 import com.github.kklisura.cdt.protocol.commands.Network;
 import com.github.kklisura.cdt.protocol.commands.Page;
 import com.github.kklisura.cdt.protocol.commands.Runtime;
 import com.github.kklisura.cdt.protocol.commands.Target;
-import com.github.kklisura.cdt.protocol.types.dom.Node;
 import com.github.kklisura.cdt.protocol.types.io.Read;
 import com.github.kklisura.cdt.protocol.types.network.CookieParam;
 import com.github.kklisura.cdt.protocol.types.page.Frame;
@@ -193,7 +191,7 @@ void navigateWithWait() throws Exception
 
         verify(this.runtime).enable();
     }
-    
+
     @Test
     void navigateWithWaitAndException() throws Exception
     {
@@ -245,34 +243,6 @@ void getSource()
         assertEquals("source", this.chromeTab.getSource());
     }
 
-    @Test
-    void setBaseURL() throws Exception
-    {
-        URL url = new URL("http://www.xwiki.org");
-
-        Evaluate evaluate = mock(Evaluate.class);
-        when(this.runtime.evaluate("jQuery('<base/>').prependTo('head').length")).thenReturn(evaluate);
-
-        RemoteObject result = mock(RemoteObject.class);
-        when(evaluate.getResult()).thenReturn(result);
-        when(result.getValue()).thenReturn(1);
-
-        DOM dom = mock(DOM.class);
-        when(this.tabDevToolsService.getDOM()).thenReturn(dom);
-
-        Node document = mock(Node.class);
-        when(dom.getDocument()).thenReturn(document);
-        when(document.getNodeId()).thenReturn(12345);
-
-        when(dom.querySelector(12345, "base")).thenReturn(6789);
-
-        this.chromeTab.setBaseURL(url);
-
-        verify(this.runtime).enable();
-        verify(dom).enable();
-        verify(dom).setAttributeValue(6789, "href", "http://www.xwiki.org");
-    }
-
     @Test
     void printToPDF() throws Exception
     {
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/test/java/org/xwiki/export/pdf/internal/job/DefaultPDFExportJobRequestFactoryTest.java b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/test/java/org/xwiki/export/pdf/internal/job/DefaultPDFExportJobRequestFactoryTest.java
index d7c679bd830..0b172ec4513 100644
--- a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/test/java/org/xwiki/export/pdf/internal/job/DefaultPDFExportJobRequestFactoryTest.java
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-default/src/test/java/org/xwiki/export/pdf/internal/job/DefaultPDFExportJobRequestFactoryTest.java
@@ -146,6 +146,10 @@ void createRequest() throws Exception
         when(this.xcontext.getURLFactory().createExternalURL("Some", "Page", "export", queryString, "foo", "test",
             this.xcontext)).thenReturn(printPreviewURL);
 
+        URL baseURL = new URL("http://localhost:8080/xwiki/bin/view/Some/Page?color=red#foo");
+        when(this.xcontext.getURLFactory().createExternalURL("Some", "Page", "view", "color=red", "foo", "test",
+            this.xcontext)).thenReturn(baseURL);
+
         List<String> supportedContextEntries = Arrays.asList("one", "two");
         when(this.contextStoreManager.getSupportedEntries()).thenReturn(supportedContextEntries);
 
@@ -177,6 +181,7 @@ void createRequest() throws Exception
         assertEquals(Collections.singleton("key"), requestParameters.keySet());
         assertEquals(Collections.singletonList("value"), Arrays.asList(requestParameters.get("key")));
         assertEquals(printPreviewURL, request.getContext().get("request.url"));
+        assertEquals(baseURL, request.getBaseURL());
 
         assertEquals(selectedDocuments, request.getDocuments());
         assertEquals(templateReference, request.getTemplate());
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-test/xwiki-platform-export-pdf-test-content/src/main/resources/PDFExportIT/Anchors/Child.xml b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-test/xwiki-platform-export-pdf-test-content/src/main/resources/PDFExportIT/Anchors/Child.xml
new file mode 100644
index 00000000000..32ce0dee680
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-test/xwiki-platform-export-pdf-test-content/src/main/resources/PDFExportIT/Anchors/Child.xml
@@ -0,0 +1,72 @@
+<?xml version="1.1" encoding="UTF-8"?>
+
+<!--
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+-->
+
+<xwikidoc version="1.5" reference="PDFExportIT.Anchors.Child" locale="">
+  <web>PDFExportIT.Anchors</web>
+  <name>Child</name>
+  <language/>
+  <defaultLanguage>en</defaultLanguage>
+  <translation>0</translation>
+  <creator>xwiki:XWiki.Admin</creator>
+  <parent>PDFExportIT.Anchors.WebHome</parent>
+  <author>xwiki:XWiki.Admin</author>
+  <contentAuthor>xwiki:XWiki.Admin</contentAuthor>
+  <version>1.1</version>
+  <title>Child</title>
+  <comment/>
+  <minorEdit>false</minorEdit>
+  <syntaxId>xwiki/2.1</syntaxId>
+  <hidden>false</hidden>
+  <content>== Description ==
+
+* [[Self&gt;&gt;]]
+* [[Usage&gt;&gt;||anchor="HUsage"]]
+* [[Comments&gt;&gt;||anchor="Comments"]]
+* [[History&gt;&gt;||queryString="viewer=history"]]
+* [[Diff&gt;&gt;||queryString="viewer=changes" anchor="diff"]]
+
+{{velocity}}
+* [[Edit&gt;&gt;path:$doc.getURL('edit')]]
+* [[Edit Description&gt;&gt;path:$doc.getURL('edit')#HDescription]]
+* [[Edit Wiki&gt;&gt;path:$doc.getURL('edit', 'editor=wiki')]]
+* [[Edit Usage Wiki&gt;&gt;path:$doc.getURL('edit', 'editor=wiki')#HUsage]]
+{{/velocity}}
+
+* [[Parent&gt;&gt;.WebHome]]
+* [[Parent Usage&gt;&gt;.WebHome||anchor="HUsage"]]
+* [[Parent Comments&gt;&gt;.WebHome||anchor="Comments"]]
+* [[Parent History&gt;&gt;.WebHome||queryString="viewer=history"]]
+* [[Parent Diff&gt;&gt;.WebHome||queryString="viewer=changes" anchor="diff"]]
+
+{{velocity}}
+* [[Parent Edit&gt;&gt;path:$xwiki.getURL('.WebHome', 'edit')]]
+* [[Parent Edit Description&gt;&gt;path:$xwiki.getURL('.WebHome', 'edit')#HDescription]]
+* [[Parent Edit Wiki&gt;&gt;path:$xwiki.getURL('.WebHome', 'edit', 'editor=wiki')]]
+* [[Parent Edit Usage Wiki&gt;&gt;path:$xwiki.getURL('.WebHome', 'edit', 'editor=wiki')#HUsage]]
+{{/velocity}}
+
+== Usage ==
+
+* [[Description&gt;&gt;||anchor="HDescription"]]
+* [[Parent Description&gt;&gt;.WebHome||anchor="HDescription"]]
+* [[Other Description&gt;&gt;PDFExportIT.Parent.WebHome||anchor="HDescription"]]</content>
+</xwikidoc>
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-test/xwiki-platform-export-pdf-test-content/src/main/resources/PDFExportIT/Anchors/WebHome.xml b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-test/xwiki-platform-export-pdf-test-content/src/main/resources/PDFExportIT/Anchors/WebHome.xml
new file mode 100644
index 00000000000..8fddaa804ec
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-test/xwiki-platform-export-pdf-test-content/src/main/resources/PDFExportIT/Anchors/WebHome.xml
@@ -0,0 +1,72 @@
+<?xml version="1.1" encoding="UTF-8"?>
+
+<!--
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+-->
+
+<xwikidoc version="1.5" reference="PDFExportIT.Anchors.WebHome" locale="">
+  <web>PDFExportIT.Anchors</web>
+  <name>WebHome</name>
+  <language/>
+  <defaultLanguage>en</defaultLanguage>
+  <translation>0</translation>
+  <creator>xwiki:XWiki.Admin</creator>
+  <parent>Main.WebHome</parent>
+  <author>xwiki:XWiki.Admin</author>
+  <contentAuthor>xwiki:XWiki.Admin</contentAuthor>
+  <version>1.1</version>
+  <title/>
+  <comment/>
+  <minorEdit>false</minorEdit>
+  <syntaxId>xwiki/2.1</syntaxId>
+  <hidden>false</hidden>
+  <content>== Description ==
+
+* [[Self&gt;&gt;]]
+* [[Usage&gt;&gt;||anchor="HUsage"]]
+* [[Comments&gt;&gt;||anchor="Comments"]]
+* [[History&gt;&gt;||queryString="viewer=history"]]
+* [[Diff&gt;&gt;||queryString="viewer=changes" anchor="diff"]]
+
+{{velocity}}
+* [[Edit&gt;&gt;path:$doc.getURL('edit')]]
+* [[Edit Description&gt;&gt;path:$doc.getURL('edit')#HDescription]]
+* [[Edit Wiki&gt;&gt;path:$doc.getURL('edit', 'editor=wiki')]]
+* [[Edit Usage Wiki&gt;&gt;path:$doc.getURL('edit', 'editor=wiki')#HUsage]]
+{{/velocity}}
+
+* [[Child&gt;&gt;.Child]]
+* [[Child Usage&gt;&gt;.Child||anchor="HUsage"]]
+* [[Child Comments&gt;&gt;.Child||anchor="Comments"]]
+* [[Child History&gt;&gt;.Child||queryString="viewer=history"]]
+* [[Child Diff&gt;&gt;.Child||queryString="viewer=changes" anchor="diff"]]
+
+{{velocity}}
+* [[Child Edit&gt;&gt;path:$xwiki.getURL('.Child', 'edit')]]
+* [[Child Edit Description&gt;&gt;path:$xwiki.getURL('.Child', 'edit')#HDescription]]
+* [[Child Edit Wiki&gt;&gt;path:$xwiki.getURL('.Child', 'edit', 'editor=wiki')]]
+* [[Child Edit Usage Wiki&gt;&gt;path:$xwiki.getURL('.Child', 'edit', 'editor=wiki')#HUsage]]
+{{/velocity}}
+
+== Usage ==
+
+* [[Description&gt;&gt;||anchor="HDescription"]]
+* [[Child Description&gt;&gt;.Child||anchor="HDescription"]]
+* [[Other Description&gt;&gt;PDFExportIT.Parent.WebHome||anchor="HDescription"]]</content>
+</xwikidoc>
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-test/xwiki-platform-export-pdf-test-docker/src/test/it/org/xwiki/export/pdf/test/ui/PDFExportIT.java b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-test/xwiki-platform-export-pdf-test-docker/src/test/it/org/xwiki/export/pdf/test/ui/PDFExportIT.java
index 7b98e157aef..f290d0c351c 100644
--- a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-test/xwiki-platform-export-pdf-test-docker/src/test/it/org/xwiki/export/pdf/test/ui/PDFExportIT.java
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-test/xwiki-platform-export-pdf-test-docker/src/test/it/org/xwiki/export/pdf/test/ui/PDFExportIT.java
@@ -23,6 +23,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Date;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
@@ -178,8 +179,7 @@ void exportAsPDF(TestUtils setup, TestConfiguration testConfiguration) throws Ex
             // The content of the parent document has a link to the child document.
             Map<String, String> contentPageLinks = pdf.getLinksFromPage(2);
             assertEquals(1, contentPageLinks.size());
-            assertEquals(setup.getURL(Arrays.asList("PDFExportIT", "Parent"), "Child") + "/",
-                contentPageLinks.get("child page."));
+            assertEquals("Hxwiki:PDFExportIT.Parent.Child.WebHome", contentPageLinks.get("child page."));
 
             //
             // Verify the page corresponding to the child document.
@@ -431,6 +431,119 @@ void invalidTOCAnchors(TestUtils setup, TestConfiguration testConfiguration) thr
         }
     }
 
+    @Test
+    @Order(8)
+    void refactorAnchors(TestUtils setup, TestConfiguration testConfiguration) throws Exception
+    {
+        setup.login("John", "pass");
+
+        ViewPage viewPage =
+            setup.gotoPage(new LocalDocumentReference(Arrays.asList("PDFExportIT", "Anchors"), "WebHome"));
+        ExportTreeModal exportTreeModal = ExportTreeModal.open(viewPage, "PDF");
+        // Include the child page in the export.
+        exportTreeModal.getPageTree().getNode("document:xwiki:PDFExportIT.Anchors.Child").select();
+        exportTreeModal.export();
+        PDFExportOptionsModal exportOptions = new PDFExportOptionsModal();
+
+        try (PDFDocument pdf = export(exportOptions, testConfiguration)) {
+            //
+            // Verify the anchors from the parent document.
+            //
+
+            Map<String, String> expectedLinks = new LinkedHashMap<>();
+            expectedLinks.put("Self", "Hxwiki:PDFExportIT.Anchors.WebHome");
+            // Anchor to a section from this document.
+            expectedLinks.put("Usage", "HUsage");
+            // Anchor to a section from this document that is not found in the PDF.
+            // expectedLinks.put("Comments", setup.getBaseBinURL() + "view/PDFExportIT/Anchors/#Comments");
+            // Anchors that use a query string cannot be made internal.
+            expectedLinks.put("History", setup.getBaseBinURL() + "view/PDFExportIT/Anchors/?viewer=history#");
+            expectedLinks.put("Diff", setup.getBaseBinURL() + "view/PDFExportIT/Anchors/?viewer=changes#diff");
+            // Anchors that don't target the view action cannot be made internal.
+            expectedLinks.put("Edit", setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/WebHome");
+            expectedLinks.put("Edit Description",
+                setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/WebHome#HDescription");
+            expectedLinks.put("Edit Wiki", setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/WebHome?editor=wiki");
+            expectedLinks.put("Edit Usage Wiki",
+                setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/WebHome?editor=wiki#HUsage");
+
+            // Anchor to the child document that is included in the PDF.
+            expectedLinks.put("Child", "Hxwiki:PDFExportIT.Anchors.Child");
+            // Anchor to a section from the child document that is included in the PDF.
+            expectedLinks.put("Child Usage", "HUsage-1");
+            // Anchor to a section from the child document that is not found in the PDF.
+            expectedLinks.put("Child Comments", setup.getBaseBinURL() + "view/PDFExportIT/Anchors/Child#Comments");
+            // Anchors that use a query string cannot be made internal.
+            expectedLinks.put("Child History", setup.getBaseBinURL() + "view/PDFExportIT/Anchors/Child?viewer=history");
+            expectedLinks.put("Child Diff",
+                setup.getBaseBinURL() + "view/PDFExportIT/Anchors/Child?viewer=changes#diff");
+            // Anchors that don't target the view action cannot be made internal.
+            expectedLinks.put("Child Edit", setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/Child");
+            expectedLinks.put("Child Edit Description",
+                setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/Child#HDescription");
+            expectedLinks.put("Child Edit Wiki", setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/Child?editor=wiki");
+            expectedLinks.put("Child Edit Usage Wiki",
+                setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/Child?editor=wiki#HUsage");
+
+            // Anchor to another section from this document.
+            expectedLinks.put("Description", "HDescription");
+            // Anchor to another section from the child document that is included in the PDF.
+            expectedLinks.put("Child Description", "HDescription-1");
+            // Anchor to a section from a document that is not included in the PDF.
+            expectedLinks.put("Other Description", setup.getBaseBinURL() + "view/PDFExportIT/Parent/#HDescription");
+
+            assertEquals(expectedLinks, pdf.getLinksFromPage(2));
+
+            //
+            // Verify the anchors from the child document.
+            //
+
+            expectedLinks.clear();
+            expectedLinks.put("Self", "Hxwiki:PDFExportIT.Anchors.Child");
+            // Anchor to a section from this document.
+            expectedLinks.put("Usage", "HUsage-1");
+            // Anchor to a section from this document that is not found in the PDF.
+            expectedLinks.put("Comments", setup.getBaseBinURL() + "view/PDFExportIT/Anchors/Child#Comments");
+            // Anchors that use a query string cannot be made internal.
+            expectedLinks.put("History", setup.getBaseBinURL() + "view/PDFExportIT/Anchors/Child?viewer=history#");
+            expectedLinks.put("Diff", setup.getBaseBinURL() + "view/PDFExportIT/Anchors/Child?viewer=changes#diff");
+            // Anchors that don't target the view action cannot be made internal.
+            expectedLinks.put("Edit", setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/Child");
+            expectedLinks.put("Edit Description",
+                setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/Child#HDescription");
+            expectedLinks.put("Edit Wiki", setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/Child?editor=wiki");
+            expectedLinks.put("Edit Usage Wiki",
+                setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/Child?editor=wiki#HUsage");
+
+            // Anchor to the parent document that is included in the PDF.
+            expectedLinks.put("Parent", "Hxwiki:PDFExportIT.Anchors.WebHome");
+            // Anchor to a section from the parent document that is included in the PDF.
+            expectedLinks.put("Parent Usage", "HUsage");
+            // Anchor to a section from the parent document that is not found in the PDF.
+            // expectedLinks.put("Parent Comments", setup.getBaseBinURL() + "view/PDFExportIT/Anchors/#Comments");
+            // Anchors that use a query string cannot be made internal.
+            expectedLinks.put("Parent History", setup.getBaseBinURL() + "view/PDFExportIT/Anchors/?viewer=history");
+            expectedLinks.put("Parent Diff", setup.getBaseBinURL() + "view/PDFExportIT/Anchors/?viewer=changes#diff");
+            // Anchors that don't target the view action cannot be made internal.
+            expectedLinks.put("Parent Edit", setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/WebHome");
+            expectedLinks.put("Parent Edit Description",
+                setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/WebHome#HDescription");
+            expectedLinks.put("Parent Edit Wiki",
+                setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/WebHome?editor=wiki");
+            expectedLinks.put("Parent Edit Usage Wiki",
+                setup.getBaseBinURL() + "edit/PDFExportIT/Anchors/WebHome?editor=wiki#HUsage");
+
+            // Anchor to another section from this document.
+            expectedLinks.put("Description", "HDescription-1");
+            // Anchor to another section from the parent document that is included in the PDF.
+            expectedLinks.put("Parent Description", "HDescription");
+            // Anchor to a section from a document that is not included in the PDF.
+            expectedLinks.put("Other Description", setup.getBaseBinURL() + "view/PDFExportIT/Parent/#HDescription");
+
+            assertEquals(expectedLinks, pdf.getLinksFromPage(3));
+        }
+    }
+
     private URL getHostURL(TestConfiguration testConfiguration) throws Exception
     {
         return new URL(String.format("http://%s:%d", testConfiguration.getServletEngine().getIP(),
diff --git a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-ui/src/main/resources/XWiki/PDFExport/Sheet.xml b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-ui/src/main/resources/XWiki/PDFExport/Sheet.xml
index 7610f533bb6..bdabd95db92 100644
--- a/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-ui/src/main/resources/XWiki/PDFExport/Sheet.xml
+++ b/xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-ui/src/main/resources/XWiki/PDFExport/Sheet.xml
@@ -83,6 +83,7 @@
   ## Inject the required skin extensions.
   #if ($pdfExportJobId)
     $!services.job.getJobStatus($pdfExportJobId).requiredSkinExtensions
+    #clientSidePDFExportConfiguration()
   #end
   ## Start the BODY tag.
   $htmlHeader.substring($headTagEnd, $bodyContentStart)
@@ -104,6 +105,21 @@
   &lt;/html&gt;
 #end
 
+#macro (clientSidePDFExportConfiguration)
+  #set ($clientSideConfig = {
+    'documents': [],
+    'baseURL': $services.job.getJobStatus($pdfExportJobId).request.baseURL
+  })
+  #foreach ($renderingResult in $services.job.getJobStatus($pdfExportJobId).documentRenderingResults)
+    #set ($discard = $clientSideConfig.documents.add({
+      'reference': $services.model.serialize($renderingResult.documentReference, 'default'),
+      'idMap': $renderingResult.idMap
+    }))
+  #end
+  &lt;script id="pdfExportConfig" type="application/json"&gt;$jsontool.serialize($clientSideConfig).replace(
+    '&lt;', '\u003C')&lt;/script&gt;
+#end
+
 #macro (renderPDFElement $pdfTemplateObj $element)
   #unwrapXPropertyDisplay($tdoc.display($element, $pdfTemplateObj))
 #end
@@ -227,15 +243,94 @@
       <cache>long</cache>
     </property>
     <property>
-      <code>require(['jquery', 'xwiki-page-ready'], function($, pageReady) {
-  const fixInternalAnchors = function(container) {
-    var baseURL = new URL(XWiki.currentDocument.getURL(), window.location.href).toString();
+      <code>define('xwiki-export-pdf-config', ['jquery'], function($) {
+  const pdfExportConfig = {
+    documents: [],
+    baseURL: window.location.href
+  };
+  try {
+    Object.assign(pdfExportConfig, JSON.parse($('#pdfExportConfig').text()));
+  } catch (e) {
+    console.error(e);
+  }
+  return pdfExportConfig;
+});
+
+require([
+  'jquery',
+  'xwiki-page-ready',
+  'xwiki-export-pdf-config'
+], function($, pageReady, pdfExportConfig) {
+  const refactorAnchors = function() {
+    // Fix anchors that use relative URLs to point to the right document (that generated that anchor).
+    fixRelativeAnchors($('#xwikicontent'));
+    // Convert external anchors that target exported documents into internal anchor, whenever possible.
+    pdfExportConfig.documents?.forEach?.(document =&gt; {
+      const documentReference = XWiki.Model.resolve(document.reference, XWiki.EntityType.DOCUMENT,
+        XWiki.currentDocument.documentReference);
+      makeInternalAnchors($('#xwikicontent'), documentReference, document.idMap);
+    });
+    // The anchors in the PDF table of contents are already using global fragment identifiers.
+    makeInternalAnchors($('.pdf-toc'), XWiki.currentDocument.documentReference);
+  };
+
+  /**
+   * When exporting multiple documents to PDF each of them can produce anchors with relative URLs that are by default
+   * resolved based on the current document used to trigger the PDF export, which is not what we want. Those relative
+   * anchors should target the documents that generated them.
+   *
+   * @param container where to look for relative anchors that need to be fixed
+   */
+  const fixRelativeAnchors = function(container) {
+    container.find('a[href]').each(function() {
+      const anchor = $(this);
+      let documentReference = anchor.parentsUntil(container).last().prevAll('h1[data-xwiki-document-reference]')
+        .first().attr('data-xwiki-document-reference');
+      if (documentReference) {
+        documentReference = XWiki.Model.resolve(documentReference, XWiki.EntityType.DOCUMENT,
+          XWiki.currentDocument.documentReference);
+        const document = new XWiki.Document(documentReference);
+        const documentURL = new URL(document.getURL(), pdfExportConfig.baseURL);
+        try {
+          anchor.attr('href', new URL(anchor.attr('href'), documentURL).href);
+        } catch (e) {
+          console.log('Failed to fix relative URL: ' + anchor.attr('href'));
+        }
+      }
+    });
+  };
+
+  /**
+   * Look for external anchors that target the specified document (which is included in the PDF export) and convert them
+   * to internal anchors like this:
+   * &lt;ul&gt;
+   *   &lt;li&gt;/xwiki/bin/view/Path/To/Document =&gt; #documentTitleHeadingId&lt;/li&gt;
+   *   &lt;li&gt;/xwiki/bin/view/Path/To/Document#localFragmentId =&gt; #globalFragmentId&lt;/li&gt;
+   *   &lt;li&gt;/xwiki/bin/view/Path/To/Document#globalFragmentId =&gt; #globalFragmentId&lt;/li&gt;
+   * &lt;/ul&gt;
+   */
+  const makeInternalAnchors = function(container, documentReference, idMap) {
+    const document = new XWiki.Document(documentReference);
+    const documentURL = new URL(document.getURL(), pdfExportConfig.baseURL).href;
     container.find('a[href]').each(function() {
       const anchor = $(this);
       try {
-        const url = new URL(anchor.prop('href'));
-        if (url.hash &amp;&amp; baseURL + url.hash === url.toString()) {
-          anchor.attr('href', url.hash);
+        const anchorURL = new URL(anchor.attr('href'), pdfExportConfig.baseURL);
+        // Drop the '#' from the end of the URL when the hash is empty.
+        anchorURL.hash = anchorURL.hash;
+        if (anchorURL.href === documentURL + anchorURL.hash) {
+          // Assume the anchor targets a document fragment specified by its global id.
+          let fragmentId = anchorURL.hash.substring(1);
+          if (!fragmentId) {
+            // The anchor targets the start of the document.
+            fragmentId = getDocumentFragmentId(documentReference);
+          } else if (idMap) {
+            // The anchor targets a document fragment specified by its local id.
+            fragmentId = idMap[fragmentId];
+          }
+          if (fragmentId) {
+            anchor.attr('href', '#' + fragmentId);
+          }
         }
       } catch (e) {
         console.log('Failed to parse URL: ' + anchor.prop('href'));
@@ -243,6 +338,19 @@
     });
   };
 
+  /**
+   * Search for the heading that marks the start of the specified document in the rendering output and return its id,
+   * which can be used as a fragment identifier in internal links.
+   *
+   * @return the id of the heading that marks the start of the specified document
+   */
+  const getDocumentFragmentId = function(documentReference) {
+    const stringDocRef = XWiki.Model.serialize(documentReference);
+    return $('#xwikicontent &gt; h1[data-xwiki-document-reference]').filter(function() {
+      return $(this).attr('data-xwiki-document-reference') === stringDocRef;
+    }).attr('id');
+  };
+
   const replaceCanvasesWithImages = function(container) {
     return $.makeArray(container.find('canvas')).reduce((replacePreviousCanvas, canvas) =&gt; {
       return replacePreviousCanvas.then(replaceCanvasWithImage.bind(null, canvas)).catch((e) =&gt; {
@@ -331,7 +439,7 @@
 
   // Adjust the exported content before performing the print layout.
   pageReady.afterPageReady(() =&gt; {
-    fixInternalAnchors($('#xwikicontent, .pdf-toc'));
+    refactorAnchors();
     removeEmptyTableOfContents();
     validateTableOfContentsAnchors();
   });
@@ -357,9 +465,20 @@
     }
   });
 
-  // Revoke the object URLs previously created, after the print layout is ready.
   pageReady.afterPageReady(() =&gt; {
+    // Revoke the object URLs previously created, after the print layout is ready.
     objectURLs.forEach(url =&gt; URL.revokeObjectURL(url));
+
+    // Sets the base URL used to resolve relative URLs in the exported content. When the base URL is not set the
+    // relative URLs are by default resolved relative to the print preview URL which uses the export action and has a
+    // long query string that is specific to PDF export. This means relative URLs may be resolved using the export
+    // action and some strange query string if the base URL is not set. We set the base URL after the page is ready
+    // because we don't want to influence resource loading (images, CSS, JavaScript). This is especially important when
+    // the PDF is printed using a remote web browser (e.g. running inside a Docker container) in which case the URL used
+    // to access the print preview page can be different than the URL used by the user to trigger the PDF export (i.e.
+    // the base URL). We want the base URL to be taken into account only when resolving relative links in the generated
+    // PDF.
+    $('&lt;base/&gt;').attr('href', pdfExportConfig.baseURL).prependTo('head');
   });
 });</code>
     </property>
-- 
GitLab