Repository: deepset-ai/haystack

Issue:
Add `to_dict` and `from_dict` methods in `HTMLToDocument`
`HTMLToDocument` Component is missing serialization methods.

We need to add them so it can be properly serialized.

Discussion:

Patch:
diff --git a/VERSION.txt b/VERSION.txt
index 20c4e260..ec7f186d 100644
--- a/VERSION.txt
+++ b/VERSION.txt
@@ -1,1 +1,1 @@
-2.0.0-beta.4
+2.0.0-beta.5
diff --git a/haystack/components/converters/html.py b/haystack/components/converters/html.py
index 0586065c..dea38dbd 100644
--- a/haystack/components/converters/html.py
+++ b/haystack/components/converters/html.py
@@ -3,7 +3,7 @@ from pathlib import Path
 from typing import Any, Dict, List, Optional, Union, Literal
 from boilerpy3 import extractors
 
-from haystack import Document, component
+from haystack import Document, component, default_from_dict, default_to_dict
 from haystack.dataclasses import ByteStream
 from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
 
@@ -49,6 +49,13 @@ class HTMLToDocument:
         """
         self.extractor_type = extractor_type
 
+    def to_dict(self) -> Dict[str, Any]:
+        return default_to_dict(self, extractor_type=self.extractor_type)
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "HTMLToDocument":
+        return default_from_dict(cls, data)
+
     @component.output_types(documents=List[Document])
     def run(
         self,
diff --git a/haystack/components/converters/pypdf.py b/haystack/components/converters/pypdf.py
index 021029e0..0f0adff9 100644
--- a/haystack/components/converters/pypdf.py
+++ b/haystack/components/converters/pypdf.py
@@ -31,7 +31,7 @@ class DefaultConverter:
 
     def convert(self, reader: "PdfReader") -> Document:
         """Extract text from the PDF and return a Document object with the text content."""
-        text = "".join(page.extract_text() for page in reader.pages if page.extract_text())
+        text = "\f".join(page.extract_text() for page in reader.pages)
         return Document(content=text)
 
 
diff --git a/haystack/components/converters/utils.py b/haystack/components/converters/utils.py
index 908a0a0b..128e4a13 100644
--- a/haystack/components/converters/utils.py
+++ b/haystack/components/converters/utils.py
@@ -28,7 +28,7 @@ def normalize_metadata(
     makes sure to return a list of dictionaries of the correct length for the converter to use.
 
     :param meta: the meta input of the converter, as-is
-    :sources_count: the number of sources the converter received
+    :param sources_count: the number of sources the converter received
     :returns: a list of dictionaries of the make length as the sources list
     """
     if meta is None:
diff --git a/haystack/components/generators/hugging_face_local.py b/haystack/components/generators/hugging_face_local.py
index 1d249f84..0a734eae 100644
--- a/haystack/components/generators/hugging_face_local.py
+++ b/haystack/components/generators/hugging_face_local.py
@@ -5,13 +5,13 @@ from haystack import component, default_from_dict, default_to_dict
 
 from haystack.lazy_imports import LazyImport
 from haystack.utils import ComponentDevice
+from haystack.utils.hf import deserialize_hf_model_kwargs, serialize_hf_model_kwargs
 
 logger = logging.getLogger(__name__)
 
 SUPPORTED_TASKS = ["text-generation", "text2text-generation"]
 
-with LazyImport(message="Run 'pip install transformers[torch]'") as torch_and_transformers_import:
-    import torch
+with LazyImport(message="Run 'pip install transformers[torch]'") as transformers_import:
     from huggingface_hub import model_info
     from transformers import StoppingCriteriaList, pipeline
     from haystack.components.generators.hf_utils import StopWordsCriteria  # pylint: disable=ungrouped-imports
