Repository: bohning/usdb_syncer
Pull request #383: Fix pdf
Author: login: bohning
Created: 2025-04-09T12:41:38Z | Merged: 2025-04-13T06:17:48Z



Diff:
diff --git a/.gitattributes b/.gitattributes
index 937f08bc..9b1c9575 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,3 +2,4 @@
 *.jpg -text
 *.png -text
 *.afphoto -text
+*.ttf -text
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d0df2f0d..c460f922 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,7 +12,8 @@
 - Infos about unavailable or invalid resources are now sent to Discord (**please enable this in the settings to support the community**).
 - Songs can now be rated on USDB via the Syncer (1-5 star rating).
 - Artist initial is now available for templates.
-- Audio normalization has a new option ReplayGain. If your karaoke software supports ReplayGain, this is the preferred option as it does not reencode the audio file but instead only writes normalization information into the header of the audio file. 
+- Audio normalization has a new option ReplayGain. If your karaoke software supports ReplayGain (e.g. UltraStar deluxe >= 2025.4.0), this is the preferred option as it does not reencode the audio file but instead only writes normalization information into the header of the audio file.
+- PDF report (song list) creation has been fixed and extended.
 
 <!-- 0.11.0 -->
 
diff --git a/poetry.lock b/poetry.lock
index 17161bad..3f2b0dd6 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand.
 
 [[package]]
 name = "altgraph"
@@ -49,12 +49,12 @@ files = [
 ]
 
 [package.extras]
-benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"]
-cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"]
-dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"]
+benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
 docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"]
-tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"]
-tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""]
+tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"]
 
 [[package]]
 name = "beautifulsoup4"