@@ -84,7 +84,7 @@ class HuggingFaceLocalGenerator:
             For some chat models, the output includes both the new text and the original prompt.
             In these cases, it's important to make sure your prompt has no stop words.
         """
-        torch_and_transformers_import.check()
+        transformers_import.check()
 
         huggingface_pipeline_kwargs = huggingface_pipeline_kwargs or {}
         generation_kwargs = generation_kwargs or {}
@@ -162,24 +162,8 @@ class HuggingFaceLocalGenerator:
         # we don't want to serialize valid tokens
         if isinstance(huggingface_pipeline_kwargs["token"], str):
             serialization_dict["init_parameters"]["huggingface_pipeline_kwargs"].pop("token")
-        # convert torch.dtype to string for serialization
-        # 1. torch_dtype can be specified in huggingface_pipeline_kwargs
-        torch_dtype = huggingface_pipeline_kwargs.get("torch_dtype", None)
-        if isinstance(torch_dtype, torch.dtype):
-            serialization_dict["init_parameters"]["huggingface_pipeline_kwargs"]["torch_dtype"] = str(torch_dtype)
-        # 2. torch_dtype and bnb_4bit_compute_dtype can be specified in model_kwargs
-        model_kwargs = huggingface_pipeline_kwargs.get("model_kwargs", {})
-        for key, value in model_kwargs.items():
-            if key in ["torch_dtype", "bnb_4bit_compute_dtype"] and isinstance(value, torch.dtype):
-                serialization_dict["init_parameters"]["huggingface_pipeline_kwargs"]["model_kwargs"][key] = str(value)
-        # 3. bnb_4bit_compute_dtype can be specified in model_kwargs["quantization_config"]
-        quantization_config = model_kwargs.get("quantization_config", {})
-        bnb_4bit_compute_dtype = quantization_config.get("bnb_4bit_compute_dtype", None)
-        if isinstance(bnb_4bit_compute_dtype, torch.dtype):
-            serialization_dict["init_parameters"]["huggingface_pipeline_kwargs"]["model_kwargs"]["quantization_config"][
-                "bnb_4bit_compute_dtype"
-            ] = str(bnb_4bit_compute_dtype)
 
+        serialize_hf_model_kwargs(huggingface_pipeline_kwargs)
         return serialization_dict
 
     @classmethod
@@ -187,32 +171,7 @@ class HuggingFaceLocalGenerator:
         """
         Deserialize this component from a dictionary.
         """
-        torch_and_transformers_import.check()
-        init_params = data.get("init_parameters", {})
-        huggingface_pipeline_kwargs = init_params.get("huggingface_pipeline_kwargs", {})
-        model_kwargs = huggingface_pipeline_kwargs.get("model_kwargs", {})
-
-        # convert string to torch.dtype
-        # 1. torch_dtype can be specified in huggingface_pipeline_kwargs
-        torch_dtype = huggingface_pipeline_kwargs.get("torch_dtype", None)
-        if torch_dtype and torch_dtype.startswith("torch."):
-            data["init_parameters"]["huggingface_pipeline_kwargs"]["torch_dtype"] = getattr(
-                torch, torch_dtype.strip("torch.")
-            )
-        # 2. torch_dtype and bnb_4bit_compute_dtype can be specified in model_kwargs
-        for key, value in model_kwargs.items():
-            if key in ["torch_dtype", "bnb_4bit_compute_dtype"] and value.startswith("torch."):
-                data["init_parameters"]["huggingface_pipeline_kwargs"]["model_kwargs"][key] = getattr(
-                    torch, value.strip("torch.")
-                )
-        # 3. bnb_4bit_compute_dtype can be specified in model_kwargs["quantization_config"]
-        quantization_config = model_kwargs.get("quantization_config", {})
-        bnb_4bit_compute_dtype = quantization_config.get("bnb_4bit_compute_dtype", None)
-        if bnb_4bit_compute_dtype and bnb_4bit_compute_dtype.startswith("torch."):
-            data["init_parameters"]["huggingface_pipeline_kwargs"]["model_kwargs"]["quantization_config"][
-                "bnb_4bit_compute_dtype"
-            ] = getattr(torch, bnb_4bit_compute_dtype.strip("torch."))
-
+        deserialize_hf_model_kwargs(data["init_parameters"]["huggingface_pipeline_kwargs"])
         return default_from_dict(cls, data)
 
     @component.output_types(replies=List[str])
diff --git a/haystack/components/preprocessors/document_splitter.py b/haystack/components/preprocessors/document_splitter.py
index 57531b73..4649c7f5 100644
--- a/haystack/components/preprocessors/document_splitter.py
+++ b/haystack/components/preprocessors/document_splitter.py
@@ -14,18 +14,21 @@ class DocumentSplitter:
     """
 
     def __init__(
-        self, split_by: Literal["word", "sentence", "passage"] = "word", split_length: int = 200, split_overlap: int = 0
+        self,
+        split_by: Literal["word", "sentence", "page", "passage"] = "word",
+        split_length: int = 200,
+        split_overlap: int = 0,
     ):
         """
         :param split_by: The unit by which the document should be split. Choose from "word" for splitting by " ",
-        "sentence" for splitting by ".", or "passage" for splitting by "\\n\\n".
+        "sentence" for splitting by ".", "page" for splitting by "\f" or "passage" for splitting by "\\n\\n".
         :param split_length: The maximum number of units in each split.
         :param split_overlap: The number of units that each split should overlap.
         """
 
         self.split_by = split_by
-        if split_by not in ["word", "sentence", "passage"]:
-            raise ValueError("split_by must be one of 'word', 'sentence' or 'passage'.")
+        if split_by not in ["word", "sentence", "page", "passage"]:
+            raise ValueError("split_by must be one of 'word', 'sentence', 'page' or 'passage'.")
         if split_length <= 0:
             raise ValueError("split_length must be greater than 0.")
         self.split_length = split_length
@@ -60,8 +63,10 @@ class DocumentSplitter:
             split_docs += [Document(content=txt, meta=metadata) for txt in text_splits]
         return {"documents": split_docs}
 
-    def _split_into_units(self, text: str, split_by: Literal["word", "sentence", "passage"]) -> List[str]:
-        if split_by == "passage":
+    def _split_into_units(self, text: str, split_by: Literal["word", "sentence", "passage", "page"]) -> List[str]:
+        if split_by == "page":
+            split_at = "\f"
+        elif split_by == "passage":
             split_at = "\n\n"
         elif split_by == "sentence":
             split_at = "."
@@ -69,7 +74,7 @@ class DocumentSplitter:
             split_at = " "
         else:
             raise NotImplementedError(
-                "DocumentSplitter only supports 'passage', 'sentence' or 'word' split_by options."
+                "DocumentSplitter only supports 'word', 'sentence', 'page' or 'passage' split_by options."
             )
         units = text.split(split_at)
         # Add the delimiter back to all units except the last one
diff --git a/haystack/components/rankers/transformers_similarity.py b/haystack/components/rankers/transformers_similarity.py
index ae8313de..d7d44cea 100644
--- a/haystack/components/rankers/transformers_similarity.py
+++ b/haystack/components/rankers/transformers_similarity.py
@@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional, Union
 from haystack import ComponentError, Document, component, default_from_dict, default_to_dict
 from haystack.lazy_imports import LazyImport
 from haystack.utils import ComponentDevice
+from haystack.utils.hf import deserialize_hf_model_kwargs, serialize_hf_model_kwargs
 
 logger = logging.getLogger(__name__)
 
@@ -131,20 +132,7 @@ class TransformersSimilarityRanker:
             model_kwargs=self.model_kwargs,
         )
 
-        # convert torch.dtype to string for serialization
-        # 1. torch_dtype and bnb_4bit_compute_dtype can be specified in model_kwargs
-        model_kwargs = serialization_dict["init_parameters"]["model_kwargs"]
-        for key, value in model_kwargs.items():
-            if key in ["torch_dtype", "bnb_4bit_compute_dtype"] and isinstance(value, torch.dtype):
-                serialization_dict["init_parameters"]["model_kwargs"][key] = str(value)
-        # 2. bnb_4bit_compute_dtype can be specified in model_kwargs["quantization_config"]
-        quantization_config = model_kwargs.get("quantization_config", {})
-        bnb_4bit_compute_dtype = quantization_config.get("bnb_4bit_compute_dtype", None)
-        if isinstance(bnb_4bit_compute_dtype, torch.dtype):
-            serialization_dict["init_parameters"]["model_kwargs"]["quantization_config"][
-                "bnb_4bit_compute_dtype"
-            ] = str(bnb_4bit_compute_dtype)
-
+        serialize_hf_model_kwargs(serialization_dict["init_parameters"]["model_kwargs"])
         return serialization_dict
 
     @classmethod
@@ -152,25 +140,9 @@ class TransformersSimilarityRanker:
         """
         Deserialize this component from a dictionary.
         """