@@ -143,10 +143,6 @@ files = [
     {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d"},
     {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0"},
     {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e"},
-    {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c"},
-    {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1"},
-    {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2"},
-    {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec"},
     {file = "Brotli-1.1.0-cp310-cp310-win32.whl", hash = "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2"},
     {file = "Brotli-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128"},
     {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc"},
@@ -159,14 +155,8 @@ files = [
     {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9"},
     {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265"},
     {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8"},
-    {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f"},
-    {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757"},
-    {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0"},
-    {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b"},
     {file = "Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50"},
     {file = "Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1"},
-    {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28"},
-    {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f"},
     {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409"},
     {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2"},
     {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451"},
@@ -177,24 +167,8 @@ files = [
     {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180"},
     {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248"},
     {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966"},
-    {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9"},
-    {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb"},
-    {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111"},
-    {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839"},
     {file = "Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0"},
     {file = "Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951"},
-    {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5"},
-    {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8"},
-    {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f"},
-    {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648"},
-    {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0"},
-    {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089"},
-    {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368"},
-    {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c"},
-    {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284"},
-    {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7"},
-    {file = "Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0"},
-    {file = "Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b"},
     {file = "Brotli-1.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1"},
     {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d"},
     {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b"},
@@ -204,10 +178,6 @@ files = [
     {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2"},
     {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354"},
     {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2"},
-    {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75"},
-    {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c"},
-    {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2"},
-    {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52"},
     {file = "Brotli-1.1.0-cp36-cp36m-win32.whl", hash = "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460"},
     {file = "Brotli-1.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579"},
     {file = "Brotli-1.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c"},
@@ -219,10 +189,6 @@ files = [
     {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74"},
     {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b"},
     {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438"},
-    {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01"},
-    {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547"},
-    {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38"},
-    {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c"},
     {file = "Brotli-1.1.0-cp37-cp37m-win32.whl", hash = "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95"},
     {file = "Brotli-1.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68"},
     {file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3"},
@@ -235,10 +201,6 @@ files = [
     {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a"},
     {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088"},
     {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596"},
-    {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7"},
-    {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5"},
-    {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943"},
-    {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a"},
     {file = "Brotli-1.1.0-cp38-cp38-win32.whl", hash = "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b"},
     {file = "Brotli-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0"},
     {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a"},
@@ -251,10 +213,6 @@ files = [
     {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c"},
     {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d"},
     {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59"},
-    {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419"},
-    {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2"},
-    {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f"},
-    {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb"},
     {file = "Brotli-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64"},
     {file = "Brotli-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467"},
     {file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"},
@@ -411,7 +369,7 @@ version = "5.2.0"
 description = "Universal encoding detector for Python 3"
 optional = false
 python-versions = ">=3.7"
-groups = ["dev"]
+groups = ["main", "dev"]
 files = [
     {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"},
     {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"},
@@ -615,10 +573,10 @@ files = [
 cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
 
 [package.extras]
-docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""]
+docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0)"]
 docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
-nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""]
-pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
+nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"]
+pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
 sdist = ["build (>=1.0.0)"]
 ssh = ["bcrypt (>=3.1.5)"]
 test = ["certifi (>=2024)", "cryptography-vectors (==44.0.2)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
@@ -738,7 +696,7 @@ files = [
 [package.extras]
 docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"]
 testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"]
-typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""]
+typing = ["typing-extensions (>=4.12.2)"]
 
 [[package]]
 name = "filetype"
@@ -783,12 +741,12 @@ files = [
 zipp = ">=3.20"
 
 [package.extras]
-check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"]
 cover = ["pytest-cov"]
 doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
 enabler = ["pytest-enabler (>=2.2)"]
 perf = ["ipython"]
-test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
+test = ["flufl.flake8", "importlib_resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
 type = ["pytest-mypy"]
 
 [[package]]
@@ -852,7 +810,7 @@ files = [
 
 [package.extras]
 doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-test = ["portend", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
+test = ["portend", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
 
 [[package]]
 name = "jaraco-functools"
@@ -870,7 +828,7 @@ files = [
 more-itertools = "*"
 
 [package.extras]
-check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"]
 cover = ["pytest-cov"]
 doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
 enabler = ["pytest-enabler (>=2.2)"]
@@ -891,7 +849,7 @@ files = [
 ]
 
 [package.extras]
-test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"]
+test = ["async-timeout", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"]
 trio = ["trio"]
 
 [[package]]
@@ -933,7 +891,7 @@ pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""}
 SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""}
 
 [package.extras]
-check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"]
 completion = ["shtab (>=1.1.0)"]
 cover = ["pytest-cov"]
 doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
@@ -1308,18 +1266,6 @@ files = [
     {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
 ]
 
-[[package]]
-name = "pdfme"
-version = "0.4.12"
-description = "Create PDFs easily"
-optional = false
-python-versions = ">=3.6"
-groups = ["main"]
-files = [
-    {file = "pdfme-0.4.12-py3-none-any.whl", hash = "sha256:a0cefae43f10ea58c39088ac73768847e242081e13c9006ed0865309bd8e1a6f"},
-    {file = "pdfme-0.4.12.tar.gz", hash = "sha256:c9305a47e7b586339536a3b910fe9d65827d1b8060dd76efd3251c5b42bc1364"},
-]
-
 [[package]]
 name = "pefile"
 version = "2023.2.7"
@@ -1419,7 +1365,7 @@ docs = ["furo", "olefile", "sphinx (>=8.1)", "sphinx-copybutton", "sphinx-inline
 fpx = ["olefile"]
 mic = ["olefile"]
 tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"]
-typing = ["typing-extensions ; python_version < \"3.10\""]
+typing = ["typing-extensions"]
 xmp = ["defusedxml"]
 
 [[package]]
@@ -1709,6 +1655,27 @@ files = [
     {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"},
 ]
 
+[[package]]
+name = "reportlab"
+version = "4.3.1"
+description = "The Reportlab Toolkit"
+optional = false
+python-versions = "<4,>=3.7"
+groups = ["main"]
+files = [
+    {file = "reportlab-4.3.1-py3-none-any.whl", hash = "sha256:0f37dd16652db3ef84363cf744632a28c38bd480d5bf94683466852d7bb678dd"},
+    {file = "reportlab-4.3.1.tar.gz", hash = "sha256:230f78b21667194d8490ac9d12958d5c14686352db7fbe03b95140fafdf5aa97"},
+]
+
+[package.dependencies]
+chardet = "*"
+pillow = ">=9.0.0"
+
+[package.extras]
+accel = ["rl_accel (>=0.9.0,<1.1)"]
+pycairo = ["freetype-py (>=2.3.0,<2.4)", "rlPyCairo (>=0.2.0,<1)"]
+renderpm = ["rl_renderPM (>=4.0.3,<4.1)"]
+
 [[package]]
 name = "requests"
 version = "2.32.3"
@@ -1840,9 +1807,9 @@ files = [
 ]
 
 [package.extras]
-nativelib = ["pyobjc-framework-Cocoa ; sys_platform == \"darwin\"", "pywin32 ; sys_platform == \"win32\""]
-objc = ["pyobjc-framework-Cocoa ; sys_platform == \"darwin\""]
-win32 = ["pywin32 ; sys_platform == \"win32\""]
+nativelib = ["pyobjc-framework-Cocoa", "pywin32"]
+objc = ["pyobjc-framework-Cocoa"]
+win32 = ["pywin32"]
 
 [[package]]
 name = "setuptools"
@@ -1857,13 +1824,13 @@ files = [
 ]
 
 [package.extras]
-check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""]
-core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.8.0)"]
+core = ["importlib_metadata (>=6)", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"]
 cover = ["pytest-cov"]
 doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
 enabler = ["pytest-enabler (>=2.2)"]
-test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
-type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"]
+test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
+type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.14.*)", "pytest-mypy"]
 
 [[package]]
 name = "shiboken6"
@@ -2054,7 +2021,7 @@ files = [
 ]
 
 [package.extras]
-brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
 h2 = ["h2 (>=4,<5)"]
 socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
 zstd = ["zstandard (>=0.18.0)"]
@@ -2078,7 +2045,7 @@ platformdirs = ">=3.9.1,<5"
 
 [package.extras]
 docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
-test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""]
+test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
 
 [[package]]
 name = "websockets"
@@ -2199,8 +2166,8 @@ websockets = {version = ">=13.0", optional = true, markers = "extra == \"default
 
 [package.extras]
 build = ["build", "hatchling", "pip", "setuptools (>=71.0.2)", "wheel"]
-curl-cffi = ["curl-cffi (>=0.5.10,<0.6.dev0 || ==0.10.*) ; implementation_name == \"cpython\""]
-default = ["brotli ; implementation_name == \"cpython\"", "brotlicffi ; implementation_name != \"cpython\"", "certifi", "mutagen", "pycryptodomex", "requests (>=2.32.2,<3)", "urllib3 (>=1.26.17,<3)", "websockets (>=13.0)"]
+curl-cffi = ["curl-cffi (>=0.5.10,<0.6.dev0 || ==0.10.*)"]
+default = ["brotli", "brotlicffi", "certifi", "mutagen", "pycryptodomex", "requests (>=2.32.2,<3)", "urllib3 (>=1.26.17,<3)", "websockets (>=13.0)"]
 dev = ["autopep8 (>=2.0,<3.0)", "pre-commit", "pytest (>=8.1,<9.0)", "pytest-rerunfailures (>=14.0,<15.0)", "ruff (>=0.11.0,<0.12.0)"]
 pyinstaller = ["pyinstaller (>=6.11.1)"]
 secretstorage = ["cffi", "secretstorage"]
@@ -2220,14 +2187,14 @@ files = [
 ]
 
 [package.extras]
-check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"]
 cover = ["pytest-cov"]
 doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
 enabler = ["pytest-enabler (>=2.2)"]
-test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"]
+test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"]
 type = ["pytest-mypy"]
 
 [metadata]
 lock-version = "2.1"
 python-versions = "3.12.*"
-content-hash = "df9872a1cac42702d7064cb2228acb6396e6c1cac8c699ec66d8bcdd3c7c214c"
+content-hash = "295472659f9dbbb62425ebecadafb1991269240239b26c8ce5571d9659415d16"
diff --git a/pyproject.toml b/pyproject.toml
index c49e6fd5..83f729a4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,10 +21,10 @@ importlib-metadata = "*"
 keyring = "*"
 lxml = "*"
 mutagen = "*"
-pdfme = "*"
 pillow = ">=10"
 poetry-dynamic-versioning = "^1.6.0"
 PySide6 = "6.8.0.2"
+reportlab = "*"
 requests = "*"
 rookiepy = "^0.5.3"
 send2trash = "*"
diff --git a/src/usdb_syncer/gui/forms/MainWindow.ui b/src/usdb_syncer/gui/forms/MainWindow.ui
index 96f85f3f..f1853254 100644
--- a/src/usdb_syncer/gui/forms/MainWindow.ui
+++ b/src/usdb_syncer/gui/forms/MainWindow.ui
@@ -209,8 +209,7 @@
     </property>
     <addaction name="action_settings"/>
     <addaction name="action_meta_tags"/>
-    <addaction name="action_generate_song_pdf"/>
-    <addaction name="action_generate_song_json"/>
+    <addaction name="action_generate_song_list"/>
    </widget>
    <widget class="QMenu" name="menu_usdb">
     <property name="title">
@@ -499,22 +498,13 @@
     <string>Settings</string>
    </property>
   </action>
-  <action name="action_generate_song_pdf">
+  <action name="action_generate_song_list">
    <property name="icon">
     <iconset resource="../resources/resources.qrc">
      <normaloff>:/icons/report.png</normaloff>:/icons/report.png</iconset>
    </property>
    <property name="text">
-    <string>Generate Song List (PDF)</string>
-   </property>
-  </action>
-  <action name="action_generate_song_json">
-   <property name="icon">
-    <iconset resource="../resources/resources.qrc">
-     <normaloff>:/icons/report.png</normaloff>:/icons/report.png</iconset>
-   </property>
-   <property name="text">
-    <string>Generate Song List (JSON)</string>
+    <string>Create Report</string>
    </property>
   </action>
   <action name="action_refetch_song_list">
diff --git a/src/usdb_syncer/gui/forms/ReportDialog.ui b/src/usdb_syncer/gui/forms/ReportDialog.ui
new file mode 100644
index 00000000..1c792bb7
--- /dev/null
+++ b/src/usdb_syncer/gui/forms/ReportDialog.ui
@@ -0,0 +1,331 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>Dialog</class>
+ <widget class="QDialog" name="Dialog">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>663</width>
+    <height>498</height>
+   </rect>
+  </property>
+  <property name="sizePolicy">
+   <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+    <horstretch>0</horstretch>
+    <verstretch>0</verstretch>
+   </sizepolicy>
+  </property>
+  <property name="minimumSize">
+   <size>
+    <width>0</width>
+    <height>0</height>
+   </size>
+  </property>
+  <property name="windowTitle">
+   <string>New Report</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout_5">
+   <item>
+    <widget class="QGroupBox" name="groupBox_source">
+     <property name="title">
+      <string>Source</string>
+     </property>
+     <layout class="QVBoxLayout" name="verticalLayout_3">
+      <item>
+       <widget class="QRadioButton" name="radioButton_locally_available_songs">
+        <property name="text">
+         <string>Locally available songs</string>
+        </property>
+        <property name="checked">
+         <bool>true</bool>
+        </property>
+       </widget>
+      </item>
+      <item>
+       <widget class="QRadioButton" name="radioButton_selected_songs">
+        <property name="text">
+         <string>Selected songs</string>
+        </property>
+       </widget>
+      </item>
+      <item>
+       <widget class="QRadioButton" name="radioButton_all_songs">
+        <property name="text">
+         <string>All songs</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item>
+    <widget class="QTabWidget" name="tabWidget_report_type">
+     <property name="minimumSize">
+      <size>
+       <width>0</width>
+       <height>200</height>
+      </size>
+     </property>
+     <property name="currentIndex">
+      <number>0</number>
+     </property>
+     <widget class="QWidget" name="tab_pdf">
+      <attribute name="title">
+       <string>PDF Report</string>
+      </attribute>
+      <layout class="QVBoxLayout" name="verticalLayout_2">
+       <item>
+        <widget class="QSplitter" name="splitter">
+         <property name="orientation">
+          <enum>Qt::Orientation::Horizontal</enum>
+         </property>
+         <widget class="QWidget" name="layoutWidget">
+          <layout class="QVBoxLayout" name="verticalLayout_columns">
+           <item>
+            <widget class="QLabel" name="label_7">
+             <property name="text">
+              <string>Optional data to include:</string>
+             </property>
+            </widget>
+           </item>
+           <item>
+            <widget class="QListWidget" name="optional_columns"/>
+           </item>
+          </layout>
+         </widget>
+         <widget class="QWidget" name="layoutWidget1">
+          <layout class="QVBoxLayout" name="verticalLayout_settings">
+           <item>
+            <widget class="QGroupBox" name="groupBox_page_layout">
+             <property name="title">
+              <string>PDF Page layout</string>
+             </property>
+             <layout class="QGridLayout" name="gridLayout">
+              <item row="3" column="0">
+               <widget class="QLabel" name="label_columns">
+                <property name="text">
+                 <string>Columns:</string>
+                </property>
+               </widget>
+              </item>
+              <item row="4" column="0">
+               <widget class="QLabel" name="label_font_size">
+                <property name="text">
+                 <string>Font size:</string>
+                </property>
+               </widget>
+              </item>
+              <item row="0" column="0">
+               <widget class="QLabel" name="label_pagesize">
+                <property name="text">
+                 <string>Page size:</string>
+                </property>
+               </widget>
+              </item>
+              <item row="1" column="1">
+               <widget class="QComboBox" name="comboBox_pdf_orientation"/>
+              </item>
+              <item row="4" column="1">
+               <widget class="QSpinBox" name="spinBox_pdf_font_size">
+                <property name="minimum">
+                 <number>7</number>
+                </property>
+                <property name="maximum">
+                 <number>20</number>
+                </property>
+                <property name="value">
+                 <number>10</number>
+                </property>
+               </widget>
+              </item>
+              <item row="3" column="1">
+               <widget class="QSpinBox" name="spinBox_pdf_columns">
+                <property name="minimum">
+                 <number>1</number>
+                </property>
+                <property name="maximum">
+                 <number>4</number>
+                </property>
+               </widget>
+              </item>
+              <item row="1" column="0">
+               <widget class="QLabel" name="label_orientation">
+                <property name="text">
+                 <string>Orientation:</string>
+                </property>
+               </widget>
+              </item>
+              <item row="0" column="1">
+               <widget class="QComboBox" name="comboBox_pdf_pagesize"/>
+              </item>
+              <item row="2" column="1">
+               <widget class="QSpinBox" name="spinBox_pdf_margin">
+                <property name="minimum">
+                 <number>10</number>
+                </property>
+                <property name="maximum">
+                 <number>30</number>
+                </property>
+                <property name="singleStep">
+                 <number>5</number>
+                </property>
+                <property name="value">
+                 <number>20</number>
+                </property>
+               </widget>
+              </item>
+              <item row="2" column="0">
+               <widget class="QLabel" name="label_margin">
+                <property name="text">
+                 <string>Margins:</string>
+                </property>
+               </widget>
+              </item>
+              <item row="5" column="1">
+               <spacer name="verticalSpacer_3">
+                <property name="orientation">
+                 <enum>Qt::Orientation::Vertical</enum>
+                </property>
+                <property name="sizeHint" stdset="0">
+                 <size>
+                  <width>20</width>
+                  <height>40</height>
+                 </size>
+                </property>
+               </spacer>
+              </item>
+             </layout>
+            </widget>
+           </item>
+          </layout>
+         </widget>
+        </widget>
+       </item>
+      </layout>
+     </widget>
+     <widget class="QWidget" name="tab_json">
+      <attribute name="title">
+       <string>JSON Report</string>
+      </attribute>
+      <layout class="QVBoxLayout" name="verticalLayout_4">
+       <item>
+        <layout class="QHBoxLayout" name="horizontalLayout">
+         <item>
+          <widget class="QLabel" name="label_json_indent">
+           <property name="text">
+            <string>Indent:</string>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <widget class="QSpinBox" name="spinBox_json_indent">
+           <property name="minimum">
+            <number>2</number>
+           </property>
+           <property name="maximum">
+            <number>4</number>
+           </property>
+           <property name="singleStep">
+            <number>2</number>
+           </property>
+           <property name="value">
+            <number>4</number>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <spacer name="horizontalSpacer">
+           <property name="orientation">
+            <enum>Qt::Orientation::Horizontal</enum>
+           </property>
+           <property name="sizeHint" stdset="0">
+            <size>
+             <width>40</width>
+             <height>20</height>
+            </size>
+           </property>
+          </spacer>
+         </item>
+        </layout>
+       </item>
+       <item>
+        <spacer name="verticalSpacer_2">
+         <property name="orientation">
+          <enum>Qt::Orientation::Vertical</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>20</width>
+           <height>40</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+      </layout>
+     </widget>
+    </widget>
+   </item>
+   <item>
+    <spacer name="verticalSpacer">
+     <property name="orientation">
+      <enum>Qt::Orientation::Vertical</enum>
+     </property>
+     <property name="sizeHint" stdset="0">
+      <size>
+       <width>20</width>
+       <height>40</height>
+      </size>
+     </property>
+    </spacer>
+   </item>
+   <item>
+    <widget class="QDialogButtonBox" name="buttonBox">
+     <property name="orientation">
+      <enum>Qt::Orientation::Horizontal</enum>
+     </property>
+     <property name="standardButtons">
+      <set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set>
+     </property>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources>
+  <include location="../resources/resources.qrc"/>
+ </resources>
+ <connections>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>accepted()</signal>
+   <receiver>Dialog</receiver>
+   <slot>accept()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>248</x>
+     <y>254</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>157</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>rejected()</signal>
+   <receiver>Dialog</receiver>
+   <slot>reject()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>316</x>
+     <y>260</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>286</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+</ui>
diff --git a/src/usdb_syncer/gui/mw.py b/src/usdb_syncer/gui/mw.py
index 5be1f054..8bed044e 100644
--- a/src/usdb_syncer/gui/mw.py
+++ b/src/usdb_syncer/gui/mw.py
@@ -1,6 +1,5 @@
 """usdb_syncer's GUI"""
 
-import datetime
 import os
 import webbrowser
 from collections.abc import Callable
@@ -18,18 +17,17 @@
 from usdb_syncer.gui.forms.MainWindow import Ui_MainWindow
 from usdb_syncer.gui.meta_tags_dialog import MetaTagsDialog
 from usdb_syncer.gui.progress import run_with_progress
+from usdb_syncer.gui.report_dialog import ReportDialog
 from usdb_syncer.gui.search_tree.tree import FilterTree
 from usdb_syncer.gui.settings_dialog import SettingsDialog
 from usdb_syncer.gui.song_table.song_table import SongTable
 from usdb_syncer.gui.usdb_login_dialog import UsdbLoginDialog
-from usdb_syncer.json_export import generate_song_json
 from usdb_syncer.logger import logger
-from usdb_syncer.pdf import generate_song_pdf
 from usdb_syncer.song_loader import DownloadManager
 from usdb_syncer.sync_meta import SyncMeta
 from usdb_syncer.usdb_scraper import post_song_rating
 from usdb_syncer.usdb_song import UsdbSong
-from usdb_syncer.utils import AppPaths, LinuxEnvCleaner, open_file_explorer
+from usdb_syncer.utils import AppPaths, LinuxEnvCleaner, open_path_or_file
 
 
 class MainWindow(Ui_MainWindow, QMainWindow):
@@ -97,11 +95,13 @@ def _setup_toolbar(self) -> None:
                 lambda: SettingsDialog(self, self.table.current_song()).show(),
             ),
             (self.action_about, lambda: AboutDialog(self).show()),
-            (self.action_generate_song_pdf, self._generate_song_pdf),
-            (self.action_generate_song_json, self._generate_song_json),
+            (
+                self.action_generate_song_list,
+                lambda: ReportDialog(self, self.table).show(),
+            ),
             (self.action_import_usdb_ids, self._import_usdb_ids_from_files),
             (self.action_export_usdb_ids, self._export_usdb_ids_to_file),
-            (self.action_show_log, lambda: open_file_explorer(AppPaths.log.parent)),
+            (self.action_show_log, lambda: open_path_or_file(AppPaths.log.parent)),
             (self.action_show_in_usdb, self._show_current_song_in_usdb),
             (self.action_post_comment_in_usdb, self._show_comment_dialog),
             (self.action_rate_1star, lambda: self._rate_in_usdb(1)),
@@ -237,21 +237,6 @@ def on_done(result: progress.Result[None]) -> None:
 
         run_with_progress("Reading meta files ...", task=task, on_done=on_done)
 
-    def _generate_song_pdf(self) -> None:
-        fname = f"{datetime.datetime.now():%Y-%m-%d}_songlist.pdf"
-        path = os.path.join(settings.get_song_dir(), fname)
-        path = QFileDialog.getSaveFileName(self, dir=path, filter="PDF (*.pdf)")[0]
-        if path:
-            generate_song_pdf(db.all_local_usdb_songs(), path)
-
-    def _generate_song_json(self) -> None:
-        fname = f"{datetime.datetime.now():%Y-%m-%d}_songlist.json"
-        path = os.path.join(settings.get_song_dir(), fname)
-        path = QFileDialog.getSaveFileName(self, dir=path, filter="JSON (*.json)")[0]
-        if path:
-            num_of_songs = generate_song_json(db.all_local_usdb_songs(), Path(path))
-            logger.info(f"exported {num_of_songs} songs to {path}")
-
     def _import_usdb_ids_from_files(self) -> None:
         file_list = QFileDialog.getOpenFileNames(
             self,
@@ -325,7 +310,7 @@ def _open_current_song(self, action: Callable[[Path], None]) -> None:
             logger.info("No current song.")
 
     def _open_current_song_folder(self) -> None:
-        self._open_current_song(open_file_explorer)
+        self._open_current_song(open_path_or_file)
 
     def _open_current_song_in_app(self, app: settings.SupportedApps) -> None:
         self._open_current_song(lambda path: settings.SupportedApps.open_app(app, path))
diff --git a/src/usdb_syncer/gui/report_dialog.py b/src/usdb_syncer/gui/report_dialog.py
new file mode 100644
index 00000000..fb7e3150
--- /dev/null
+++ b/src/usdb_syncer/gui/report_dialog.py
@@ -0,0 +1,204 @@
+"""Dialog to create a report."""
+
+import datetime
+import os
+from collections.abc import Iterable
+from pathlib import Path
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+    QAbstractItemView,
+    QDialog,
+    QFileDialog,
+    QListWidgetItem,
+    QMessageBox,
+    QWidget,
+)
+
+from usdb_syncer import SongId, db, settings, utils
+from usdb_syncer.gui import progress
+from usdb_syncer.gui.forms.ReportDialog import Ui_Dialog
+from usdb_syncer.gui.progress import run_with_progress
+from usdb_syncer.gui.song_table.column import Column
+from usdb_syncer.gui.song_table.song_table import SongTable
+from usdb_syncer.json_export import generate_report_json
+from usdb_syncer.logger import logger
+from usdb_syncer.pdf import generate_report_pdf
+
+optional_columns = [
+    Column.LANGUAGE,
+    Column.EDITION,
+    Column.GENRE,
+    Column.YEAR,
+    Column.CREATOR,
+    Column.SONG_ID,
+]
+
+
+class ReportDialog(Ui_Dialog, QDialog):
+    """Dialog to create a report."""
+
+    def __init__(self, parent: QWidget, song_table: SongTable) -> None:
+        super().__init__(parent=parent)
+        self.song_table = song_table
+        self.setupUi(self)
+        self._populate_comboboxes()
+        self._populate_optional_columns()
+        self._load_settings()
+
+    def _populate_optional_columns(self) -> None:
+        for column in optional_columns:
+            item = QListWidgetItem()
+            item.setData(Qt.ItemDataRole.UserRole, column)
+            if icon := column.decoration_data():
+                item.setIcon(icon)
+            if name := column.display_data():
+                item.setText(name)
+            item.setCheckState(Qt.CheckState.Unchecked)
+            if column in [Column.ARTIST, Column.TITLE]:
+                item.setCheckState(Qt.CheckState.Checked)
+            item.setFlags(
+                Qt.ItemFlag.ItemIsEnabled
+                | Qt.ItemFlag.ItemIsUserCheckable
+                | Qt.ItemFlag.ItemIsSelectable
+                | Qt.ItemFlag.ItemIsDragEnabled
+            )
+            self.optional_columns.addItem(item)
+        self.optional_columns.setDragEnabled(True)
+        self.optional_columns.setDragDropMode(
+            QAbstractItemView.DragDropMode.InternalMove
+        )
+
+    def _populate_comboboxes(self) -> None:
+        combobox_settings = (
+            (self.comboBox_pdf_pagesize, settings.ReportPDFPagesize),
+            (self.comboBox_pdf_orientation, settings.ReportPDFOrientation),
+        )
+        for combobox, setting in combobox_settings:
+            for item in setting:
+                combobox.addItem(str(item), item)
+
+    def _load_settings(self) -> None:
+        self.comboBox_pdf_pagesize.setCurrentIndex(
+            self.comboBox_pdf_pagesize.findData(settings.get_report_pdf_pagesize())
+        )
+        self.comboBox_pdf_orientation.setCurrentIndex(
+            self.comboBox_pdf_orientation.findData(
+                settings.get_report_pdf_orientation()
+            )
+        )
+        self.spinBox_pdf_margin.setValue(settings.get_report_pdf_margin())
+        self.spinBox_pdf_columns.setValue(settings.get_report_pdf_columns())
+        self.spinBox_pdf_font_size.setValue(settings.get_report_pdf_fontsize())
+        self.spinBox_json_indent.setValue(settings.get_report_json_indent())
+
+    def accept(self) -> None:
+        self._save_settings()
+        if self.tabWidget_report_type.currentIndex() == 0:
+            if self._generate_report_pdf():
+                super().accept()
+        elif self.tabWidget_report_type.currentIndex() == 1:
+            if self._generate_report_json():
+                super().accept()
+
+    def reject(self) -> None:
+        self._save_settings()
+        super().reject()
+
+    def _save_settings(self) -> None:
+        settings.set_report_pdf_pagesize(self.comboBox_pdf_pagesize.currentData())
+        settings.set_report_pdf_orientation(self.comboBox_pdf_orientation.currentData())
+        settings.set_report_pdf_margin(self.spinBox_pdf_margin.value())
+        settings.set_report_pdf_columns(self.spinBox_pdf_columns.value())
+        settings.set_report_pdf_fontsize(self.spinBox_pdf_font_size.value())
+        settings.set_report_json_indent(self.spinBox_json_indent.value())
+
+    def _generate_report_pdf(self) -> bool:
+        def on_done(result: progress.Result) -> None:
+            path = result.result()
+            logger.info(f"PDF report created at {path}.")
+            utils.open_path_or_file(path)
+
+        songs: Iterable[SongId] = []
+        if self.radioButton_locally_available_songs.isChecked():
+            songs = db.all_local_usdb_songs()
+        if self.radioButton_selected_songs.isChecked():
+            songs = []
+            for song in self.song_table.selected_songs():
+                songs.append(song.song_id)
+        elif self.radioButton_all_songs.isChecked():
+            songs = db.all_song_ids()
+        if not songs:
+            msg = "Skipping PDF report creation: no songs match the selection."
+            QMessageBox.information(self, "Empty song list", msg)
+            logger.info(msg)
+            return False
+        fname = f"{datetime.datetime.now():%Y-%m-%d}_songlist.pdf"
+        path = os.path.join(settings.get_song_dir(), fname)
+        path = QFileDialog.getSaveFileName(self, dir=path, filter="PDF (*.pdf)")[0]
+        if path:
+            pagesize = self.comboBox_pdf_pagesize.currentData()
+            orientation = self.comboBox_pdf_orientation.currentData()
+            margin = self.spinBox_pdf_margin.value()
+            column_count = self.spinBox_pdf_columns.value()
+            base_font_size = self.spinBox_pdf_font_size.value()
+            optional_info = []
+            for index in range(self.optional_columns.count()):
+                item: QListWidgetItem = self.optional_columns.item(index)
+                if item.checkState() == Qt.CheckState.Checked:
+                    optional_info.append(item.data(Qt.ItemDataRole.UserRole))
+            run_with_progress(
+                "Creating PDF report ...",
+                lambda: generate_report_pdf(
+                    songs=songs,
+                    path=path,
+                    size=pagesize,
+                    orientation=orientation,
+                    margin=margin,
+                    column_count=column_count,
+                    base_font_size=base_font_size,
+                    optional_info=optional_info,
+                ),
+                on_done=on_done,
+            )
+            return True
+        # dialog is hidden by main window on macOS if file picker was cancelled
+        self.raise_()
+        return False
+
+    def _generate_report_json(self) -> bool:
+        def on_done(result: progress.Result) -> None:
+            path, num_of_songs = result.result()
+            logger.info(f"JSON report created at {path} ({num_of_songs} songs).")
+
+        songs: Iterable[SongId] = []
+        if self.radioButton_locally_available_songs.isChecked():
+            songs = db.all_local_usdb_songs()
+        if self.radioButton_selected_songs.isChecked():
+            songs = []
+            for song in self.song_table.selected_songs():
+                songs.append(song.song_id)
+        elif self.radioButton_all_songs.isChecked():
+            songs = db.all_song_ids()
+        songs = db.all_local_usdb_songs()
+        if not songs:
+            msg = "Skipping JSON report creation: no songs match the selection."
+            QMessageBox.information(self, "Empty song list", msg)
+            logger.info(msg)
+            return False
+        fname = f"{datetime.datetime.now():%Y-%m-%d}_songlist.json"
+        path = os.path.join(settings.get_song_dir(), fname)
+        path = QFileDialog.getSaveFileName(self, dir=path, filter="JSON (*.json)")[0]
+        indent = self.spinBox_json_indent.value()
+        if path:
+            run_with_progress(
+                "Creating JSON report ...",
+                lambda: generate_report_json(
+                    songs=songs, path=Path(path), indent=indent
+                ),
+                on_done=on_done,
+            )
+            return True
+        # dialog is hidden by main window on macOS if file picker was cancelled
+        self.raise_()
+        return False
diff --git a/src/usdb_syncer/gui/resources/NotoSans-Black.ttf b/src/usdb_syncer/gui/resources/NotoSans-Black.ttf
new file mode 100644
index 00000000..d5a6e0d1
Binary files /dev/null and b/src/usdb_syncer/gui/resources/NotoSans-Black.ttf differ
diff --git a/src/usdb_syncer/gui/resources/NotoSans-Bold.ttf b/src/usdb_syncer/gui/resources/NotoSans-Bold.ttf
new file mode 100644
index 00000000..506f7d84
Binary files /dev/null and b/src/usdb_syncer/gui/resources/NotoSans-Bold.ttf differ
diff --git a/src/usdb_syncer/gui/resources/NotoSans-Regular.ttf b/src/usdb_syncer/gui/resources/NotoSans-Regular.ttf
new file mode 100644
index 00000000..4bac02f2
Binary files /dev/null and b/src/usdb_syncer/gui/resources/NotoSans-Regular.ttf differ
diff --git a/src/usdb_syncer/gui/resources/document-code.png b/src/usdb_syncer/gui/resources/document-code.png
new file mode 100644
index 00000000..4ca303f0
Binary files /dev/null and b/src/usdb_syncer/gui/resources/document-code.png differ
diff --git a/src/usdb_syncer/gui/resources/document-pdf-text.png b/src/usdb_syncer/gui/resources/document-pdf-text.png
new file mode 100644
index 00000000..ca704c29
Binary files /dev/null and b/src/usdb_syncer/gui/resources/document-pdf-text.png differ
diff --git a/src/usdb_syncer/gui/resources/resources.qrc b/src/usdb_syncer/gui/resources/resources.qrc
index 75ab9e06..0850e814 100644
--- a/src/usdb_syncer/gui/resources/resources.qrc
+++ b/src/usdb_syncer/gui/resources/resources.qrc
@@ -23,8 +23,10 @@
     <file>control-play.png</file>
     <file>cover.png</file>
     <file>database.png</file>
+    <file>document-code.png</file>
     <file>document-export.png</file>
     <file>document-import.png</file>
+    <file>document-pdf-text.png</file>
     <file>drawer.png</file>
     <file>drive-download.png</file>
     <file>edge.png</file>
diff --git a/src/usdb_syncer/gui/song_table/column.py b/src/usdb_syncer/gui/song_table/column.py
index 0e0dd9cd..c8eaddc6 100644
--- a/src/usdb_syncer/gui/song_table/column.py
+++ b/src/usdb_syncer/gui/song_table/column.py
@@ -38,6 +38,8 @@ class Column(IntEnum):
 
     def display_data(self) -> str | None:
         match self:
+            case Column.SONG_ID:
+                return "ID"
             case Column.ARTIST:
                 return "Artist"
             case Column.TITLE:
@@ -58,7 +60,6 @@ def display_data(self) -> str | None:
                 return "Status"
             case (
                 Column.SAMPLE_URL
-                | Column.SONG_ID
                 | Column.GOLDEN_NOTES
                 | Column.RATING
                 | Column.VIEWS
diff --git a/src/usdb_syncer/json_export.py b/src/usdb_syncer/json_export.py
index 0aab5d3d..9a68e708 100644
--- a/src/usdb_syncer/json_export.py
+++ b/src/usdb_syncer/json_export.py
@@ -110,8 +110,12 @@ def default(self, o: Any) -> Any:
         return super().default(o)
 
 
-def generate_song_json(songs: Iterable[SongId], path: Path) -> int:
+def generate_report_json(
+    songs: Iterable[SongId], path: Path, indent: int = 4
+) -> tuple[Path, int]:
     content = JsonSongList.from_songs(songs=songs, date=datetime.datetime.now())
     with path.open("w", encoding="utf8") as file:
-        json.dump(content, file, cls=JsonSongListEncoder, ensure_ascii=False)
-    return len(content.songs)
+        json.dump(
+            content, file, cls=JsonSongListEncoder, indent=indent, ensure_ascii=False
+        )
+    return path, len(content.songs)
diff --git a/src/usdb_syncer/path_template.py b/src/usdb_syncer/path_template.py
index 01de8b6f..e3946bad 100644
--- a/src/usdb_syncer/path_template.py
+++ b/src/usdb_syncer/path_template.py
@@ -8,7 +8,6 @@
 from typing import TYPE_CHECKING, assert_never
 
 import attrs
-from unidecode import unidecode
 
 from usdb_syncer import errors, utils
 from usdb_syncer.custom_data import CustomData
@@ -171,10 +170,10 @@ def evaluate(self, song: UsdbSong) -> str:
                     return song.artist
                 return UNKNOWN_PLACEHOLDER_STRING
             case PathTemplatePlaceholder.ARTIST_INITIAL:
-                for char in song.artist:
-                    if char.isalnum():
-                        return unidecode(char)[0].upper()
-                return UNKNOWN_PLACEHOLDER_STRING
+                return (
+                    utils.get_first_alphanum_upper(song.artist)
+                    or UNKNOWN_PLACEHOLDER_STRING
+                )
             case PathTemplatePlaceholder.TITLE:
                 if song.title and len(song.title) > 0:
                     return song.title
diff --git a/src/usdb_syncer/pdf.py b/src/usdb_syncer/pdf.py
index 24ae9885..984c5efe 100644
--- a/src/usdb_syncer/pdf.py
+++ b/src/usdb_syncer/pdf.py
@@ -1,40 +1,237 @@
 """Generates a PDF from the passed song list."""
 
-# maybe reportlab is better suited?
-import datetime
+from collections import defaultdict
 from collections.abc import Iterable
-from typing import Any
+from dataclasses import dataclass
+from typing import assert_never
 
-from pdfme import build_pdf
+from reportlab.lib import colors
+from reportlab.lib.pagesizes import A3, A4, A5, LEGAL, LETTER, landscape
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.lib.units import mm
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.ttfonts import TTFont
+from reportlab.pdfgen.canvas import Canvas
+from reportlab.platypus import BaseDocTemplate, Flowable, Frame, PageTemplate, Paragraph
 
-from usdb_syncer import SongId
+from usdb_syncer import SongId, utils
+from usdb_syncer.gui.song_table.column import Column
+from usdb_syncer.settings import ReportPDFOrientation, ReportPDFPagesize
 from usdb_syncer.usdb_song import UsdbSong
 
+NOTOSANS_BLACK = "NotoSans-Black"
+NOTOSANS_BOLD = "NotoSans-Bold"
+NOTOSANS_REGULAR = "NotoSans-Regular"
 
-def generate_song_pdf(songs: Iterable[SongId], path: str) -> None:
-    document: dict[str, Any] = {}
-    document["style"] = {"margin_bottom": 15, "text_align": "j"}
-    document["formats"] = {"url": {"c": "blue", "u": 1}, "title": {"b": 1, "s": 13}}
-    document["sections"] = []
-    section1: dict[str, list[Any]] = {}
-    document["sections"].append(section1)
-    content1: list[Any] = []
-    section1["content"] = content1
-    date = datetime.datetime.now()
-    content1.append(
-        {
-            ".": f"Songlist ({date:%Y-%m-%d})",
-            "style": "title",
-            "label": "title1",
-            "outline": {"level": 1, "text": "A different title 1"},
-        }
+pdfmetrics.registerFont(
+    TTFont(NOTOSANS_BLACK, utils.AppPaths.resources.joinpath(f"{NOTOSANS_BLACK}.ttf"))
+)
+pdfmetrics.registerFont(
+    TTFont(NOTOSANS_BOLD, utils.AppPaths.resources.joinpath(f"{NOTOSANS_BOLD}.ttf"))
+)
+pdfmetrics.registerFont(
+    TTFont(
+        NOTOSANS_REGULAR, utils.AppPaths.resources.joinpath(f"{NOTOSANS_REGULAR}.ttf")
     )
+)
 
+
+class Bookmark(Flowable):
+    """Bookmark flowable."""
+
+    def __init__(self, key: str, title: str, level: int = 0):
+        super().__init__()
+        self.key = key
+        self.title = title
+        self.level = level
+
+    def draw(self) -> None:
+        self.canv.bookmarkPage(self.key)
+        self.canv.addOutlineEntry(self.title, self.key, level=self.level, closed=False)
+
+
+def generate_report_pdf(
+    *,
+    songs: Iterable[SongId],
+    path: str,
+    size: ReportPDFPagesize = ReportPDFPagesize.A4,
+    orientation: ReportPDFOrientation = ReportPDFOrientation.PORTRAIT,
+    margin: int = 20,
+    column_count: int = 1,
+    base_font_size: int = 10,
+    optional_info: list[Column] | None = None,
+) -> str:
+    optional_info = optional_info or []
+    pagesize = _get_pagesize(size, orientation)
+
+    doc: BaseDocTemplate = BaseDocTemplate(
+        path,
+        pagesize=pagesize,
+        leftMargin=margin * mm,
+        rightMargin=margin * mm,
+        topMargin=margin * mm,
+        bottomMargin=margin * mm,
+    )
+
+    custom_styles = _create_paragraph_styles(base_font_size)
+    content = _build_pdf_content(songs, base_font_size, custom_styles, optional_info)
+
+    gutter = margin * mm * 0.5
+    frames = _create_column_frames(
+        pagesize, margin=margin * mm, gutter=gutter, column_count=column_count
+    )
+    template = PageTemplate(
+        id="with_page_number", frames=frames, onPage=_add_page_number
+    )
+    doc.addPageTemplates([template])
+    doc.build(content)
+
+    return path
+
+
+def _get_pagesize(
+    size: ReportPDFPagesize, orientation: ReportPDFOrientation
+) -> tuple[float, float]:
+    match size:
+        case ReportPDFPagesize.A3:
+            pagesize = A3
+        case ReportPDFPagesize.A4:
+            pagesize = A4
+        case ReportPDFPagesize.A5:
+            pagesize = A5
+        case ReportPDFPagesize.LEGAL:
+            pagesize = LEGAL
+        case ReportPDFPagesize.LETTER:
+            pagesize = LETTER
+        case _ as unreachable:
+            assert_never(unreachable)
+    return (
+        landscape(pagesize)
+        if orientation == ReportPDFOrientation.LANDSCAPE
+        else pagesize
+    )
+
+
+@dataclass(frozen=True)
+class ParagraphStyles:
+    """Paragraph styles for the report."""
+
+    initial: ParagraphStyle
+    artist: ParagraphStyle
+    entry: ParagraphStyle
+
+
+def _create_paragraph_styles(base_font_size: int) -> ParagraphStyles:
+    return ParagraphStyles(
+        initial=ParagraphStyle(
+            "Initial",
+            fontName=NOTOSANS_BLACK,
+            fontSize=base_font_size * 3,
+            textColor=colors.green,
+            spaceBefore=base_font_size * 2.4,
+            spaceAfter=base_font_size * 2.4,
+        ),
+        artist=ParagraphStyle(
+            "Artist",
+            fontName=NOTOSANS_BOLD,
+            fontSize=base_font_size * 1.2,
+            spaceBefore=base_font_size * 1.2,
+            leading=base_font_size * 1.6,
+            textColor=colors.black,
+            leftIndent=0,
+        ),
+        entry=ParagraphStyle(
+            "Entry",
+            fontName=NOTOSANS_REGULAR,
+            fontSize=base_font_size,
+            leftIndent=base_font_size,
+            leading=base_font_size * 1.4,
+        ),
+    )
+
+
+def _build_pdf_content(
+    songs: Iterable[SongId],
+    base_font_size: int,
+    styles: ParagraphStyles,
+    optional_info: list[Column],
+) -> list:
+    content: list = []
+    artist_map: dict[str, list[UsdbSong]] = defaultdict(list)
     for song_id in songs:
         song = UsdbSong.get(song_id)
         if song:
-            data = f"{song.song_id}\t\t{song.artist}\t\t{song.title}\t\t{song.language}"
-            content1.append([data.replace("’", "'")])
+            artist_map[song.artist].append(song)
+
+    initial_map: dict[str, dict[str, list[UsdbSong]]] = defaultdict(dict)
+    for artist, artist_songs in artist_map.items():
+        initial = utils.get_first_alphanum_upper(artist) or artist[0].upper()
+        initial_map[initial][artist] = sorted(
+            artist_songs, key=lambda s: s.title.lower()
+        )
+
+    for initial in sorted(initial_map.keys()):
+        bookmark_key = f"initial_{initial}"
+        content.append(Bookmark(bookmark_key, initial))
+        content.append(Paragraph(initial, styles.initial))
+        for artist in sorted(initial_map[initial].keys(), key=str.lower):
+            content.append(Paragraph(f"<b>{artist}</b>", styles.artist))
+            for song in initial_map[initial][artist]:
+                entry = _format_song_entry(song, base_font_size, optional_info)
+                content.append(Paragraph(entry, style=styles.entry))
+
+    return content
+
+
+def _format_song_entry(
+    song: UsdbSong, base_font_size: int, optional_info: list[Column]
+) -> str:
+    entry = f"{song.title} "
+    entry += f'<font size="{base_font_size*0.8}" color="{colors.grey.hexval()}">'
+    for column in optional_info:
+        match column:
+            case Column.LANGUAGE:
+                if song.language:
+                    entry += f"&nbsp;&nbsp;{song.language}"
+            case Column.EDITION:
+                if song.edition:
+                    entry += f"&nbsp;&nbsp;{song.edition}"
+            case Column.GENRE:
+                if song.genre:
+                    entry += f"&nbsp;&nbsp;{song.genre}"
+            case Column.YEAR:
+                if song.year:
+                    entry += f"&nbsp;&nbsp;{song.year}"
+            case Column.CREATOR:
+                if song.creator:
+                    entry += f"&nbsp;&nbsp;{song.creator}"
+            case Column.SONG_ID:
+                entry += f"&nbsp;&nbsp;({song.song_id})"
+            case _:
+                pass
+    entry += "</font>"
+    return entry
+
+
+def _add_page_number(canvas: Canvas, doc: BaseDocTemplate) -> None:
+    canvas.saveState()
+    page_num: str = str(doc.page)
+    canvas.setFont(NOTOSANS_REGULAR, 8)
+    canvas.setFillColor(colors.grey)
+    canvas.drawCentredString(doc.pagesize[0] / 2, doc.bottomMargin * 0.5, page_num)
+    canvas.restoreState()
+
+
+def _create_column_frames(
+    pagesize: tuple[float, float], margin: float, gutter: float, column_count: int
+) -> list[Frame]:
+    usable_width = pagesize[0] - 2 * margin - (column_count - 1) * gutter
+    column_width = usable_width / column_count
+    frames: list[Frame] = []
+
+    for i in range(column_count):
+        x = margin + i * (column_width + gutter)
+        frame = Frame(x, margin, column_width, pagesize[1] - 2 * margin, id=f"col{i+1}")
+        frames.append(frame)
 
-    with open(path, "wb") as file:
-        build_pdf(document, file)
+    return frames
diff --git a/src/usdb_syncer/settings.py b/src/usdb_syncer/settings.py
index 80575b60..0777e830 100644
--- a/src/usdb_syncer/settings.py
+++ b/src/usdb_syncer/settings.py
@@ -1,3 +1,5 @@
+# pylint: disable=too-many-lines
+
 """Persistent app settings.
 
 To ensure consistent default values and avoid key collisions, QSettings should never be
@@ -107,6 +109,12 @@ class SettingKey(Enum):
     APP_PATH_USDX = "app_paths/usdx"
     APP_PATH_VOCALUXE = "app_paths/vocaluxe"
     APP_PATH_YASS_RELOADED = "app_paths/yass_reloaded"
+    REPORT_PDF_PAGESIZE = "report/pdf_pagesize"
+    REPORT_PDF_ORIENTATION = "report/pdf_orientation"
+    REPORT_PDF_MARGIN = "report/pdf_margin"
+    REPORT_PDF_COLUMNS = "report/pdf_columns"
+    REPORT_PDF_FONTSIZE = "report/pdf_fontsize"
+    REPORT_JSON_INDENT = "report/json_indent"
 
 
 class Encoding(Enum):
@@ -640,6 +648,29 @@ def open_app(self, path: Path) -> None:
             logger.debug(traceback.format_exc())
 
 
+class ReportPDFPagesize(Enum):
+    """Supported PDF page sizes."""
+
+    A3 = "A3"
+    A4 = "A4"
+    A5 = "A5"
+    LETTER = "Letter"
+    LEGAL = "Legal"
+
+    def __str__(self) -> str:
+        return str(self.value)
+
+
+class ReportPDFOrientation(Enum):
+    """Supported PDF page orientations."""
+
+    PORTRAIT = "Portrait"
+    LANDSCAPE = "Landscape"
+
+    def __str__(self) -> str:
+        return str(self.value)
+
+
 T = TypeVar("T")
 
 
@@ -992,3 +1023,51 @@ def set_app_path(app: SupportedApps, path: str) -> None:
             set_setting(SettingKey.APP_PATH_YASS_RELOADED, path)
         case _ as unreachable:
             assert_never(unreachable)
+
+
+def get_report_pdf_pagesize() -> ReportPDFPagesize:
+    return get_setting(SettingKey.REPORT_PDF_PAGESIZE, ReportPDFPagesize.A4)
+
+
+def set_report_pdf_pagesize(pagesize: ReportPDFPagesize) -> None:
+    set_setting(SettingKey.REPORT_PDF_PAGESIZE, pagesize)
+
+
+def get_report_pdf_orientation() -> ReportPDFOrientation:
+    return get_setting(SettingKey.REPORT_PDF_ORIENTATION, ReportPDFOrientation.PORTRAIT)
+
+
+def set_report_pdf_orientation(orientation: ReportPDFOrientation) -> None:
+    set_setting(SettingKey.REPORT_PDF_ORIENTATION, orientation)
+
+
+def get_report_pdf_margin() -> int:
+    return get_setting(SettingKey.REPORT_PDF_MARGIN, 20)
+
+
+def set_report_pdf_margin(margin: int) -> None:
+    set_setting(SettingKey.REPORT_PDF_MARGIN, margin)
+
+
+def get_report_pdf_columns() -> int:
+    return get_setting(SettingKey.REPORT_PDF_COLUMNS, 2)
+
+
+def set_report_pdf_columns(columns: int) -> None:
+    set_setting(SettingKey.REPORT_PDF_COLUMNS, columns)
+
+
+def get_report_pdf_fontsize() -> int:
+    return get_setting(SettingKey.REPORT_PDF_FONTSIZE, 10)
+
+
+def set_report_pdf_fontsize(fontsize: int) -> None:
+    set_setting(SettingKey.REPORT_PDF_FONTSIZE, fontsize)
+
+
+def get_report_json_indent() -> int:
+    return get_setting(SettingKey.REPORT_JSON_INDENT, 4)
+
+
+def set_report_json_indent(indent: int) -> None:
+    set_setting(SettingKey.REPORT_JSON_INDENT, indent)
diff --git a/src/usdb_syncer/utils.py b/src/usdb_syncer/utils.py
index 530d34e2..ec47a245 100644
--- a/src/usdb_syncer/utils.py
+++ b/src/usdb_syncer/utils.py
@@ -16,6 +16,7 @@
 from appdirs import AppDirs
 from bs4 import BeautifulSoup, Tag
 from packaging import version
+from unidecode import unidecode
 
 from usdb_syncer import constants
 from usdb_syncer.logger import logger
@@ -94,6 +95,14 @@ def remove_ansi_codes(text: str) -> str:
     return ansi_escape.sub("", text)
 
 
+def get_first_alphanum_upper(text: str) -> str | None:
+    """Returns the first uppercase alphanumeric character in a string."""
+    for char in text:
+        if char.isalnum():
+            return unidecode(char)[0].upper()
+    return None
+
+
 class AppPaths:
     """App data paths."""
 
@@ -106,6 +115,7 @@ class AppPaths:
     sql = Path(root, "src", "usdb_syncer", "db", "sql")
     addons = Path(_app_dirs.user_data_dir, "addons")
     shared = Path(root, "shared")
+    resources = Path(root, "src", "usdb_syncer", "gui", "resources")
 
     @classmethod
     def make_dirs(cls) -> None:
@@ -238,7 +248,7 @@ def path_matches_maybe_with_suffix(path: Path, search: Path) -> bool:
     return is_name_maybe_with_suffix(path.name, search.name)
 
 
-def open_file_explorer(path: Path) -> None:
+def open_path_or_file(path: Path) -> None:
     logger.debug(f"Opening '{path}' with file explorer.")
     if sys.platform == "win32":
         os.startfile(path)


Reviews:
user: login: RumovZ
body: Unfortunately, it doesn't work for me. Here is the traceback after propagating the exception (see below):

```
2025-04-12 09:18:01 [ERROR] Uncaught exception:
    Traceback (most recent call last):
      File "C:\code\Python\usdb_syncer\src\usdb_syncer\gui\progress.py", line 87, in wrapped_on_done
    on_done(result)
      File "C:\code\Python\usdb_syncer\src\usdb_syncer\gui\report_dialog.py", line 109, in on_done
    result.result()
      File "C:\code\Python\usdb_syncer\src\usdb_syncer\gui\progress.py", line 36, in result
    raise self._result.error
      File "C:\code\Python\usdb_syncer\src\usdb_syncer\gui\progress.py", line 79, in wrapped_task
    result = Result(task())
                    ^^^^^^
      File "C:\code\Python\usdb_syncer\src\usdb_syncer\gui\report_dialog.py", line 139, in <lambda>
    lambda: generate_report_pdf(
            ^^^^^^^^^^^^^^^^^^^^
      File "C:\code\Python\usdb_syncer\src\usdb_syncer\pdf.py", line 80, in generate_report_pdf
    doc.build(content)
      File "~\AppData\Local\pypoetry\Cache\virtualenvs\usdb-syncer-VPHy5iGw-py3.12\Lib\site-packages\reportlab\platypus\doctemplate.py", line 1109, in build
    self._endBuild()
      File "~\AppData\Local\pypoetry\Cache\virtualenvs\usdb-syncer-VPHy5iGw-py3.12\Lib\site-packages\reportlab\platypus\doctemplate.py", line 1044, in _endBuild
    if getattr(self,'_doSave',1): self.canv.save()
                                  ^^^^^^^^^^^^^^^^
      File "~\AppData\Local\pypoetry\Cache\virtualenvs\usdb-syncer-VPHy5iGw-py3.12\Lib\site-packages\reportlab\pdfgen\canvas.py", line 1301, in save
    self._doc.SaveToFile(self._filename, self)
      File "~\AppData\Local\pypoetry\Cache\virtualenvs\usdb-syncer-VPHy5iGw-py3.12\Lib\site-packages\reportlab\pdfbase\pdfdoc.py", line 212, in SaveToFile
    data = self.GetPDFData(canvas)
           ^^^^^^^^^^^^^^^^^^^^^^^
      File "~\AppData\Local\pypoetry\Cache\virtualenvs\usdb-syncer-VPHy5iGw-py3.12\Lib\site-packages\reportlab\pdfbase\pdfdoc.py", line 227, in GetPDFData
    fnt.addObjects(self)
      File "~\AppData\Local\pypoetry\Cache\virtualenvs\usdb-syncer-VPHy5iGw-py3.12\Lib\site-packages\reportlab\pdfbase\ttfonts.py", line 1336, in addObjects
    pdfFont.FontDescriptor = self.face.addSubsetObjects(doc, baseFontName, subset)
                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "~\AppData\Local\pypoetry\Cache\virtualenvs\usdb-syncer-VPHy5iGw-py3.12\Lib\site-packages\reportlab\pdfbase\ttfonts.py", line 1098, in addSubsetObjects
    fontFile.content = self.makeSubset(subset)
                       ^^^^^^^^^^^^^^^^^^^^^^^
      File "~\AppData\Local\pypoetry\Cache\virtualenvs\usdb-syncer-VPHy5iGw-py3.12\Lib\site-packages\reportlab\pdfbase\ttfonts.py", line 915, in makeSubset
    glyphPos = self.glyphPos[originalGlyphIdx]
               ~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
    IndexError: list index out of range
```
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-12T07:38:04Z
user: login: bohning
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-12T13:44:48Z
user: login: RumovZ
body: 🚀 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-13T06:15:35Z

Review comments:
diff_hunk: @@ -25,6 +25,7 @@ pdfme = "*"
 pillow = ">=10"
 poetry-dynamic-versioning = "^1.6.0"
 PySide6 = "6.8.0.2"
+reportlab = "*"
path: pyproject.toml
user: login: RumovZ
body: Can we drop pdfme then?
created_at: 2025-04-12T06:37:25Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,158 @@
+"""Dialog to create a report."""
+
+import datetime
+import os
+from collections.abc import Iterable
+from pathlib import Path
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+    QAbstractItemView,
+    QDialog,
+    QFileDialog,
+    QListWidgetItem,
+    QWidget,
+)
+
+from usdb_syncer import SongId, db, settings
+from usdb_syncer.gui import progress
+from usdb_syncer.gui.forms.ReportDialog import Ui_Dialog
+from usdb_syncer.gui.progress import run_with_progress
+from usdb_syncer.gui.song_table.column import Column
+from usdb_syncer.gui.song_table.song_table import SongTable
+from usdb_syncer.json_export import generate_report_json
+from usdb_syncer.logger import logger
+from usdb_syncer.pdf import generate_report_pdf
+
+optional_columns = [
+    Column.LANGUAGE,
+    Column.EDITION,
+    Column.GENRE,
+    Column.YEAR,
+    Column.CREATOR,
+    Column.SONG_ID,
+]
+
+
+class ReportDialog(Ui_Dialog, QDialog):
+    """Dialog to create a report."""
+
+    def __init__(self, parent: QWidget, song_table: SongTable) -> None:
+        super().__init__(parent=parent)
+        self.song_table = song_table
+        self.setupUi(self)
+        self._populate_comboboxes()
+        self._populate_optional_columns()
+        self._load_settings()
+        self._connect_signals()
+
+    def _populate_optional_columns(self) -> None:
+        for column in optional_columns:
+            item = QListWidgetItem()
+            item.setData(Qt.ItemDataRole.UserRole, column)
+            if icon := column.decoration_data():
+                item.setIcon(icon)
+            if name := column.display_data():
+                item.setText(name)
+            item.setCheckState(Qt.CheckState.Unchecked)
+            if column in [Column.ARTIST, Column.TITLE]:
+                item.setCheckState(Qt.CheckState.Checked)
+            item.setFlags(
+                Qt.ItemFlag.ItemIsEnabled
+                | Qt.ItemFlag.ItemIsUserCheckable
+                | Qt.ItemFlag.ItemIsSelectable
+                | Qt.ItemFlag.ItemIsDragEnabled
+            )
+            self.optional_columns.addItem(item)
+        self.optional_columns.setDragEnabled(True)
+        self.optional_columns.setDragDropMode(
+            QAbstractItemView.DragDropMode.InternalMove
+        )
+
+    def _populate_comboboxes(self) -> None:
+        combobox_settings = (
+            (self.comboBox_pagesize, settings.ReportPagesize),
+            (self.comboBox_orientation, settings.ReportOrientation),
+        )
+        for combobox, setting in combobox_settings:
+            for item in setting:
+                combobox.addItem(str(item), item)
+
+    def _load_settings(self) -> None:
+        self.comboBox_pagesize.setCurrentIndex(
+            self.comboBox_pagesize.findData(settings.get_report_pagesize())
+        )
+        self.comboBox_orientation.setCurrentIndex(
+            self.comboBox_orientation.findData(settings.get_report_orientation())
+        )
+        self.spinBox_margin.setValue(settings.get_report_margin())
+        self.spinBox_columns.setValue(settings.get_report_columns())
+        self.spinBox_font_size.setValue(settings.get_report_fontsize())
+
+    def _connect_signals(self) -> None:
+        self.pushButton_generate_report_pdf.pressed.connect(self._generate_report_pdf)
+        self.pushButton_generate_report_json.pressed.connect(self._generate_report_json)
+
+    def accept(self) -> None:
+        self._save_settings()
+        super().accept()
+
+    def _save_settings(self) -> None:
+        settings.set_report_pagesize(self.comboBox_pagesize.currentData())
+        settings.set_report_orientation(self.comboBox_orientation.currentData())
+        settings.set_report_margin(self.spinBox_margin.value())
+        settings.set_report_columns(self.spinBox_columns.value())
+        settings.set_report_fontsize(self.spinBox_font_size.value())
+
+    def _generate_report_pdf(self) -> None:
+        def on_done(_: progress.Result) -> None:
+            logger.info("PDF report created.")
path: src/usdb_syncer/gui/report_dialog.py
user: login: RumovZ
body: `Result.result()` must be called to propagate a possible exception to the main thread.
created_at: 2025-04-12T06:46:17Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,158 @@
+"""Dialog to create a report."""
+
+import datetime
+import os
+from collections.abc import Iterable
+from pathlib import Path
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+    QAbstractItemView,
+    QDialog,
+    QFileDialog,
+    QListWidgetItem,
+    QWidget,
+)
+
+from usdb_syncer import SongId, db, settings
+from usdb_syncer.gui import progress
+from usdb_syncer.gui.forms.ReportDialog import Ui_Dialog
+from usdb_syncer.gui.progress import run_with_progress
+from usdb_syncer.gui.song_table.column import Column
+from usdb_syncer.gui.song_table.song_table import SongTable
+from usdb_syncer.json_export import generate_report_json
+from usdb_syncer.logger import logger
+from usdb_syncer.pdf import generate_report_pdf
+
+optional_columns = [
+    Column.LANGUAGE,
+    Column.EDITION,
+    Column.GENRE,
+    Column.YEAR,
+    Column.CREATOR,
+    Column.SONG_ID,
+]
+
+
+class ReportDialog(Ui_Dialog, QDialog):
+    """Dialog to create a report."""
+
+    def __init__(self, parent: QWidget, song_table: SongTable) -> None:
+        super().__init__(parent=parent)
+        self.song_table = song_table
+        self.setupUi(self)
+        self._populate_comboboxes()
+        self._populate_optional_columns()
+        self._load_settings()
+        self._connect_signals()
+
+    def _populate_optional_columns(self) -> None:
+        for column in optional_columns:
+            item = QListWidgetItem()
+            item.setData(Qt.ItemDataRole.UserRole, column)
+            if icon := column.decoration_data():
+                item.setIcon(icon)
+            if name := column.display_data():
+                item.setText(name)
+            item.setCheckState(Qt.CheckState.Unchecked)
+            if column in [Column.ARTIST, Column.TITLE]:
+                item.setCheckState(Qt.CheckState.Checked)
+            item.setFlags(
+                Qt.ItemFlag.ItemIsEnabled
+                | Qt.ItemFlag.ItemIsUserCheckable
+                | Qt.ItemFlag.ItemIsSelectable
+                | Qt.ItemFlag.ItemIsDragEnabled
+            )
+            self.optional_columns.addItem(item)
+        self.optional_columns.setDragEnabled(True)
+        self.optional_columns.setDragDropMode(
+            QAbstractItemView.DragDropMode.InternalMove
+        )
+
+    def _populate_comboboxes(self) -> None:
+        combobox_settings = (
+            (self.comboBox_pagesize, settings.ReportPagesize),
+            (self.comboBox_orientation, settings.ReportOrientation),
+        )
+        for combobox, setting in combobox_settings:
+            for item in setting:
+                combobox.addItem(str(item), item)
+
+    def _load_settings(self) -> None:
+        self.comboBox_pagesize.setCurrentIndex(
+            self.comboBox_pagesize.findData(settings.get_report_pagesize())
+        )
+        self.comboBox_orientation.setCurrentIndex(
+            self.comboBox_orientation.findData(settings.get_report_orientation())
+        )
+        self.spinBox_margin.setValue(settings.get_report_margin())
+        self.spinBox_columns.setValue(settings.get_report_columns())
+        self.spinBox_font_size.setValue(settings.get_report_fontsize())
+
+    def _connect_signals(self) -> None:
+        self.pushButton_generate_report_pdf.pressed.connect(self._generate_report_pdf)
+        self.pushButton_generate_report_json.pressed.connect(self._generate_report_json)
+
+    def accept(self) -> None:
+        self._save_settings()
+        super().accept()
+
+    def _save_settings(self) -> None:
+        settings.set_report_pagesize(self.comboBox_pagesize.currentData())
+        settings.set_report_orientation(self.comboBox_orientation.currentData())
+        settings.set_report_margin(self.spinBox_margin.value())
+        settings.set_report_columns(self.spinBox_columns.value())
+        settings.set_report_fontsize(self.spinBox_font_size.value())
+
+    def _generate_report_pdf(self) -> None:
+        def on_done(_: progress.Result) -> None:
+            logger.info("PDF report created.")
+
+        fname = f"{datetime.datetime.now():%Y-%m-%d}_songlist.pdf"
+        path = os.path.join(settings.get_song_dir(), fname)
+        path = QFileDialog.getSaveFileName(self, dir=path, filter="PDF (*.pdf)")[0]
+        # dialog is hidden by main window on macOS if file picker was cancelled
+        # self.raise_()
path: src/usdb_syncer/gui/report_dialog.py
user: login: RumovZ
body: Is this necessary or not?
created_at: 2025-04-12T06:48:15Z
author_association: COLLABORATOR
diff_hunk: @@ -1,40 +1,217 @@
 """Generates a PDF from the passed song list."""
 
-# maybe reportlab is better suited?
-import datetime
+from collections import defaultdict
 from collections.abc import Iterable
-from typing import Any
+from typing import assert_never
 
-from pdfme import build_pdf
+from reportlab.lib import colors
+from reportlab.lib.pagesizes import A3, A4, A5, LEGAL, LETTER, landscape
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.lib.units import mm
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.ttfonts import TTFont
+from reportlab.pdfgen.canvas import Canvas
+from reportlab.platypus import BaseDocTemplate, Flowable, Frame, PageTemplate, Paragraph
 
 from usdb_syncer import SongId
+from usdb_syncer.gui.song_table.column import Column
+from usdb_syncer.settings import ReportOrientation, ReportPagesize
 from usdb_syncer.usdb_song import UsdbSong
 
+pdfmetrics.registerFont(
+    TTFont("NotoSans-Black", "./src/usdb_syncer/gui/resources/NotoSans-Black.ttf")
path: src/usdb_syncer/pdf.py
user: login: RumovZ
body: Please add this to .gitattributes so git doesn't mess with the binary files: `*.ttf -text`
created_at: 2025-04-12T06:55:08Z
author_association: COLLABORATOR
diff_hunk: @@ -1,40 +1,217 @@
 """Generates a PDF from the passed song list."""
 
-# maybe reportlab is better suited?
-import datetime
+from collections import defaultdict
 from collections.abc import Iterable
-from typing import Any
+from typing import assert_never
 
-from pdfme import build_pdf
+from reportlab.lib import colors
+from reportlab.lib.pagesizes import A3, A4, A5, LEGAL, LETTER, landscape
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.lib.units import mm
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.ttfonts import TTFont
+from reportlab.pdfgen.canvas import Canvas
+from reportlab.platypus import BaseDocTemplate, Flowable, Frame, PageTemplate, Paragraph
 
 from usdb_syncer import SongId
+from usdb_syncer.gui.song_table.column import Column
+from usdb_syncer.settings import ReportOrientation, ReportPagesize
 from usdb_syncer.usdb_song import UsdbSong
 
+pdfmetrics.registerFont(
+    TTFont("NotoSans-Black", "./src/usdb_syncer/gui/resources/NotoSans-Black.ttf")
+)
+pdfmetrics.registerFont(
+    TTFont("NotoSans-Bold", "./src/usdb_syncer/gui/resources/NotoSans-Bold.ttf")
+)
+pdfmetrics.registerFont(
+    TTFont("NotoSans-Regular", "./src/usdb_syncer/gui/resources/NotoSans-Regular.ttf")
+)
 
-def generate_song_pdf(songs: Iterable[SongId], path: str) -> None:
-    document: dict[str, Any] = {}
-    document["style"] = {"margin_bottom": 15, "text_align": "j"}
-    document["formats"] = {"url": {"c": "blue", "u": 1}, "title": {"b": 1, "s": 13}}
-    document["sections"] = []
-    section1: dict[str, list[Any]] = {}
-    document["sections"].append(section1)
-    content1: list[Any] = []
-    section1["content"] = content1
-    date = datetime.datetime.now()
-    content1.append(
-        {
-            ".": f"Songlist ({date:%Y-%m-%d})",
-            "style": "title",
-            "label": "title1",
-            "outline": {"level": 1, "text": "A different title 1"},
-        }
+
+class Bookmark(Flowable):
+    """Bookmark flowable."""
+
+    def __init__(self, key: str, title: str, level: int = 0):
+        super().__init__()
+        self.key = key
+        self.title = title
+        self.level = level
+
+    def draw(self) -> None:
+        self.canv.bookmarkPage(self.key)
+        self.canv.addOutlineEntry(self.title, self.key, level=self.level, closed=False)
+
+
+def generate_report_pdf(
+    *,
+    songs: Iterable[SongId],
+    path: str,
+    size: ReportPagesize = ReportPagesize.A4,
+    orientation: ReportOrientation = ReportOrientation.PORTRAIT,
+    margin: int = 20,
+    column_count: int = 1,
+    base_font_size: int = 10,
+    optional_info: list[Column] | None = None,
+) -> None:
+    optional_info = optional_info or []
+    pagesize = _get_pagesize(size, orientation)
+
+    doc: BaseDocTemplate = BaseDocTemplate(
+        path,
+        pagesize=pagesize,
+        leftMargin=margin * mm,
+        rightMargin=margin * mm,
+        topMargin=margin * mm,
+        bottomMargin=margin * mm,
+    )
+
+    custom_styles = _create_paragraph_styles(base_font_size)
+    content = _build_pdf_content(songs, base_font_size, custom_styles, optional_info)
+
+    gutter = margin * mm * 0.5
+    frames = _create_column_frames(
+        pagesize, margin=margin * mm, gutter=gutter, column_count=column_count
+    )
+    template = PageTemplate(
+        id="with_page_number", frames=frames, onPage=_add_page_number
+    )
+    doc.addPageTemplates([template])
+    doc.build(content)
+
+
+def _get_pagesize(
+    size: ReportPagesize, orientation: ReportOrientation
+) -> tuple[float, float]:
+    match size:
+        case ReportPagesize.A3:
+            pagesize = A3
+        case ReportPagesize.A4:
+            pagesize = A4
+        case ReportPagesize.A5:
+            pagesize = A5
+        case ReportPagesize.LEGAL:
+            pagesize = LEGAL
+        case ReportPagesize.LETTER:
+            pagesize = LETTER
+        case _ as unreachable:
+            assert_never(unreachable)
+    return (
+        landscape(pagesize) if orientation == ReportOrientation.LANDSCAPE else pagesize
     )
 
+
+def _create_paragraph_styles(base_font_size: int) -> dict[str, ParagraphStyle]:
+    return {
+        "Initial": ParagraphStyle(
+            "Initial",
+            fontName="NotoSans-Black",
+            fontSize=base_font_size * 3,
+            textColor=colors.green,
+            spaceBefore=base_font_size * 2.4,
+            spaceAfter=base_font_size * 2.4,
+        ),
+        "Artist": ParagraphStyle(
+            "Artist",
+            fontName="NotoSans-Bold",
+            fontSize=base_font_size * 1.2,
+            spaceBefore=base_font_size * 1.2,
+            leading=base_font_size * 1.6,
+            textColor=colors.black,
+            leftIndent=0,
+        ),
+        "Entry": ParagraphStyle(
+            "Entry",
+            fontName="NotoSans-Regular",
+            fontSize=base_font_size,
+            leftIndent=base_font_size,
+            leading=base_font_size * 1.4,
+        ),
+    }
+
+
+def _build_pdf_content(
+    songs: Iterable[SongId],
+    base_font_size: int,
+    styles: dict[str, ParagraphStyle],
+    optional_info: list[Column],
+) -> list:
+    content: list = []
+    artist_map: dict[str, list[UsdbSong]] = defaultdict(list)
     for song_id in songs:
         song = UsdbSong.get(song_id)
         if song:
-            data = f"{song.song_id}\t\t{song.artist}\t\t{song.title}\t\t{song.language}"
-            content1.append([data.replace("’", "'")])
+            artist_map[song.artist].append(song)
+
+    initial_map: dict[str, dict[str, list[UsdbSong]]] = defaultdict(dict)
+    for artist, artist_songs in artist_map.items():
+        initial = artist[0].upper()
+        initial_map[initial][artist] = sorted(
+            artist_songs, key=lambda s: s.title.lower()
+        )
+
+    for initial in sorted(initial_map.keys()):
+        bookmark_key = f"initial_{initial}"
+        content.append(Bookmark(bookmark_key, initial))
+        content.append(Paragraph(initial, styles["Initial"]))
+        for artist in sorted(initial_map[initial].keys(), key=str.lower):
+            content.append(Paragraph(f"<b>{artist}</b>", styles["Artist"]))
+            for song in initial_map[initial][artist]:
+                entry = _format_song_entry(song, base_font_size, optional_info)
+                content.append(Paragraph(entry, style=styles["Entry"]))
+
+    return content
+
+
+def _format_song_entry(
+    song: UsdbSong, base_font_size: int, optional_info: list[Column]
+) -> str:
+    entry = f"{song.title} "
+    entry += f'<font size="{base_font_size*0.8}" color="{colors.grey.hexval()}">'
+    for column in optional_info:
+        match column:
+            case Column.LANGUAGE:
+                if song.language:
+                    entry += f"&nbsp;&nbsp;{song.language}"
+            case Column.EDITION:
+                if song.edition:
+                    entry += f"&nbsp;&nbsp;{song.edition}"
+            case Column.GENRE:
+                if song.genre:
+                    entry += f"&nbsp;&nbsp;{song.genre}"
+            case Column.YEAR:
+                if song.year:
+                    entry += f"&nbsp;&nbsp;{song.year}"
+            case Column.CREATOR:
+                if song.creator:
+                    entry += f"&nbsp;&nbsp;{song.creator}"
+            case Column.SONG_ID:
+                entry += f"&nbsp;&nbsp;({song.song_id})"
+            case _:
+                pass
+    entry += "</font>"
+    return entry
+
+
+def _add_page_number(canvas: Canvas, doc: BaseDocTemplate) -> None:
+    canvas.saveState()
+    page_num: str = str(doc.page)
+    canvas.setFont("NotoSans-Regular", 8)
path: src/usdb_syncer/pdf.py
user: login: RumovZ
body: The font names are repeated multiple times and should be constants.
created_at: 2025-04-12T07:06:23Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,318 @@
+<?xml version="1.0" encoding="UTF-8"?>
path: src/usdb_syncer/gui/forms/ReportDialog.ui
user: login: RumovZ
body: I think the source selector should be available across all report kinds, and Ok should generate the report. You'd still have the tab widget to choose the report kind and specific settings. You'd also need a Cancel button.

At this point you can also add a check if the selector matches any songs at all.
created_at: 2025-04-12T07:12:47Z
author_association: COLLABORATOR
diff_hunk: @@ -1,40 +1,217 @@
 """Generates a PDF from the passed song list."""
 
-# maybe reportlab is better suited?
-import datetime
+from collections import defaultdict
 from collections.abc import Iterable
-from typing import Any
+from typing import assert_never
 
-from pdfme import build_pdf
+from reportlab.lib import colors
+from reportlab.lib.pagesizes import A3, A4, A5, LEGAL, LETTER, landscape
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.lib.units import mm
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.ttfonts import TTFont
+from reportlab.pdfgen.canvas import Canvas
+from reportlab.platypus import BaseDocTemplate, Flowable, Frame, PageTemplate, Paragraph
 
 from usdb_syncer import SongId
+from usdb_syncer.gui.song_table.column import Column
+from usdb_syncer.settings import ReportOrientation, ReportPagesize
 from usdb_syncer.usdb_song import UsdbSong
 
+pdfmetrics.registerFont(
+    TTFont("NotoSans-Black", "./src/usdb_syncer/gui/resources/NotoSans-Black.ttf")
path: src/usdb_syncer/pdf.py
user: login: RumovZ
body: Paths should not be hardcoded strings but Path objects in AppPaths (at least the parent folder).
created_at: 2025-04-12T07:25:48Z
author_association: COLLABORATOR
diff_hunk: @@ -1,40 +1,217 @@
 """Generates a PDF from the passed song list."""
 
-# maybe reportlab is better suited?
-import datetime
+from collections import defaultdict
 from collections.abc import Iterable
-from typing import Any
+from typing import assert_never
 
-from pdfme import build_pdf
+from reportlab.lib import colors
+from reportlab.lib.pagesizes import A3, A4, A5, LEGAL, LETTER, landscape
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.lib.units import mm
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.ttfonts import TTFont
+from reportlab.pdfgen.canvas import Canvas
+from reportlab.platypus import BaseDocTemplate, Flowable, Frame, PageTemplate, Paragraph
 
 from usdb_syncer import SongId
+from usdb_syncer.gui.song_table.column import Column
+from usdb_syncer.settings import ReportOrientation, ReportPagesize
 from usdb_syncer.usdb_song import UsdbSong
 
+pdfmetrics.registerFont(
+    TTFont("NotoSans-Black", "./src/usdb_syncer/gui/resources/NotoSans-Black.ttf")
+)
+pdfmetrics.registerFont(
+    TTFont("NotoSans-Bold", "./src/usdb_syncer/gui/resources/NotoSans-Bold.ttf")
+)
+pdfmetrics.registerFont(
+    TTFont("NotoSans-Regular", "./src/usdb_syncer/gui/resources/NotoSans-Regular.ttf")
+)
 
-def generate_song_pdf(songs: Iterable[SongId], path: str) -> None:
-    document: dict[str, Any] = {}
-    document["style"] = {"margin_bottom": 15, "text_align": "j"}
-    document["formats"] = {"url": {"c": "blue", "u": 1}, "title": {"b": 1, "s": 13}}
-    document["sections"] = []
-    section1: dict[str, list[Any]] = {}
-    document["sections"].append(section1)
-    content1: list[Any] = []
-    section1["content"] = content1
-    date = datetime.datetime.now()
-    content1.append(
-        {
-            ".": f"Songlist ({date:%Y-%m-%d})",
-            "style": "title",
-            "label": "title1",
-            "outline": {"level": 1, "text": "A different title 1"},
-        }
+
+class Bookmark(Flowable):
+    """Bookmark flowable."""
+
+    def __init__(self, key: str, title: str, level: int = 0):
+        super().__init__()
+        self.key = key
+        self.title = title
+        self.level = level
+
+    def draw(self) -> None:
+        self.canv.bookmarkPage(self.key)
+        self.canv.addOutlineEntry(self.title, self.key, level=self.level, closed=False)
+
+
+def generate_report_pdf(
+    *,
+    songs: Iterable[SongId],
+    path: str,
+    size: ReportPagesize = ReportPagesize.A4,
+    orientation: ReportOrientation = ReportOrientation.PORTRAIT,
+    margin: int = 20,
+    column_count: int = 1,
+    base_font_size: int = 10,
+    optional_info: list[Column] | None = None,
+) -> None:
+    optional_info = optional_info or []
+    pagesize = _get_pagesize(size, orientation)
+
+    doc: BaseDocTemplate = BaseDocTemplate(
+        path,
+        pagesize=pagesize,
+        leftMargin=margin * mm,
+        rightMargin=margin * mm,
+        topMargin=margin * mm,
+        bottomMargin=margin * mm,
+    )
+
+    custom_styles = _create_paragraph_styles(base_font_size)
+    content = _build_pdf_content(songs, base_font_size, custom_styles, optional_info)
+
+    gutter = margin * mm * 0.5
+    frames = _create_column_frames(
+        pagesize, margin=margin * mm, gutter=gutter, column_count=column_count
+    )
+    template = PageTemplate(
+        id="with_page_number", frames=frames, onPage=_add_page_number
+    )
+    doc.addPageTemplates([template])
+    doc.build(content)
+
+
+def _get_pagesize(
+    size: ReportPagesize, orientation: ReportOrientation
+) -> tuple[float, float]:
+    match size:
+        case ReportPagesize.A3:
+            pagesize = A3
+        case ReportPagesize.A4:
+            pagesize = A4
+        case ReportPagesize.A5:
+            pagesize = A5
+        case ReportPagesize.LEGAL:
+            pagesize = LEGAL
+        case ReportPagesize.LETTER:
+            pagesize = LETTER
+        case _ as unreachable:
+            assert_never(unreachable)
+    return (
+        landscape(pagesize) if orientation == ReportOrientation.LANDSCAPE else pagesize
     )
 
+
+def _create_paragraph_styles(base_font_size: int) -> dict[str, ParagraphStyle]:
+    return {
+        "Initial": ParagraphStyle(
path: src/usdb_syncer/pdf.py
user: login: RumovZ
body: Should be type-safe, e.g. by using a dataclass instead of a dict with magic keys.
created_at: 2025-04-12T07:28:19Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,158 @@
+"""Dialog to create a report."""
+
+import datetime
+import os
+from collections.abc import Iterable
+from pathlib import Path
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+    QAbstractItemView,
+    QDialog,
+    QFileDialog,
+    QListWidgetItem,
+    QWidget,
+)
+
+from usdb_syncer import SongId, db, settings
+from usdb_syncer.gui import progress
+from usdb_syncer.gui.forms.ReportDialog import Ui_Dialog
+from usdb_syncer.gui.progress import run_with_progress
+from usdb_syncer.gui.song_table.column import Column
+from usdb_syncer.gui.song_table.song_table import SongTable
+from usdb_syncer.json_export import generate_report_json
+from usdb_syncer.logger import logger
+from usdb_syncer.pdf import generate_report_pdf
+
+optional_columns = [
+    Column.LANGUAGE,
+    Column.EDITION,
+    Column.GENRE,
+    Column.YEAR,
+    Column.CREATOR,
+    Column.SONG_ID,
+]
+
+
+class ReportDialog(Ui_Dialog, QDialog):
+    """Dialog to create a report."""
+
+    def __init__(self, parent: QWidget, song_table: SongTable) -> None:
+        super().__init__(parent=parent)
+        self.song_table = song_table
+        self.setupUi(self)
+        self._populate_comboboxes()
+        self._populate_optional_columns()
+        self._load_settings()
+        self._connect_signals()
+
+    def _populate_optional_columns(self) -> None:
+        for column in optional_columns:
+            item = QListWidgetItem()
+            item.setData(Qt.ItemDataRole.UserRole, column)
+            if icon := column.decoration_data():
+                item.setIcon(icon)
+            if name := column.display_data():
+                item.setText(name)
+            item.setCheckState(Qt.CheckState.Unchecked)
+            if column in [Column.ARTIST, Column.TITLE]:
+                item.setCheckState(Qt.CheckState.Checked)
+            item.setFlags(
+                Qt.ItemFlag.ItemIsEnabled
+                | Qt.ItemFlag.ItemIsUserCheckable
+                | Qt.ItemFlag.ItemIsSelectable
+                | Qt.ItemFlag.ItemIsDragEnabled
+            )
+            self.optional_columns.addItem(item)
+        self.optional_columns.setDragEnabled(True)
+        self.optional_columns.setDragDropMode(
+            QAbstractItemView.DragDropMode.InternalMove
+        )
+
+    def _populate_comboboxes(self) -> None:
+        combobox_settings = (
+            (self.comboBox_pagesize, settings.ReportPagesize),
+            (self.comboBox_orientation, settings.ReportOrientation),
+        )
+        for combobox, setting in combobox_settings:
+            for item in setting:
+                combobox.addItem(str(item), item)
+
+    def _load_settings(self) -> None:
+        self.comboBox_pagesize.setCurrentIndex(
+            self.comboBox_pagesize.findData(settings.get_report_pagesize())
+        )
+        self.comboBox_orientation.setCurrentIndex(
+            self.comboBox_orientation.findData(settings.get_report_orientation())
+        )
+        self.spinBox_margin.setValue(settings.get_report_margin())
+        self.spinBox_columns.setValue(settings.get_report_columns())
+        self.spinBox_font_size.setValue(settings.get_report_fontsize())
+
+    def _connect_signals(self) -> None:
+        self.pushButton_generate_report_pdf.pressed.connect(self._generate_report_pdf)
+        self.pushButton_generate_report_json.pressed.connect(self._generate_report_json)
+
+    def accept(self) -> None:
+        self._save_settings()
+        super().accept()
+
+    def _save_settings(self) -> None:
+        settings.set_report_pagesize(self.comboBox_pagesize.currentData())
+        settings.set_report_orientation(self.comboBox_orientation.currentData())
+        settings.set_report_margin(self.spinBox_margin.value())
+        settings.set_report_columns(self.spinBox_columns.value())
+        settings.set_report_fontsize(self.spinBox_font_size.value())
+
+    def _generate_report_pdf(self) -> None:
+        def on_done(_: progress.Result) -> None:
+            logger.info("PDF report created.")
+
+        fname = f"{datetime.datetime.now():%Y-%m-%d}_songlist.pdf"
+        path = os.path.join(settings.get_song_dir(), fname)
+        path = QFileDialog.getSaveFileName(self, dir=path, filter="PDF (*.pdf)")[0]
+        # dialog is hidden by main window on macOS if file picker was cancelled
+        # self.raise_()
path: src/usdb_syncer/gui/report_dialog.py
user: login: bohning
body: Hmm, just tested it. It does not make a difference, with and without it, the entire report dialog seems to be closed when the file picker is cancelled.
created_at: 2025-04-12T13:44:48Z
author_association: OWNER

PR comments:
user: login: bohning
created_at: 2025-04-12T16:57:22Z
body: > Unfortunately, it doesn't work for me. Here is the traceback after propagating the exception (see below):

I removed and re-added the font files after adding `*.ttf -text` to `.gitattributes`. This should fix the issue.
author_association: OWNER
user: login: RumovZ
created_at: 2025-04-12T20:15:59Z
body: Great, works now!
I have some more points.

I notice not all special characters are supported, e.g. ★. Is that a deliberate choice because the required font files would need to be far too large otherwise?

Maybe the same logic we have for artist initials in path templates should be used?

When no songs are selected Ok does seemingly nothing, if you don't pay attention to the log. Would be helpful to have a pop-up in this case or possibly just close the dialog anyway.
If a PDF was successfully created I guess most people would appreciate it opening automatically.
author_association: COLLABORATOR
user: login: bohning
created_at: 2025-04-12T21:20:30Z
body: > I notice not all special characters are supported, e.g. ★. Is that a deliberate choice because the required font files would need to be far too large otherwise?

I think the ★ is the only symbol that would require an additional font (Noto Sans Symbols) and additional font fallback logic, which I thought was not worth it. But the artist initial logic is simplified and harmonized with the path templates to use `unidecode` anyways.
 
> When no songs are selected Ok does seemingly nothing, if you don't pay attention to the log. Would be helpful to have a pop-up in this case or possibly just close the dialog anyway.

Good point. I added a popup with the corresponding message (but kept the dialog open).

> If a PDF was successfully created I guess most people would appreciate it opening automatically.

Also good point. The PDF now opens after successful creation (but not the JSON).


author_association: OWNER