-        torch_and_transformers_import.check()
-        init_params = data.get("init_parameters", {})
-        model_kwargs = init_params.get("model_kwargs", {})
-
-        serialized_device = init_params.get("device", {})
-        init_params["device"] = ComponentDevice.from_dict(serialized_device)
-
-        # convert string to torch.dtype
-        # 1. torch_dtype and bnb_4bit_compute_dtype can be specified in model_kwargs
-        for key, value in model_kwargs.items():
-            if key in ["torch_dtype", "bnb_4bit_compute_dtype"] and value.startswith("torch."):
-                data["init_parameters"]["model_kwargs"][key] = getattr(torch, value.strip("torch."))
-        # 2. bnb_4bit_compute_dtype can be specified in model_kwargs["quantization_config"]
-        quantization_config = model_kwargs.get("quantization_config", {})
-        bnb_4bit_compute_dtype = quantization_config.get("bnb_4bit_compute_dtype", None)
-        if isinstance(bnb_4bit_compute_dtype, str) and bnb_4bit_compute_dtype.startswith("torch."):
-            data["init_parameters"]["model_kwargs"]["quantization_config"]["bnb_4bit_compute_dtype"] = getattr(
-                torch, bnb_4bit_compute_dtype.strip("torch.")
-            )
+        init_params = data["init_parameters"]
+        init_params["device"] = ComponentDevice.from_dict(init_params["device"])
+        deserialize_hf_model_kwargs(init_params["model_kwargs"])
 
         return default_from_dict(cls, data)
 
diff --git a/haystack/components/readers/extractive.py b/haystack/components/readers/extractive.py
index f84ab558..13d98240 100644
--- a/haystack/components/readers/extractive.py
+++ b/haystack/components/readers/extractive.py
@@ -7,6 +7,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
 from haystack import ComponentError, Document, ExtractedAnswer, component, default_from_dict, default_to_dict
 from haystack.lazy_imports import LazyImport
 from haystack.utils import ComponentDevice
+from haystack.utils.hf import deserialize_hf_model_kwargs, serialize_hf_model_kwargs
 
 with LazyImport("Run 'pip install transformers[torch,sentencepiece]'") as torch_and_transformers_import:
     import torch
@@ -132,20 +133,7 @@ class ExtractiveReader:
             model_kwargs=self.model_kwargs,
         )
 
-        # convert torch.dtype to string for serialization
-        # 1. torch_dtype and bnb_4bit_compute_dtype can be specified in model_kwargs
-        model_kwargs = serialization_dict["init_parameters"]["model_kwargs"]
-        for key, value in model_kwargs.items():
-            if key in ["torch_dtype", "bnb_4bit_compute_dtype"] and isinstance(value, torch.dtype):
-                serialization_dict["init_parameters"]["model_kwargs"][key] = str(value)
-        # 2. bnb_4bit_compute_dtype can be specified in model_kwargs["quantization_config"]
-        quantization_config = model_kwargs.get("quantization_config", {})
-        bnb_4bit_compute_dtype = quantization_config.get("bnb_4bit_compute_dtype", None)
-        if isinstance(bnb_4bit_compute_dtype, torch.dtype):
-            serialization_dict["init_parameters"]["model_kwargs"]["quantization_config"][
-                "bnb_4bit_compute_dtype"
-            ] = str(bnb_4bit_compute_dtype)
-
+        serialize_hf_model_kwargs(serialization_dict["init_parameters"]["model_kwargs"])
         return serialization_dict
 
     @classmethod
@@ -153,25 +141,10 @@ class ExtractiveReader:
         """
         Deserialize this component from a dictionary.
         """
-        torch_and_transformers_import.check()
-        init_params = data.get("init_parameters", {})
-        model_kwargs = init_params.get("model_kwargs", {})
-
-        serialized_device = init_params.get("device", {})
-        init_params["device"] = ComponentDevice.from_dict(serialized_device)
-
-        # convert string to torch.dtype
-        # 1. torch_dtype and bnb_4bit_compute_dtype can be specified in model_kwargs
-        for key, value in model_kwargs.items():
-            if key in ["torch_dtype", "bnb_4bit_compute_dtype"] and value.startswith("torch."):
-                data["init_parameters"]["model_kwargs"][key] = getattr(torch, value.strip("torch."))
-        # 2. bnb_4bit_compute_dtype can be specified in model_kwargs["quantization_config"]
-        quantization_config = model_kwargs.get("quantization_config", {})
-        bnb_4bit_compute_dtype = quantization_config.get("bnb_4bit_compute_dtype", None)
-        if isinstance(bnb_4bit_compute_dtype, str) and bnb_4bit_compute_dtype.startswith("torch."):
-            data["init_parameters"]["model_kwargs"]["quantization_config"]["bnb_4bit_compute_dtype"] = getattr(
-                torch, bnb_4bit_compute_dtype.strip("torch.")
-            )
+        init_params = data["init_parameters"]
+        init_params["device"] = ComponentDevice.from_dict(init_params["device"])
+        deserialize_hf_model_kwargs(init_params["model_kwargs"])
+
         return default_from_dict(cls, data)
 
     def warm_up(self):
diff --git a/haystack/utils/hf.py b/haystack/utils/hf.py
new file mode 100644
index 00000000..8516a9b9
--- /dev/null
+++ b/haystack/utils/hf.py
@@ -0,0 +1,41 @@
+from typing import Any, Dict
+from haystack.lazy_imports import LazyImport
+
+
+with LazyImport(message="Run 'pip install transformers[torch]'") as torch_import:
+    import torch
+
+
+def serialize_hf_model_kwargs(kwargs: Dict[str, Any]):
+    """
+    Recursively serialize HuggingFace specific model keyword arguments
+    in-place to make them JSON serializable.
+    """
+    torch_import.check()
+
+    for k, v in kwargs.items():
+        # torch.dtype
+        if isinstance(v, torch.dtype):
+            kwargs[k] = str(v)
+
+        if isinstance(v, dict):
+            serialize_hf_model_kwargs(v)
+
+
+def deserialize_hf_model_kwargs(kwargs: Dict[str, Any]):
+    """
+    Recursively deserialize HuggingFace specific model keyword arguments
+    in-place to make them JSON serializable.
+    """
+    torch_import.check()
+
+    for k, v in kwargs.items():
+        # torch.dtype
+        if isinstance(v, str) and v.startswith("torch."):
+            dtype_str = v.split(".")[1]
+            dtype = getattr(torch, dtype_str, None)
+            if dtype is not None and isinstance(dtype, torch.dtype):
+                kwargs[k] = dtype
+
+        if isinstance(v, dict):
+            deserialize_hf_model_kwargs(v)
diff --git a/releasenotes/notes/add-split-by-page-to-DocumentSplitter-63232c17d858d787.yaml b/releasenotes/notes/add-split-by-page-to-DocumentSplitter-63232c17d858d787.yaml
new file mode 100644
index 00000000..3fccae32
--- /dev/null
+++ b/releasenotes/notes/add-split-by-page-to-DocumentSplitter-63232c17d858d787.yaml
@@ -0,0 +1,4 @@
+---
+enhancements:
+  - |
+    Added split by page to DocumentSplitter, which will split the document at \f
diff --git a/releasenotes/notes/pypdf-page-breaks-b6842d93f4c69185.yaml b/releasenotes/notes/pypdf-page-breaks-b6842d93f4c69185.yaml
new file mode 100644
index 00000000..b57856db
--- /dev/null
+++ b/releasenotes/notes/pypdf-page-breaks-b6842d93f4c69185.yaml
@@ -0,0 +1,7 @@
+---
+upgrade:
+  - |
+    Upgraded the default converter in PyPDFToDocument to insert page breaks "\f"
+    between each extracted page.
+    This allows for downstream components and applications to better be able to
+    keep track of the original PDF page a portion of text comes from.


Test patch:
diff --git a/e2e/pipelines/test_dense_doc_search.py b/e2e/pipelines/test_dense_doc_search.py
index b116817a..9b3ea2ed 100644
--- a/e2e/pipelines/test_dense_doc_search.py
+++ b/e2e/pipelines/test_dense_doc_search.py
@@ -41,14 +41,13 @@ def test_dense_doc_search_pipeline(tmp_path, samples_path):
     # Draw the indexing pipeline
     indexing_pipeline.draw(tmp_path / "test_dense_doc_search_indexing_pipeline.png")
 
-    # Serialize the indexing pipeline to JSON
-    with open(tmp_path / "test_dense_doc_search_indexing_pipeline.json", "w") as f:
-        print(json.dumps(indexing_pipeline.to_dict(), indent=4))
-        json.dump(indexing_pipeline.to_dict(), f)
+    # Serialize the indexing pipeline to YAML.
+    with open(tmp_path / "test_dense_doc_search_indexing_pipeline.yaml", "w") as f:
+        indexing_pipeline.dump(f)
 
     # Load the indexing pipeline back
-    with open(tmp_path / "test_dense_doc_search_indexing_pipeline.json", "r") as f:
-        indexing_pipeline = Pipeline.from_dict(json.load(f))
+    with open(tmp_path / "test_dense_doc_search_indexing_pipeline.yaml", "r") as f:
+        indexing_pipeline = Pipeline.load(f)
 
     indexing_result = indexing_pipeline.run({"file_type_router": {"sources": list(samples_path.iterdir())}})
     filled_document_store = indexing_pipeline.get_component("writer").document_store
diff --git a/e2e/pipelines/test_extractive_qa_pipeline.py b/e2e/pipelines/test_extractive_qa_pipeline.py
index 46b3b6cc..860fb7b2 100644
--- a/e2e/pipelines/test_extractive_qa_pipeline.py
+++ b/e2e/pipelines/test_extractive_qa_pipeline.py
@@ -16,14 +16,13 @@ def test_extractive_qa_pipeline(tmp_path):
     # Draw the pipeline
     qa_pipeline.draw(tmp_path / "test_extractive_qa_pipeline.png")
 
-    # Serialize the pipeline to JSON
-    with open(tmp_path / "test_bm25_rag_pipeline.json", "w") as f:
-        print(json.dumps(qa_pipeline.to_dict(), indent=4))
-        json.dump(qa_pipeline.to_dict(), f)
+    # Serialize the pipeline to YAML
+    with open(tmp_path / "test_bm25_rag_pipeline.yaml", "w") as f:
+        qa_pipeline.dump(f)
 
     # Load the pipeline back
-    with open(tmp_path / "test_bm25_rag_pipeline.json", "r") as f:
-        qa_pipeline = Pipeline.from_dict(json.load(f))
+    with open(tmp_path / "test_bm25_rag_pipeline.yaml", "r") as f:
+        qa_pipeline = Pipeline.load(f)
 
     # Populate the document store
     documents = [
diff --git a/e2e/pipelines/test_hybrid_doc_search_pipeline.py b/e2e/pipelines/test_hybrid_doc_search_pipeline.py
index 4436bfab..f416b24d 100644
--- a/e2e/pipelines/test_hybrid_doc_search_pipeline.py
+++ b/e2e/pipelines/test_hybrid_doc_search_pipeline.py
@@ -31,14 +31,13 @@ def test_hybrid_doc_search_pipeline(tmp_path):
     # Draw the pipeline
     hybrid_pipeline.draw(tmp_path / "test_hybrid_doc_search_pipeline.png")
 
-    # Serialize the pipeline to JSON
-    with open(tmp_path / "test_hybrid_doc_search_pipeline.json", "w") as f:
-        print(json.dumps(hybrid_pipeline.to_dict(), indent=4))
-        json.dump(hybrid_pipeline.to_dict(), f)
+    # Serialize the pipeline to YAML
+    with open(tmp_path / "test_hybrid_doc_search_pipeline.yaml", "w") as f:
+        hybrid_pipeline.dump(f)
 
     # Load the pipeline back
-    with open(tmp_path / "test_hybrid_doc_search_pipeline.json", "r") as f:
-        hybrid_pipeline = Pipeline.from_dict(json.load(f))
+    with open(tmp_path / "test_hybrid_doc_search_pipeline.yaml", "r") as f:
+        hybrid_pipeline = Pipeline.load(f)
 
     # Populate the document store
     documents = [
diff --git a/e2e/pipelines/test_preprocessing_pipeline.py b/e2e/pipelines/test_preprocessing_pipeline.py
index 95bfc112..9a73ab93 100644
--- a/e2e/pipelines/test_preprocessing_pipeline.py
+++ b/e2e/pipelines/test_preprocessing_pipeline.py
@@ -39,14 +39,13 @@ def test_preprocessing_pipeline(tmp_path):
     # Draw the pipeline
     preprocessing_pipeline.draw(tmp_path / "test_preprocessing_pipeline.png")
 
-    # Serialize the pipeline to JSON
-    with open(tmp_path / "test_preprocessing_pipeline.json", "w") as f:
-        print(json.dumps(preprocessing_pipeline.to_dict(), indent=4))
-        json.dump(preprocessing_pipeline.to_dict(), f)
+    # Serialize the pipeline to YAML
+    with open(tmp_path / "test_preprocessing_pipeline.yaml", "w") as f:
+        preprocessing_pipeline.dump(f)
 
     # Load the pipeline back
-    with open(tmp_path / "test_preprocessing_pipeline.json", "r") as f:
-        preprocessing_pipeline = Pipeline.from_dict(json.load(f))
+    with open(tmp_path / "test_preprocessing_pipeline.yaml", "r") as f:
+        preprocessing_pipeline = Pipeline.load(f)
 
     # Write a txt file
     with open(tmp_path / "test_file_english.txt", "w") as f:
diff --git a/e2e/pipelines/test_rag_pipelines_e2e.py b/e2e/pipelines/test_rag_pipelines_e2e.py
index 4fdc99ef..fa3aeb8c 100644
--- a/e2e/pipelines/test_rag_pipelines_e2e.py
+++ b/e2e/pipelines/test_rag_pipelines_e2e.py
@@ -41,13 +41,13 @@ def test_bm25_rag_pipeline(tmp_path):
     # Draw the pipeline
     rag_pipeline.draw(tmp_path / "test_bm25_rag_pipeline.png")
 
-    # Serialize the pipeline to JSON
-    with open(tmp_path / "test_bm25_rag_pipeline.json", "w") as f:
-        json.dump(rag_pipeline.to_dict(), f)
+    # Serialize the pipeline to YAML
+    with open(tmp_path / "test_bm25_rag_pipeline.yaml", "w") as f:
+        rag_pipeline.dump(f)
 
     # Load the pipeline back
-    with open(tmp_path / "test_bm25_rag_pipeline.json", "r") as f:
-        rag_pipeline = Pipeline.from_dict(json.load(f))
+    with open(tmp_path / "test_bm25_rag_pipeline.yaml", "r") as f:
+        rag_pipeline = Pipeline.load(f)
 
     # Populate the document store
     documents = [
diff --git a/test/components/converters/test_html_to_document.py b/test/components/converters/test_html_to_document.py
index aa8df511..519a1c05 100644
--- a/test/components/converters/test_html_to_document.py
+++ b/test/components/converters/test_html_to_document.py
@@ -160,3 +160,12 @@ class TestHTMLToDocument:
         assert len(docs) == 3
         for doc in docs:
             assert "Haystack" in doc.content
+
+    def test_serde(self):
+        """
+        Test if the component runs correctly gets serialized and deserialized.
+        """
+        converter = HTMLToDocument("ArticleExtractor")
+        serde_data = converter.to_dict()
+        new_converter = HTMLToDocument.from_dict(serde_data)
+        assert new_converter.extractor_type == converter.extractor_type
diff --git a/test/components/converters/test_pypdf_to_document.py b/test/components/converters/test_pypdf_to_document.py
index 8de0646b..330ed9a3 100644
--- a/test/components/converters/test_pypdf_to_document.py
+++ b/test/components/converters/test_pypdf_to_document.py
@@ -7,35 +7,45 @@ from haystack.components.converters.pypdf import PyPDFToDocument, CONVERTERS_REG
 from haystack.dataclasses import ByteStream
 
 
-@pytest.mark.integration
+@pytest.fixture
+def pypdf_converter():
+    return PyPDFToDocument()
+
+
 class TestPyPDFToDocument:
-    def test_init(self):
-        component = PyPDFToDocument()
-        assert component.converter_name == "default"
-        assert hasattr(component, "_converter")
+    def test_init(self, pypdf_converter):
+        assert pypdf_converter.converter_name == "default"
+        assert hasattr(pypdf_converter, "_converter")
 
     def test_init_fail_nonexisting_converter(self):
         with pytest.raises(ValueError):
             PyPDFToDocument(converter_name="non_existing_converter")
 
-    def test_run(self, test_files_path):
+    @pytest.mark.integration
+    def test_run(self, test_files_path, pypdf_converter):
         """
         Test if the component runs correctly.
         """
-        paths = [test_files_path / "pdf" / "react_paper.pdf"]
-        converter = PyPDFToDocument()
-        output = converter.run(sources=paths)
+        paths = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
+        output = pypdf_converter.run(sources=paths)
         docs = output["documents"]
         assert len(docs) == 1
-        assert "ReAct" in docs[0].content
+        assert "History" in docs[0].content
 
-    def test_run_with_meta(self, test_files_path):
+    @pytest.mark.integration
+    def test_page_breaks_added(self, test_files_path, pypdf_converter):
+        paths = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
+        output = pypdf_converter.run(sources=paths)
+        docs = output["documents"]
+        assert len(docs) == 1
+        assert docs[0].content.count("\f") == 3
+
+    def test_run_with_meta(self, test_files_path, pypdf_converter):
         bytestream = ByteStream(data=b"test", meta={"author": "test_author", "language": "en"})
 
-        converter = PyPDFToDocument()
         with patch("haystack.components.converters.pypdf.PdfReader"):
-            output = converter.run(
-                sources=[bytestream, test_files_path / "pdf" / "react_paper.pdf"], meta={"language": "it"}
+            output = pypdf_converter.run(
+                sources=[bytestream, test_files_path / "pdf" / "sample_pdf_1.pdf"], meta={"language": "it"}
             )
 
         # check that the metadata from the bytestream is merged with that from the meta parameter
@@ -43,38 +53,38 @@ class TestPyPDFToDocument:
         assert output["documents"][0].meta["language"] == "it"
         assert output["documents"][1].meta["language"] == "it"
 
-    def test_run_error_handling(self, test_files_path, caplog):
+    def test_run_error_handling(self, test_files_path, pypdf_converter, caplog):
         """
         Test if the component correctly handles errors.
         """
         paths = ["non_existing_file.pdf"]
-        converter = PyPDFToDocument()
         with caplog.at_level(logging.WARNING):
-            converter.run(sources=paths)
+            pypdf_converter.run(sources=paths)
             assert "Could not read non_existing_file.pdf" in caplog.text
 
-    def test_mixed_sources_run(self, test_files_path):
+    @pytest.mark.integration
+    def test_mixed_sources_run(self, test_files_path, pypdf_converter):
         """
         Test if the component runs correctly when mixed sources are provided.
         """
-        paths = [test_files_path / "pdf" / "react_paper.pdf"]
-        with open(test_files_path / "pdf" / "react_paper.pdf", "rb") as f:
+        paths = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
+        with open(test_files_path / "pdf" / "sample_pdf_1.pdf", "rb") as f:
             paths.append(ByteStream(f.read()))
 
-        converter = PyPDFToDocument()
-        output = converter.run(sources=paths)
+        output = pypdf_converter.run(sources=paths)
         docs = output["documents"]
         assert len(docs) == 2
-        assert "ReAct" in docs[0].content
-        assert "ReAct" in docs[1].content
+        assert "History and standardization" in docs[0].content
+        assert "History and standardization" in docs[1].content
 
+    @pytest.mark.integration
     def test_custom_converter(self, test_files_path):
         """
         Test if the component correctly handles custom converters.
         """
         from pypdf import PdfReader
 
-        paths = [test_files_path / "pdf" / "react_paper.pdf"]
+        paths = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
 
         class MyCustomConverter:
             def convert(self, reader: PdfReader) -> Document:
diff --git a/test/components/preprocessors/test_document_splitter.py b/test/components/preprocessors/test_document_splitter.py
index b99e3be2..479f0d50 100644
--- a/test/components/preprocessors/test_document_splitter.py
+++ b/test/components/preprocessors/test_document_splitter.py
@@ -23,7 +23,7 @@ class TestDocumentSplitter:
         assert res == {"documents": []}
 
     def test_unsupported_split_by(self):
-        with pytest.raises(ValueError, match="split_by must be one of 'word', 'sentence' or 'passage'."):
+        with pytest.raises(ValueError, match="split_by must be one of 'word', 'sentence', 'page' or 'passage'."):
             DocumentSplitter(split_by="unsupported")
 
     def test_unsupported_split_length(self):
@@ -94,6 +94,20 @@ class TestDocumentSplitter:
         assert result["documents"][1].content == "And there is a third sentence.\n\n"
         assert result["documents"][2].content == " And another passage."
 
+    def test_split_by_page(self):
+        splitter = DocumentSplitter(split_by="page", split_length=1)
+        result = splitter.run(
+            documents=[
+                Document(
+                    content="This is a text with some words. There is a second sentence.\f And there is a third sentence.\f And another passage."
+                )
+            ]
+        )
+        assert len(result["documents"]) == 3
+        assert result["documents"][0].content == "This is a text with some words. There is a second sentence.\x0c"
+        assert result["documents"][1].content == " And there is a third sentence.\x0c"
+        assert result["documents"][2].content == " And another passage."
+
     def test_split_by_word_with_overlap(self):
         splitter = DocumentSplitter(split_by="word", split_length=10, split_overlap=2)
         result = splitter.run(