diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a511b13..1a35537 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,18 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v3 + - name: Install macOS dependencies + if: matrix.os == 'macos-latest' + run: | + brew install cairo libxml2 libffi + echo "PKG_CONFIG_PATH=/opt/homebrew/lib/pkgconfig:$PKG_CONFIG_PATH" >> $GITHUB_ENV + echo "DYLD_LIBRARY_PATH=/opt/homebrew/lib:$DYLD_LIBRARY_PATH" >> $GITHUB_ENV + - name: Install Windows dependencies + if: matrix.os == 'windows-latest' + run: | + choco install msys2 --yes + C:\tools\msys64\usr\bin\pacman -S --noconfirm mingw-w64-x86_64-cairo mingw-w64-x86_64-pkg-config + echo "C:\tools\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: Install poetry run: pipx install poetry - name: Setup Python ${{ matrix.python-version }} diff --git a/.gitignore b/.gitignore index 259ed2a..c6479f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ __pycache__/ .idea +data-tests/ +.DS_Store +src/rmc/assets/fonts/reMarkableSans.woff2 +src/rmc/assets/fonts/reMarkableSerif.woff2 +src/rmc/assets/fonts/reMarkableSerifItalic.woff2 diff --git a/README.md b/README.md index e5f2bcb..7b69ba1 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,69 @@ # rmc -Command line tool for converting to/from remarkable `.rm` version 6 (software version 3) files. +Command line tool for converting to/from remarkable `.rm` version 6 (software +version 3) files. ## Installation +If you want nice font rendering, you will need to install chrome/chromium (used +to create svg/pdf with embedded fonts). Otherwise, rendering will fall back to +using cairo. Chrome based rendering can also be turned off with `--no-chrome`. + To install in your current Python environment: pip install rmc -Or use [pipx](https://pypa.github.io/pipx/) to install in an isolated environment (recommended): +Or use [pipx](https://pypa.github.io/pipx/) to install in an isolated +environment (recommended): pipx install rmc -## Usage +To embed custom reMarkable fonts in the output, you will need to download them first: -Convert a remarkable v6 file to other formats, specified by `-t FORMAT`: + cd ./src/rmc/assets/fonts/ + ./download_remarkable_fonts.sh - $ rmc -t markdown file.rm - Text in the file is printed to standard output. +## Usage -Specify the filename to write the output to with `-o`: +Convert rm to pdf: - $ rmc -t svg -o file.svg file.rm - -The format is guessed based on the filename if not specified: - $ rmc file.rm -o file.pdf -Create a `.rm` file containing the text in `text.md`: +Convert rm to svg: - $ rmc -t rm text.md -o text.rm + $ rmc file.rm -o file.svg + +Convert a remarkable v6 file to other formats, specified by `-t FORMAT`: -## SVG/PDF Conversion Status + $ rmc -t markdown file.rm -o file.md -Right now the converter works well while there are no text boxes. If you add text boxes, there are x issues: +Create a `.rm` file containing the text in `text.md`: + + $ rmc -t rm text.md -o text.rm -1. if the text box contains multiple lines, the lines are actually printed in the same line, and -2. the position of the strokes gets corrupted. +``` +$ rmc --help +Usage: rmc [OPTIONS] [INPUT]... + + Convert to/from reMarkable v6 files. + + Available FORMATs are: `rm` (reMarkable file), `markdown`, `svg`, `pdf`, + `blocks`, `blocks-data`. + + Formats `blocks` and `blocks-data` dump the internal structure of the `rm` + file, with and without detailed data values respectively. + +Options: + --version Show the version and exit. + -v, --verbose + -f, --from FORMAT Format to convert from (default: guess from filename) + -t, --to FORMAT Format to convert to (default: guess from filename) + -o, --output PATH Output filename (default: write to standard out) + --no-chrome Use Cairo instead of Chrome for PDF conversion + --chrome-loc PATH Path to Chrome/Chromium binary + --device [RM2|RMPP] Device type (overrides auto-detection) + --help Show this message and exit. +``` # Acknowledgements diff --git a/flake.lock b/flake.lock index 803acce..0772c10 100644 --- a/flake.lock +++ b/flake.lock @@ -18,6 +18,45 @@ "type": "github" } }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1726560853, + "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nix-github-actions": { + "inputs": { + "nixpkgs": [ + "poetry2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1729742964, + "narHash": "sha256-B4mzTcQ0FZHdpeWcpDYPERtyjJd/NIuaQ9+BV1h+MpA=", + "owner": "nix-community", + "repo": "nix-github-actions", + "rev": "e04df33f62cdcf93d73e9a04142464753a16db67", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nix-github-actions", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1733686850, @@ -33,10 +72,49 @@ "type": "indirect" } }, + "nixpkgs_2": { + "locked": { + "lastModified": 1730157240, + "narHash": "sha256-P8wF4ag6Srmpb/gwskYpnIsnspbjZlRvu47iN527ABQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "75e28c029ef2605f9841e0baa335d70065fe7ae2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "75e28c029ef2605f9841e0baa335d70065fe7ae2", + "type": "github" + } + }, + "poetry2nix": { + "inputs": { + "flake-utils": "flake-utils_2", + "nix-github-actions": "nix-github-actions", + "nixpkgs": "nixpkgs_2", + "systems": "systems_3", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1743690424, + "narHash": "sha256-cX98bUuKuihOaRp8dNV1Mq7u6/CQZWTPth2IJPATBXc=", + "owner": "nix-community", + "repo": "poetry2nix", + "rev": "ce2369db77f45688172384bbeb962bc6c2ea6f94", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "poetry2nix", + "type": "github" + } + }, "root": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "poetry2nix": "poetry2nix" } }, "systems": { @@ -53,6 +131,57 @@ "repo": "default", "type": "github" } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_3": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "poetry2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1730120726, + "narHash": "sha256-LqHYIxMrl/1p3/kvm2ir925tZ8DkI0KA10djk8wecSk=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "9ef337e492a5555d8e17a51c911ff1f02635be15", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 9993f1c..2d114fa 100644 --- a/flake.nix +++ b/flake.nix @@ -4,27 +4,43 @@ inputs = { nixpkgs.url = "nixpkgs/nixpkgs-unstable"; flake-utils.url = "github:numtide/flake-utils"; + poetry2nix = { url = "github:nix-community/poetry2nix"; }; }; - outputs = { self, nixpkgs, flake-utils }: + outputs = { self, nixpkgs, flake-utils, poetry2nix }: flake-utils.lib.eachDefaultSystem (system: let - pythonEnv = pkgs.python312.withPackages (ps: []); pkgs = import nixpkgs { inherit system; }; + inherit (poetry2nix.lib.mkPoetry2Nix { inherit pkgs; }) + mkPoetryEnv mkPoetryApplication defaultPoetryOverrides; + poetryArgs = { + python = pkgs.python312; + projectDir = ./.; + preferWheels = true; + overrides = defaultPoetryOverrides.extend (final: prev: { + click = prev.click.overridePythonAttrs (old: { + buildInputs = (old.buildInputs or [ ]) ++ [ prev.flit-scm ]; + }); + rmc = prev.rmc.overridePythonAttrs (old: { + buildInputs = (old.buildInputs or [ ]) ++ [ prev.poetry-core ]; + }); + }); + }; + pythonEnv = mkPoetryEnv (poetryArgs); + rmcBin = mkPoetryApplication (poetryArgs); in { + packages = { + default = rmcBin; + }; devShells.default = pkgs.mkShell { - buildInputs = with pkgs; [ - sqlite.dev - poetry - inkscape + buildInputs = [ + pkgs.poetry + rmcBin + pythonEnv ]; - - LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ - pkgs.sqlite.out - ]; }; }); } diff --git a/poetry.lock b/poetry.lock index 17059df..8becd79 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,14 +1,260 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. + +[[package]] +name = "brotli" +version = "1.2.0" +description = "Python bindings for the Brotli compression library" +optional = false +python-versions = "*" +files = [ + {file = "brotli-1.2.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:99cfa69813d79492f0e5d52a20fd18395bc82e671d5d40bd5a91d13e75e468e8"}, + {file = "brotli-1.2.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:3ebe801e0f4e56d17cd386ca6600573e3706ce1845376307f5d2cbd32149b69a"}, + {file = "brotli-1.2.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:a387225a67f619bf16bd504c37655930f910eb03675730fc2ad69d3d8b5e7e92"}, + {file = "brotli-1.2.0-cp27-cp27m-win32.whl", hash = "sha256:b908d1a7b28bc72dfb743be0d4d3f8931f8309f810af66c906ae6cd4127c93cb"}, + {file = "brotli-1.2.0-cp27-cp27m-win_amd64.whl", hash = "sha256:d206a36b4140fbb5373bf1eb73fb9de589bb06afd0d22376de23c5e91d0ab35f"}, + {file = "brotli-1.2.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7e9053f5fb4e0dfab89243079b3e217f2aea4085e4d58c5c06115fc34823707f"}, + {file = "brotli-1.2.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4735a10f738cb5516905a121f32b24ce196ab82cfc1e4ba2e3ad1b371085fd46"}, + {file = "brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e"}, + {file = "brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984"}, + {file = "brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de"}, + {file = "brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947"}, + {file = "brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2"}, + {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84"}, + {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d"}, + {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1"}, + {file = "brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997"}, + {file = "brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196"}, + {file = "brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744"}, + {file = "brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f"}, + {file = "brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd"}, + {file = "brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe"}, + {file = "brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a"}, + {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b"}, + {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3"}, + {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae"}, + {file = "brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03"}, + {file = "brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24"}, + {file = "brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84"}, + {file = "brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b"}, + {file = "brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d"}, + {file = "brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca"}, + {file = "brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f"}, + {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28"}, + {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7"}, + {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036"}, + {file = "brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161"}, + {file = "brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44"}, + {file = "brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab"}, + {file = "brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c"}, + {file = "brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f"}, + {file = "brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6"}, + {file = "brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c"}, + {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48"}, + {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18"}, + {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5"}, + {file = "brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a"}, + {file = "brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8"}, + {file = "brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21"}, + {file = "brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac"}, + {file = "brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e"}, + {file = "brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7"}, + {file = "brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63"}, + {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b"}, + {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361"}, + {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888"}, + {file = "brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d"}, + {file = "brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3"}, + {file = "brotli-1.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:82676c2781ecf0ab23833796062786db04648b7aae8be139f6b8065e5e7b1518"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c16ab1ef7bb55651f5836e8e62db1f711d55b82ea08c3b8083ff037157171a69"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e85190da223337a6b7431d92c799fca3e2982abd44e7b8dec69938dcc81c8e9e"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d8c05b1dfb61af28ef37624385b0029df902ca896a639881f594060b30ffc9a7"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:465a0d012b3d3e4f1d6146ea019b5c11e3e87f03d1676da1cc3833462e672fb0"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:96fbe82a58cdb2f872fa5d87dedc8477a12993626c446de794ea025bbda625ea"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:1b71754d5b6eda54d16fbbed7fce2d8bc6c052a1b91a35c320247946ee103502"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:66c02c187ad250513c2f4fce973ef402d22f80e0adce734ee4e4efd657b6cb64"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:ba76177fd318ab7b3b9bf6522be5e84c2ae798754b6cc028665490f6e66b5533"}, + {file = "brotli-1.2.0-cp36-cp36m-win32.whl", hash = "sha256:c1702888c9f3383cc2f09eb3e88b8babf5965a54afb79649458ec7c3c7a63e96"}, + {file = "brotli-1.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f8d635cafbbb0c61327f942df2e3f474dde1cff16c3cd0580564774eaba1ee13"}, + {file = "brotli-1.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e80a28f2b150774844c8b454dd288be90d76ba6109670fe33d7ff54d96eb5cb8"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b1b799f45da91292ffaa21a473ab3a3054fa78560e8ff67082a185274431c8"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29b7e6716ee4ea0c59e3b241f682204105f7da084d6254ec61886508efeb43bc"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:640fe199048f24c474ec6f3eae67c48d286de12911110437a36a87d7c89573a6"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:92edab1e2fd6cd5ca605f57d4545b6599ced5dea0fd90b2bcdf8b247a12bd190"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7274942e69b17f9cef76691bcf38f2b2d4c8a5f5dba6ec10958363dcb3308a0a"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:a56ef534b66a749759ebd091c19c03ef81eb8cd96f0d1d16b59127eaf1b97a12"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5732eff8973dd995549a18ecbd8acd692ac611c5c0bb3f59fa3541ae27b33be3"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:598e88c736f63a0efec8363f9eb34e5b5536b7b6b1821e401afcb501d881f59a"}, + {file = "brotli-1.2.0-cp37-cp37m-win32.whl", hash = "sha256:7ad8cec81f34edf44a1c6a7edf28e7b7806dfb8886e371d95dcf789ccd4e4982"}, + {file = "brotli-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:865cedc7c7c303df5fad14a57bc5db1d4f4f9b2b4d0a7523ddd206f00c121a16"}, + {file = "brotli-1.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ac27a70bda257ae3f380ec8310b0a06680236bea547756c277b5dfe55a2452a8"}, + {file = "brotli-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e813da3d2d865e9793ef681d3a6b66fa4b7c19244a45b817d0cceda67e615990"}, + {file = "brotli-1.2.0-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fe11467c42c133f38d42289d0861b6b4f9da31e8087ca2c0d7ebb4543625526"}, + {file = "brotli-1.2.0-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c0d6770111d1879881432f81c369de5cde6e9467be7c682a983747ec800544e2"}, + {file = "brotli-1.2.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eda5a6d042c698e28bda2507a89b16555b9aa954ef1d750e1c20473481aff675"}, + {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3173e1e57cebb6d1de186e46b5680afbd82fd4301d7b2465beebe83ed317066d"}, + {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:71a66c1c9be66595d628467401d5976158c97888c2c9379c034e1e2312c5b4f5"}, + {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:1e68cdf321ad05797ee41d1d09169e09d40fdf51a725bb148bff892ce04583d7"}, + {file = "brotli-1.2.0-cp38-cp38-win32.whl", hash = "sha256:f16dace5e4d3596eaeb8af334b4d2c820d34b8278da633ce4a00020b2eac981c"}, + {file = "brotli-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:14ef29fc5f310d34fc7696426071067462c9292ed98b5ff5a27ac70a200e5470"}, + {file = "brotli-1.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8d4f47f284bdd28629481c97b5f29ad67544fa258d9091a6ed1fda47c7347cd1"}, + {file = "brotli-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2881416badd2a88a7a14d981c103a52a23a276a553a8aacc1346c2ff47c8dc17"}, + {file = "brotli-1.2.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d39b54b968f4b49b5e845758e202b1035f948b0561ff5e6385e855c96625971"}, + {file = "brotli-1.2.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95db242754c21a88a79e01504912e537808504465974ebb92931cfca2510469e"}, + {file = "brotli-1.2.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bba6e7e6cfe1e6cb6eb0b7c2736a6059461de1fa2c0ad26cf845de6c078d16c8"}, + {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:88ef7d55b7bcf3331572634c3fd0ed327d237ceb9be6066810d39020a3ebac7a"}, + {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:7fa18d65a213abcfbb2f6cafbb4c58863a8bd6f2103d65203c520ac117d1944b"}, + {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:09ac247501d1909e9ee47d309be760c89c990defbb2e0240845c892ea5ff0de4"}, + {file = "brotli-1.2.0-cp39-cp39-win32.whl", hash = "sha256:c25332657dee6052ca470626f18349fc1fe8855a56218e19bd7a8c6ad4952c49"}, + {file = "brotli-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:1ce223652fd4ed3eb2b7f78fbea31c52314baecfac68db44037bb4167062a937"}, + {file = "brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a"}, +] + +[[package]] +name = "cairocffi" +version = "1.7.1" +description = "cffi-based cairo bindings for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f"}, + {file = "cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b"}, +] + +[package.dependencies] +cffi = ">=1.1.0" + +[package.extras] +doc = ["sphinx", "sphinx_rtd_theme"] +test = ["numpy", "pikepdf", "pytest", "ruff"] +xcb = ["xcffib (>=1.4.0)"] + +[[package]] +name = "cairosvg" +version = "2.8.2" +description = "A Simple SVG Converter based on Cairo" +optional = false +python-versions = ">=3.9" +files = [ + {file = "cairosvg-2.8.2-py3-none-any.whl", hash = "sha256:eab46dad4674f33267a671dce39b64be245911c901c70d65d2b7b0821e852bf5"}, + {file = "cairosvg-2.8.2.tar.gz", hash = "sha256:07cbf4e86317b27a92318a4cac2a4bb37a5e9c1b8a27355d06874b22f85bef9f"}, +] + +[package.dependencies] +cairocffi = "*" +cssselect2 = "*" +defusedxml = "*" +pillow = "*" +tinycss2 = "*" + +[package.extras] +doc = ["sphinx", "sphinx_rtd_theme"] +test = ["flake8", "isort", "pytest"] + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "click" -version = "8.1.7" +version = "8.3.1" description = "Composable command line interface toolkit" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, ] [package.dependencies] @@ -25,29 +271,134 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cssselect2" +version = "0.8.0" +description = "CSS selectors for Python ElementTree" +optional = false +python-versions = ">=3.9" +files = [ + {file = "cssselect2-0.8.0-py3-none-any.whl", hash = "sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e"}, + {file = "cssselect2-0.8.0.tar.gz", hash = "sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a"}, +] + +[package.dependencies] +tinycss2 = "*" +webencodings = "*" + +[package.extras] +doc = ["furo", "sphinx"] +test = ["pytest", "ruff"] + +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] + [[package]] name = "exceptiongroup" -version = "1.2.2" +version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, ] +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "fonttools" +version = "4.61.1" +description = "Tools to manipulate font files" +optional = false +python-versions = ">=3.10" +files = [ + {file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24"}, + {file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958"}, + {file = "fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da"}, + {file = "fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6"}, + {file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1"}, + {file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881"}, + {file = "fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47"}, + {file = "fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6"}, + {file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09"}, + {file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37"}, + {file = "fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb"}, + {file = "fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9"}, + {file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87"}, + {file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56"}, + {file = "fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a"}, + {file = "fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7"}, + {file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e"}, + {file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2"}, + {file = "fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796"}, + {file = "fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d"}, + {file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8"}, + {file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0"}, + {file = "fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261"}, + {file = "fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9"}, + {file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c"}, + {file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e"}, + {file = "fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5"}, + {file = "fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd"}, + {file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3"}, + {file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d"}, + {file = "fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c"}, + {file = "fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b"}, + {file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd"}, + {file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e"}, + {file = "fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c"}, + {file = "fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75"}, + {file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063"}, + {file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2"}, + {file = "fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c"}, + {file = "fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c"}, + {file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa"}, + {file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91"}, + {file = "fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19"}, + {file = "fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba"}, + {file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7"}, + {file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118"}, + {file = "fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5"}, + {file = "fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b"}, + {file = "fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371"}, + {file = "fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69"}, +] + +[package.extras] +all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "pycairo", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.45.0)", "unicodedata2 (>=17.0.0)", "xattr", "zopfli (>=0.1.4)"] +graphite = ["lz4 (>=1.7.4.2)"] +interpolatable = ["munkres", "pycairo", "scipy"] +lxml = ["lxml (>=4.0)"] +pathops = ["skia-pathops (>=0.5.0)"] +plot = ["matplotlib"] +repacker = ["uharfbuzz (>=0.45.0)"] +symfont = ["sympy"] +type1 = ["xattr"] +unicode = ["unicodedata2 (>=17.0.0)"] +woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"] + [[package]] name = "iniconfig" -version = "2.0.0" +version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] [[package]] @@ -61,20 +412,139 @@ files = [ {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, ] +[[package]] +name = "pillow" +version = "12.1.0" +description = "Python Imaging Library (fork)" +optional = false +python-versions = ">=3.10" +files = [ + {file = "pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd"}, + {file = "pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7"}, + {file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a"}, + {file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef"}, + {file = "pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09"}, + {file = "pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91"}, + {file = "pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea"}, + {file = "pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3"}, + {file = "pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0"}, + {file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b"}, + {file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18"}, + {file = "pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64"}, + {file = "pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75"}, + {file = "pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304"}, + {file = "pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b"}, + {file = "pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17"}, + {file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670"}, + {file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616"}, + {file = "pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7"}, + {file = "pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d"}, + {file = "pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c"}, + {file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1"}, + {file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179"}, + {file = "pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0"}, + {file = "pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587"}, + {file = "pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc"}, + {file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644"}, + {file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c"}, + {file = "pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171"}, + {file = "pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a"}, + {file = "pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45"}, + {file = "pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d"}, + {file = "pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4"}, + {file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0"}, + {file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b"}, + {file = "pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65"}, + {file = "pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0"}, + {file = "pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8"}, + {file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91"}, + {file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796"}, + {file = "pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd"}, + {file = "pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13"}, + {file = "pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9"}, + {file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a"}, + {file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a"}, + {file = "pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030"}, + {file = "pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94"}, + {file = "pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4"}, + {file = "pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2"}, + {file = "pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8"}, + {file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924"}, + {file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef"}, + {file = "pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988"}, + {file = "pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6"}, + {file = "pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19"}, + {file = "pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +xmp = ["defusedxml"] + [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [package.extras] dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pycparser" +version = "2.23" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, +] [[package]] name = "pytest" @@ -100,60 +570,119 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no [[package]] name = "rmscene" -version = "0.6.0" +version = "0.7.0" description = "Read v6 .rm files from the reMarkable tablet" optional = false -python-versions = "<4.0,>=3.10" +python-versions = "^3.10" +files = [] +develop = false + +[package.dependencies] +packaging = "^23.0" + +[package.source] +type = "git" +url = "https://github.com/scrybbling-together/rmscene.git" +reference = "main" +resolved_reference = "ec753f1af27052353f9087113beac74e5b17986b" + +[[package]] +name = "tinycss2" +version = "1.5.1" +description = "A tiny CSS parser" +optional = false +python-versions = ">=3.10" files = [ - {file = "rmscene-0.6.0-py3-none-any.whl", hash = "sha256:30a4c8590e8edb4567698adb96dc31004c6d41d752ad04e6c92ab985d0a64c7c"}, - {file = "rmscene-0.6.0.tar.gz", hash = "sha256:c3d89a026ca2b98cfca3b6bcb4c6132ebe5e86b12192de14b119f286c5de39cd"}, + {file = "tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661"}, + {file = "tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957"}, ] [package.dependencies] -packaging = ">=23.0,<24.0" +webencodings = ">=0.4" + +[package.extras] +doc = ["furo", "sphinx"] +test = ["pytest", "ruff"] [[package]] name = "tomli" -version = "2.2.1" +version = "2.4.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"}, + {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"}, + {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"}, + {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"}, + {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"}, + {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"}, + {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"}, + {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"}, + {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"}, + {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"}, + {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"}, + {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"}, + {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"}, + {file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"}, + {file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"}, + {file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"}, + {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, + {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" +optional = false +python-versions = "*" +files = [ + {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, + {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, ] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "89300f7c0755c959ecf73ddc93b01d4528f11a86a072e4f384e9626329136aee" +content-hash = "aa98d378f1dcd8337013ff63ac5ff747d04ea60078de863f67394b440da4b974" diff --git a/pyproject.toml b/pyproject.toml index 6efff24..8dd89ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "rmc" -version = "0.3.1-dev" +version = "0.3.2-dev" description = "Convert to/from v6 .rm files from the reMarkable tablet" authors = ["Rick Lupton "] license = "MIT" @@ -8,8 +8,11 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.10" -rmscene = ">=0.6.0, <0.7.0" +rmscene = { git = "https://github.com/scrybbling-together/rmscene.git", branch = "main" } click = "^8.0" +cairosvg = "^2.8.2" +brotli = "*" +fontTools = "*" [tool.poetry.dev-dependencies] pytest = "^7.2.0" diff --git a/src/rmc/__init__.py b/src/rmc/__init__.py index 2e55d0a..f4d0870 100644 --- a/src/rmc/__init__.py +++ b/src/rmc/__init__.py @@ -1,2 +1,7 @@ +from pathlib import Path +current_dir = Path(__file__).parent +ASSETS = current_dir / "assets" + from .exporters.svg import tree_to_svg, rm_to_svg from .exporters.pdf import rm_to_pdf + diff --git a/src/rmc/assets/checked.svg b/src/rmc/assets/checked.svg new file mode 100644 index 0000000..bb5d049 --- /dev/null +++ b/src/rmc/assets/checked.svg @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/src/rmc/assets/fonts/EBGaramond-Italic-VariableFont_wght.ttf b/src/rmc/assets/fonts/EBGaramond-Italic-VariableFont_wght.ttf new file mode 100644 index 0000000..9cb1376 Binary files /dev/null and b/src/rmc/assets/fonts/EBGaramond-Italic-VariableFont_wght.ttf differ diff --git a/src/rmc/assets/fonts/EBGaramond-VariableFont_wght.ttf b/src/rmc/assets/fonts/EBGaramond-VariableFont_wght.ttf new file mode 100644 index 0000000..baf64b2 Binary files /dev/null and b/src/rmc/assets/fonts/EBGaramond-VariableFont_wght.ttf differ diff --git a/src/rmc/assets/fonts/NotoSans-Italic-VariableFont_wdth,wght.ttf b/src/rmc/assets/fonts/NotoSans-Italic-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000..6245ba0 Binary files /dev/null and b/src/rmc/assets/fonts/NotoSans-Italic-VariableFont_wdth,wght.ttf differ diff --git a/src/rmc/assets/fonts/NotoSans-VariableFont_wdth,wght.ttf b/src/rmc/assets/fonts/NotoSans-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000..9530d84 Binary files /dev/null and b/src/rmc/assets/fonts/NotoSans-VariableFont_wdth,wght.ttf differ diff --git a/src/rmc/assets/fonts/download_remarkable_fonts.sh b/src/rmc/assets/fonts/download_remarkable_fonts.sh new file mode 100755 index 0000000..1ec0fb1 --- /dev/null +++ b/src/rmc/assets/fonts/download_remarkable_fonts.sh @@ -0,0 +1,9 @@ +#/bin/sh + +# Links found in /usr/share/remarkable/webui/assets/index.js from a Remarkable Paper +# Pro, firmware version v3.24.0.149. +# Additionally that directly also contains reMarkableSerif.woff2 and reMarkableSans.woff2 + +curl https://cdn.sanity.io/files/xpujt61d/production/47ed70b8382b19b3487648982b78a7b2ada3eb3f.woff2 --output reMarkableSerifItalic.woff2 +curl https://cdn.sanity.io/files/xpujt61d/production/f75f89732cba9023fa578d7fd28666798de505d0.woff2 --output reMarkableSerif.woff2 +curl https://cdn.sanity.io/files/xpujt61d/production/227f58180ac8527c16669879375e917f9d5ab6e4.woff2 --output reMarkableSans.woff2 diff --git a/src/rmc/assets/unchecked.svg b/src/rmc/assets/unchecked.svg new file mode 100644 index 0000000..97a2309 --- /dev/null +++ b/src/rmc/assets/unchecked.svg @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/src/rmc/cli.py b/src/rmc/cli.py index 05554aa..3f9147d 100644 --- a/src/rmc/cli.py +++ b/src/rmc/cli.py @@ -7,7 +7,7 @@ from contextlib import contextmanager import click from rmscene import read_tree, read_blocks, write_blocks, simple_text_document -from .exporters.svg import tree_to_svg +from .exporters.svg import tree_to_svg, DEVICE_PROFILES from .exporters.pdf import svg_to_pdf from .exporters.markdown import print_text @@ -20,8 +20,11 @@ @click.option("-f", "--from", "from_", metavar="FORMAT", help="Format to convert from (default: guess from filename)") @click.option("-t", "--to", metavar="FORMAT", help="Format to convert to (default: guess from filename)") @click.option("-o", "--output", type=click.Path(), help="Output filename (default: write to standard out)") +@click.option("--no-chrome", is_flag=True, help="Use Cairo instead of Chrome for PDF conversion") +@click.option("--chrome-loc", type=click.Path(), help="Path to Chrome/Chromium binary") +@click.option("--device", type=click.Choice(list(DEVICE_PROFILES.keys())), help="Device type (overrides auto-detection)") @click.argument("input", nargs=-1, type=click.Path(exists=True)) -def cli(verbose, from_, to, output, input): +def cli(verbose, from_, to, output, input, no_chrome, chrome_loc, device): """Convert to/from reMarkable v6 files. Available FORMATs are: `rm` (reMarkable file), `markdown`, `svg`, `pdf`, @@ -52,10 +55,17 @@ def cli(verbose, from_, to, output, input): raise click.UsageError("Must specify --output or --to") to = guess_format(output) + if device: + from .exporters.svg import set_device + set_device(device) + # If no device specified, defaults to RMPP (see device.py) + # Arbitary output size can be set with set_dimensions_for_pdf but this isn't + # exposed via the CLI yet. + if from_ == "rm": with open_output(to, output) as fout: for fn in input: - convert_rm(Path(fn), to, fout) + convert_rm(Path(fn), to, fout, no_chrome=no_chrome, chrome_loc=chrome_loc) elif from_ == "markdown": text = "".join( Path(fn).read_text() for fn in input @@ -116,7 +126,7 @@ def tree_structure(item): return item -def convert_rm(filename: Path, to, fout): +def convert_rm(filename: Path, to, fout, no_chrome=False, chrome_loc=None): with open(filename, "rb") as f: if to == "blocks": pprint_blocks(f, fout) @@ -138,7 +148,7 @@ def convert_rm(filename: Path, to, fout): tree = read_tree(f) tree_to_svg(tree, buf) buf.seek(0) - svg_to_pdf(buf, fout) + svg_to_pdf(buf, fout, use_chrome=not no_chrome, chrome_loc=chrome_loc) else: raise click.UsageError("Unknown format %s" % to) diff --git a/src/rmc/exporters/pdf.py b/src/rmc/exporters/pdf.py index bf5843a..2b97e56 100644 --- a/src/rmc/exporters/pdf.py +++ b/src/rmc/exporters/pdf.py @@ -5,42 +5,198 @@ """ import logging -from tempfile import NamedTemporaryFile -from subprocess import check_call +import re +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +from cairosvg import svg2pdf from .svg import rm_to_svg _logger = logging.getLogger(__name__) +# Chrome/Chromium command names to search in PATH +CHROME_COMMANDS = [ + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + "chrome", +] -def rm_to_pdf(rm_path, pdf_path, debug=0): - """Convert `rm_path` to PDF at `pdf_path`.""" - with NamedTemporaryFile(suffix=".svg") as f_temp: - rm_to_svg(rm_path, f_temp.name) +# Common Chrome/Chromium installation paths +CHROME_PATHS = [ + # macOS + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "~/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "~/Applications/Chromium.app/Contents/MacOS/Chromium", + # Linux + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/usr/lib/chromium/chromium", + "/usr/lib/chromium-browser/chromium-browser", + "/snap/bin/chromium", + "/opt/google/chrome/google-chrome", + "/opt/google/chrome/chrome", + # Flatpak + "/var/lib/flatpak/exports/bin/com.google.Chrome", + "/var/lib/flatpak/exports/bin/org.chromium.Chromium", +] + + +def find_chrome(chrome_loc: Optional[str] = None) -> Optional[str]: + """ + Find Chrome/Chromium binary. + + :param chrome_loc: Optional explicit path to Chrome binary + :return: Path to Chrome binary, or None if not found + :raises FileNotFoundError: If chrome_loc is specified but doesn't exist + """ + if chrome_loc: + path = Path(chrome_loc).expanduser() + if path.is_file(): + _logger.info(f"Using specified Chrome location: {path}") + return str(path) + raise FileNotFoundError(f"Chrome not found at specified location: {chrome_loc}") + + # Check PATH first + for cmd in CHROME_COMMANDS: + path = shutil.which(cmd) + if path: + _logger.info(f"Found Chrome in PATH: {path}") + return path + + # Check common installation paths + for path_str in CHROME_PATHS: + path = Path(path_str).expanduser() + if path.is_file(): + _logger.info(f"Found Chrome at: {path}") + return str(path) + + return None + + +def chrome_svg_to_pdf(svg_path: str, pdf_path: str, chrome_loc: Optional[str] = None): + """ + Convert SVG to PDF using headless Chrome. - # use inkscape to convert svg to pdf - check_call(["inkscape", f_temp.name, "--export-filename", pdf_path]) + :param svg_path: Path to input SVG file + :param pdf_path: Path to output PDF file + :param chrome_loc: Optional explicit path to Chrome binary + :raises RuntimeError: If Chrome is not found + """ + chrome = find_chrome(chrome_loc) + if not chrome: + raise RuntimeError( + "Chrome/Chromium not found. Install it, specify --chrome-loc, or use --no-chrome" + ) + # Create HTML wrapper that sizes the page to match the SVG + # NOTE: Deliberately omitting - with DOCTYPE, Chrome renders 2 pages instead of 1 + html_content = f''' + + + + + + + +''' -def svg_to_pdf(svg_file, pdf_file): - """Read svg data from `svg_file` and write PDF data to `pdf_file`.""" + with tempfile.TemporaryDirectory() as tmpdir: + html_path = Path(tmpdir) / "temp.html" + html_path.write_text(html_content) - with NamedTemporaryFile("w", suffix=".svg") as fsvg, NamedTemporaryFile("rb", suffix=".pdf") as fpdf: - fsvg.write(svg_file.read()) - fsvg.flush() # Make sure content is writen to the file - - # use inkscape to convert svg to pdf + subprocess.run([ + chrome, + "--headless", + "--no-sandbox", # without this flag, chrome won't run in docker + "--disable-gpu", + "--no-pdf-header-footer", + f"--print-to-pdf={pdf_path}", + str(html_path) + ], check=True) + + +def _svg_to_pdf(svg_data: str, pdf_file: Path, use_chrome: bool = True, chrome_loc: Optional[str] = None): + """ + Convert SVG data to PDF. + + :param svg_data: SVG data as string + :param pdf_file: Path to output pdf + :param use_chrome: If True, use Chrome when fonts are present; if False, always use Cairo + :param chrome_loc: Optional explicit path to Chrome binary + """ + # Only use Chrome if requested and SVG contains fonts + has_fonts = 'font-family' in svg_data or '@font-face' in svg_data + + if use_chrome and has_fonts and find_chrome(chrome_loc): + with tempfile.NamedTemporaryFile(suffix=".svg", mode="w", delete=False) as temp_svg: + temp_svg.write(svg_data) + temp_svg.flush() + temp_svg_path = temp_svg.name try: - print("Convert SVG to PDF using Inkscape") - check_call(["inkscape", fsvg.name, "--export-filename", fpdf.name]) - except FileNotFoundError: - print("Inkscape not found in path") - - try: - print("Convert SVG to PDF using Inkscape (default MacOS path)") - check_call(["/Applications/Inkscape.app/Contents/MacOS/inkscape", fsvg.name, "--export-filename", fpdf.name]) - except FileNotFoundError: - pass - - pdf_file.write(fpdf.read()) - pdf_file.flush() + chrome_svg_to_pdf(temp_svg_path, str(pdf_file), chrome_loc) + finally: + Path(temp_svg_path).unlink(missing_ok=True) + else: + if use_chrome and has_fonts: + _logger.warning("Chrome/Chromium not found, falling back to Cairo for PDF conversion") + # Use Cairo - extract dimensions from SVG to ensure correct output size + width_match = re.search(r'width="([0-9.]+)"', svg_data) + height_match = re.search(r'height="([0-9.]+)"', svg_data) + kwargs = {} + if width_match and height_match: + kwargs['output_width'] = float(width_match.group(1)) + kwargs['output_height'] = float(height_match.group(1)) + svg2pdf( + bytestring=svg_data.encode('utf-8'), + write_to=str(pdf_file), + **kwargs + ) + +def svg_to_pdf(svg_file, pdf_file, use_chrome: bool = True, chrome_loc: Optional[str] = None): + """ + Convert SVG to PDF. + + :param svg_file: Input SVG file object (StringIO with .getvalue()) + :param pdf_file: Output PDF file object (with .name attribute) + :param use_chrome: If True, use Chrome; if False, use Cairo + :param chrome_loc: Optional explicit path to Chrome binary + """ + svg_str = svg_file.getvalue() + _svg_to_pdf(svg_str, pdf_file.name, use_chrome, chrome_loc) + +def rm_to_pdf(rm_path, pdf_path, use_chrome: bool = True, chrome_loc: Optional[str] = None): + """ + Convert .rm file to PDF. + + :param rm_path: Path to .rm file + :param pdf_path: Path to output PDF file + :param use_chrome: If True, use Chrome when fonts are present; if False, always use Cairo + :param chrome_loc: Optional explicit path to Chrome binary + """ + with tempfile.NamedTemporaryFile(suffix=".svg", mode="w", delete=False) as f_temp: + rm_to_svg(rm_path, f_temp.name) + temp_svg_path = f_temp.name + + try: + svg_data = Path(temp_svg_path).read_text() + _svg_to_pdf(svg_data, pdf_path, use_chrome=use_chrome, chrome_loc=chrome_loc) + finally: + Path(temp_svg_path).unlink(missing_ok=True) diff --git a/src/rmc/exporters/svg.py b/src/rmc/exporters/svg.py deleted file mode 100644 index baab89e..0000000 --- a/src/rmc/exporters/svg.py +++ /dev/null @@ -1,283 +0,0 @@ -"""Convert blocks to svg file. - -Code originally from https://github.com/lschwetlick/maxio through -https://github.com/chemag/maxio . -""" - -import logging -import string -import typing as tp -from pathlib import Path - -from rmscene import CrdtId, SceneTree, read_tree -from rmscene import scene_items as si -from rmscene.text import TextDocument - -from .writing_tools import Pen - -_logger = logging.getLogger(__name__) - -SCREEN_WIDTH = 1404 -SCREEN_HEIGHT = 1872 -SCREEN_DPI = 226 - -SCALE = 72.0 / SCREEN_DPI - -PAGE_WIDTH_PT = SCREEN_WIDTH * SCALE -PAGE_HEIGHT_PT = SCREEN_HEIGHT * SCALE -X_SHIFT = PAGE_WIDTH_PT // 2 - - -def scale(screen_unit: float) -> float: - return screen_unit * SCALE - - -# For now, at least, the xx and yy function are identical to scale -xx = scale -yy = scale - -TEXT_TOP_Y = -88 -LINE_HEIGHTS = { - # Based on a rm file having 4 anchors based on the line height I was able to find a value of - # 69.5, but decided on 70 (to keep integer values) - si.ParagraphStyle.PLAIN: 70, - si.ParagraphStyle.BULLET: 35, - si.ParagraphStyle.BULLET2: 35, - si.ParagraphStyle.BOLD: 70, - si.ParagraphStyle.HEADING: 150, - si.ParagraphStyle.CHECKBOX: 35, - si.ParagraphStyle.CHECKBOX_CHECKED: 35, - - # There appears to be another format code (value 0) which is used when the - # text starts far down the page, which case it has a negative offset (line - # height) of about -20? - # - # Probably, actually, the line height should be added *after* the first - # line, but there is still something a bit odd going on here. -} - -SVG_HEADER = string.Template(""" -""") - - -def rm_to_svg(rm_path, svg_path): - """Convert `rm_path` to SVG at `svg_path`.""" - with open(rm_path, "rb") as infile, open(svg_path, "wt") as outfile: - tree = read_tree(infile) - tree_to_svg(tree, outfile) - - -def read_template_svg(template_path: Path) -> str: - lines = template_path.read_text().splitlines() - return "\n".join(lines[2:-2]) - - -def tree_to_svg(tree: SceneTree, output, include_template: Path | None = None): - """Convert Blocks to SVG.""" - - # find the anchor pos for further use - anchor_pos = build_anchor_pos(tree.root_text) - _logger.debug("anchor_pos: %s", anchor_pos) - - # find the extremum along x and y - x_min, x_max, y_min, y_max = get_bounding_box(tree.root, anchor_pos) - width_pt = xx(x_max - x_min + 1) - height_pt = yy(y_max - y_min + 1) - _logger.debug("x_min, x_max, y_min, y_max: %.1f, %.1f, %.1f, %.1f ; scalded %.1f, %.1f, %.1f, %.1f", - x_min, x_max, y_min, y_max, xx(x_min), xx(x_max), yy(y_min), yy(y_max)) - - # add svg header - output.write(SVG_HEADER.substitute(width=width_pt, - height=height_pt, - viewbox=f"{xx(x_min)} {yy(y_min)} {width_pt} {height_pt}") + "\n") - - if include_template is not None: - output.write(read_template_svg(include_template)) - output.write(f'\n\t\n') - - output.write(f'\t\n') - - if tree.root_text is not None: - draw_text(tree.root_text, output) - - draw_group(tree.root, output, anchor_pos) - - # Closing page group - output.write('\t\n') - # END notebook - output.write('\n') - - -def build_anchor_pos(text: tp.Optional[si.Text]) -> tp.Dict[CrdtId, int]: - """ - Find the anchor pos - - :param text: the root text of the remarkable file - """ - # Special anchors adjusted based on pen_size_test.strokes.rm - anchor_pos = { - CrdtId(0, 281474976710654): 100, - CrdtId(0, 281474976710655): 100, - } - - if text is not None: - # Save anchor from text - doc = TextDocument.from_scene_item(text) - ypos = text.pos_y + TEXT_TOP_Y - for i, p in enumerate(doc.contents): - anchor_pos[p.start_id] = ypos - for subp in p.contents: - for k in subp.i: - anchor_pos[k] = ypos # TODO check these anchor are used - ypos += LINE_HEIGHTS.get(p.style.value, 70) - - return anchor_pos - - -def get_anchor(item: si.Group, anchor_pos): - anchor_x = 0.0 - anchor_y = 0.0 - if item.anchor_id is not None: - assert item.anchor_origin_x is not None - anchor_x = item.anchor_origin_x.value - if item.anchor_id.value in anchor_pos: - anchor_y = anchor_pos[item.anchor_id.value] - _logger.debug("Group anchor: %s -> y=%.1f (scalded y=%.1f)", - item.anchor_id.value, - anchor_y, - yy(anchor_y)) - else: - _logger.warning("Group anchor: %s is unknown!", item.anchor_id.value) - - return anchor_x, anchor_y - - -def get_bounding_box(item: si.Group, - anchor_pos: tp.Dict[CrdtId, int], - default: tp.Tuple[int, int, int, int] = (- SCREEN_WIDTH // 2, SCREEN_WIDTH // 2, 0, SCREEN_HEIGHT)) \ - -> tp.Tuple[int, int, int, int]: - """ - Get the bounding box of the given item. - The minimum size is the default size of the screen. - - :return: x_min, x_max, y_min, y_max: the bounding box in screen units (need to be scalded using xx and yy functions) - """ - x_min, x_max, y_min, y_max = default - - for child_id in item.children: - child = item.children[child_id] - if isinstance(child, si.Group): - anchor_x, anchor_y = get_anchor(child, anchor_pos) - x_min_t, x_max_t, y_min_t, y_max_t = get_bounding_box(child, anchor_pos, (0, 0, 0, 0)) - x_min = min(x_min, x_min_t + anchor_x) - x_max = max(x_max, x_max_t + anchor_x) - y_min = min(y_min, y_min_t + anchor_y) - y_max = max(y_max, y_max_t + anchor_y) - elif isinstance(child, si.Line): - x_min = min([x_min] + [p.x for p in child.points]) - x_max = max([x_max] + [p.x for p in child.points]) - y_min = min([y_min] + [p.y for p in child.points]) - y_max = max([y_max] + [p.y for p in child.points]) - - return x_min, x_max, y_min, y_max - - -def draw_group(item: si.Group, output, anchor_pos): - anchor_x, anchor_y = get_anchor(item, anchor_pos) - output.write(f'\t\t\n') - for child_id in item.children: - child = item.children[child_id] - _logger.debug("Group child: %s %s", child_id, type(child)) - if _logger.root.level == logging.DEBUG: - output.write(f'\t\t\n') - if isinstance(child, si.Group): - draw_group(child, output, anchor_pos) - elif isinstance(child, si.Line): - draw_stroke(child, output) - output.write(f'\t\t\n') - - -def draw_stroke(item: si.Line, output): - # print debug infos - if _logger.root.level == logging.DEBUG: - _logger.debug("Writing line: %s", item) - output.write(f'\t\t\t\n') - - # initiate the pen - pen = Pen.create(item.tool.value, item.color.value, item.thickness_scale) - - last_xpos = -1. - last_ypos = -1. - last_segment_width = segment_width = 0 - # Iterate through the point to form a polyline - for point_id, point in enumerate(item.points): - # align the original position - xpos = point.x - ypos = point.y - if point_id % pen.segment_length == 0: - # if there was a previous segment, end it - if last_xpos != -1.: - output.write('"/>\n') - - segment_color = pen.get_segment_color(point.speed, point.direction, point.width, point.pressure, - last_segment_width) - segment_width = pen.get_segment_width(point.speed, point.direction, point.width, point.pressure, - last_segment_width) - segment_opacity = pen.get_segment_opacity(point.speed, point.direction, point.width, point.pressure, - last_segment_width) - # create the next segment of the stroke - output.write('\t\t\t\n') - - -def draw_text(text: si.Text, output): - output.write('\t\t') - - # add some style to get readable text - output.write(''' - -''') - - y_offset = TEXT_TOP_Y - - doc = TextDocument.from_scene_item(text) - for p in doc.contents: - y_offset += LINE_HEIGHTS.get(p.style.value, 70) - - xpos = text.pos_x - ypos = text.pos_y + y_offset - cls = p.style.value.name.lower() - if str(p): - # TODO: this doesn't take into account the CrdtStr.properties (font-weight/font-style) - if _logger.root.level == logging.DEBUG: - output.write(f'\t\t\t\n') - output.write(f'\t\t\t{str(p).strip()}\n') - output.write('\t\t\n') diff --git a/src/rmc/exporters/svg/__init__.py b/src/rmc/exporters/svg/__init__.py new file mode 100644 index 0000000..00d2ff9 --- /dev/null +++ b/src/rmc/exporters/svg/__init__.py @@ -0,0 +1,45 @@ +"""SVG export functionality for reMarkable files. + +This module provides functions to convert .rm files to SVG format. +""" + +# Import device module for config access +from .device import ( + set_device, + get_device, + set_dimensions_for_pdf, + DEVICE_PROFILES, + SvgRenderConfig, + rmc_config, +) + +# Import rendering module functions +from .rendering import ( + rm_to_svg, + tree_to_svg, + SVG_HEADER, + draw_text, + draw_group, +) + +# Import layout module functions +from .layout import ( + build_anchor_pos, + get_bounding_box, +) + +__all__ = [ + 'rm_to_svg', + 'tree_to_svg', + 'set_device', + 'get_device', + 'set_dimensions_for_pdf', + 'build_anchor_pos', + 'get_bounding_box', + 'DEVICE_PROFILES', + 'SvgRenderConfig', + 'rmc_config', + 'SVG_HEADER', + 'draw_text', + 'draw_group', +] diff --git a/src/rmc/exporters/svg/device.py b/src/rmc/exporters/svg/device.py new file mode 100644 index 0000000..d4c6886 --- /dev/null +++ b/src/rmc/exporters/svg/device.py @@ -0,0 +1,111 @@ +"""Device profiles and coordinate scaling for reMarkable devices.""" + +import logging +from dataclasses import dataclass, field + +_logger = logging.getLogger(__name__) + +# Device profiles with screen dimensions +DEVICE_PROFILES = { + "RM2": {"width": 1404, "height": 1872, "dpi": 226}, + "RMPP": {"width": 1620, "height": 2160, "dpi": 229}, +} + + +@dataclass +class SvgRenderConfig: + """Configuration for SVG rendering - device dimensions and coordinate scaling.""" + + # Primary device configuration + screen_width: int # Device screen width in pixels + screen_height: int # Device screen height in pixels + screen_dpi: int # Device DPI (dots per inch) + device_name: str = "CUSTOM" # "RM2", "RMPP", or "CUSTOM" + + # Computed scaling factors (auto-calculated in __post_init__) + scale: float = field(init=False) # 72.0 / DPI + page_width_pt: float = field(init=False) # screen_width * scale + page_height_pt: float = field(init=False) # screen_height * scale + x_shift: float = field(init=False) # page_width_pt // 2 + + def __post_init__(self): + """Compute derived values after initialization.""" + self._recompute_derived_fields() + + def _recompute_derived_fields(self) -> None: + """Recompute scale, page_width_pt, page_height_pt, x_shift after config changes.""" + self.scale = 72.0 / self.screen_dpi + self.page_width_pt = self.screen_width * self.scale + self.page_height_pt = self.screen_height * self.scale + self.x_shift = self.page_width_pt // 2 + + @classmethod + def from_device_profile(cls, device: str) -> "SvgRenderConfig": + """Create config from device profile name ('RM2' or 'RMPP').""" + config = cls.__new__(cls) + config.update_from_device_profile(device) + return config + + @classmethod + def from_pdf_size(cls, width_pt: float, height_pt: float, dpi: int = 226) -> "SvgRenderConfig": + """Create config from PDF dimensions in points.""" + config = cls.__new__(cls) + config.update_from_pdf_size(width_pt, height_pt, dpi) + return config + + def update_from_device_profile(self, device: str) -> None: + """Update this config in-place from a device profile.""" + if device not in DEVICE_PROFILES: + raise ValueError(f"Unknown device: {device}. Valid options: {list(DEVICE_PROFILES.keys())}") + profile = DEVICE_PROFILES[device] + self.screen_width = profile["width"] + self.screen_height = profile["height"] + self.screen_dpi = profile["dpi"] + self.device_name = device + self._recompute_derived_fields() + + def update_from_pdf_size(self, width_pt: float, height_pt: float, dpi: int = 226) -> None: + """Update this config in-place from PDF dimensions.""" + scale = 72.0 / dpi + self.screen_width = int(round(width_pt / scale)) + self.screen_height = int(round(height_pt / scale)) + self.screen_dpi = dpi + self.device_name = "CUSTOM" + self._recompute_derived_fields() + + def xx(self, screen_units: float) -> float: + """Convert screen units to PDF points (x-axis).""" + return screen_units * self.scale + + def yy(self, screen_units: float) -> float: + """Convert screen units to PDF points (y-axis).""" + return screen_units * self.scale + + +# Global render config instance +# Default to RMPP +rmc_config = SvgRenderConfig.from_device_profile("RMPP") + + +def set_device(device: str) -> None: + """Set the current device profile. Valid values: 'RM2', 'RMPP'""" + rmc_config.update_from_device_profile(device) + _logger.debug(f"Set device to {device}: {rmc_config.screen_width}x{rmc_config.screen_height} @ {rmc_config.screen_dpi} DPI") + + +def get_device() -> str: + """Get the current device profile name.""" + return rmc_config.device_name + + +def set_dimensions_for_pdf(width_pt: float, height_pt: float, dpi: int = 226) -> None: + """Set dimensions to match a PDF page size. + + Converts PDF points to screen coordinates and sets custom dimensions. + This ensures the generated SVG matches the PDF exactly. + + :param width_pt: PDF page width in points + :param height_pt: PDF page height in points + :param dpi: DPI to use for conversion (default 226, same as RM2) + """ + rmc_config.update_from_pdf_size(width_pt, height_pt, dpi) diff --git a/src/rmc/exporters/svg/fonts.py b/src/rmc/exporters/svg/fonts.py new file mode 100644 index 0000000..c42bb42 --- /dev/null +++ b/src/rmc/exporters/svg/fonts.py @@ -0,0 +1,393 @@ +"""Font configuration, resolution, metrics, and text measurement.""" + +import logging +import typing as tp +from pathlib import Path + +from rmscene import scene_items as si + +# Import from parent package +from ... import ASSETS + +# Import from device module for SCALE and SCREEN_DPI +from . import device + +_logger = logging.getLogger(__name__) + +# ============================================================================= +# FONT CONFIGURATION +# ============================================================================= +# All font files, sizes (in points), and weights are defined here. +# Change these values to adjust text rendering throughout the document. +# +# To add a new font: +# 1. Add .woff2/ttf file to rmc/assets/fonts/ +# 2. Add an entry to FONTS below with a unique key +# 3. Reference it in FONT_CONFIG or CSS generation as needed + +# Font directory within assets +FONTS_DIR = ASSETS / "fonts" + +# Font definitions - maps font keys to their configuration +# Each entry specifies: +# - file: filename in the fonts directory +# - family: CSS font-family name +# - style: CSS font-style (normal, italic) +# - weight_range: CSS font-weight range for variable fonts +# - format: font format for CSS (woff2, truetype) +# - fallback: optional fallback font config if primary doesn't exist +FONTS = { + "sans": { + "file": "reMarkableSans.woff2", + "family": "reMarkable Sans VF", + "style": "normal", + "weight_range": "300 700", + "format": "woff2", + "fallback": { + "file": "NotoSans-VariableFont_wdth,wght.ttf", + "family": "Noto Sans", + "format": "truetype", + "weight_range": "100 900", + }, + }, + "sans_italic": { + "file": "reMarkableSansItalic.woff2", # doesn't exist, will trigger fallback + "family": "reMarkable Sans VF", + "style": "italic", + "weight_range": "300 700", + "format": "woff2", + "fallback": { + "file": "NotoSans-Italic-VariableFont_wdth,wght.ttf", + "family": "Noto Sans", + "format": "truetype", + "weight_range": "100 900", + }, + }, + "serif": { + "file": "reMarkableSerif.woff2", + "family": "reMarkable Serif VF", + "style": "normal", + "weight_range": "300 800", + "format": "woff2", + "fallback": { + "file": "EBGaramond-VariableFont_wght.ttf", + "family": "EB Garamond", + "format": "truetype", + "weight_range": "400 800", + }, + }, + "serif_italic": { + "file": "reMarkableSerifItalic.woff2", + "family": "reMarkable Serif VF", + "style": "italic", + "weight_range": "300 800", + "format": "woff2", + "fallback": { + "file": "EBGaramond-Italic-VariableFont_wght.ttf", + "family": "EB Garamond", + "format": "truetype", + "weight_range": "400 800", + }, + }, +} + + +def _resolve_font_config(font_key: str) -> tp.Optional[dict]: + """Resolve font config, falling back if primary font file doesn't exist.""" + font_config = FONTS.get(font_key) + if font_config is None: + return None + + primary_path = FONTS_DIR / font_config["file"] + if primary_path.exists(): + return { + "file": font_config["file"], + "family": font_config["family"], + "style": font_config["style"], + "weight_range": font_config["weight_range"], + "format": font_config.get("format", "woff2"), + } + + # Try fallback + fallback = font_config.get("fallback") + if fallback: + fallback_path = FONTS_DIR / fallback["file"] + if fallback_path.exists(): + return { + "file": fallback["file"], + "family": fallback["family"], + "style": font_config["style"], # inherit style from primary + "weight_range": fallback["weight_range"], + "format": fallback["format"], + } + + return None + + +def _get_resolved_font_family(font_key: str) -> str: + """Get the resolved font family name for a font key.""" + resolved = _resolve_font_config(font_key) + if resolved: + return resolved["family"] + # Fallback to original config family + return FONTS[font_key]["family"] if FONTS.get(font_key) else "sans-serif" + + +# Convenience aliases for font families (resolved dynamically) +# These are initialized lazily to allow fallback resolution +_font_family_serif: tp.Optional[str] = None +_font_family_sans: tp.Optional[str] = None + + +def _ensure_font_families(): + """Ensure font family globals are initialized.""" + global _font_family_serif, _font_family_sans + if _font_family_serif is None: + _font_family_serif = _get_resolved_font_family("serif") + if _font_family_sans is None: + _font_family_sans = _get_resolved_font_family("sans") + + +def get_font_family_serif() -> str: + """Get the resolved serif font family name.""" + _ensure_font_families() + return _font_family_serif + + +def get_font_family_sans() -> str: + """Get the resolved sans font family name.""" + _ensure_font_families() + return _font_family_sans + + +# Keep these for backwards compatibility but they may not reflect fallback fonts +# Use get_font_family_serif() and get_font_family_sans() for resolved names +FONT_FAMILY_SERIF = FONTS["serif"]["family"] +FONT_FAMILY_SANS = FONTS["sans"]["family"] + +# Font weight ranges (derived from FONTS config) +FONT_WEIGHT_RANGE_SERIF = FONTS["serif"]["weight_range"] +FONT_WEIGHT_RANGE_SANS = FONTS["sans"]["weight_range"] + +# Font configuration per paragraph style +# Each entry contains: family ('serif' or 'sans'), size (in points), weight +FONT_CONFIG = { + "heading": { + "family": "serif", + "size": 15, + "weight": 450, + }, + "bold": { + "family": "sans", + "size": 8.3, + "weight": 500, + }, + "plain": { + "family": "sans", + "size": 7.7, + "weight": 400, + }, + # These styles inherit from plain + "bullet": { + "family": "sans", + "size": 7.7, + "weight": 400, + }, + "bullet2": { + "family": "sans", + "size": 7.7, + "weight": 400, + }, + "checkbox": { + "family": "sans", + "size": 7.7, + "weight": 400, + }, + "checkbox_checked": { + "family": "sans", + "size": 7.7, + "weight": 400, + }, + "checkbox2": { + "family": "sans", + "size": 7.7, + "weight": 400, + }, + "checkbox2_checked": { + "family": "sans", + "size": 7.7, + "weight": 400, + }, + "numbered": { + "family": "sans", + "size": 7.7, + "weight": 400, + }, + "numbered2": { + "family": "sans", + "size": 7.7, + "weight": 400, + }, +} + +# Inline formatting weights +FONT_WEIGHT_INLINE_BOLD = 700 + + +# ============================================================================= +# FONT METRICS AND TEXT MEASUREMENT +# ============================================================================= + +def _get_font_size_pt(style: si.ParagraphStyle) -> float: + """Get font size in points for a paragraph style.""" + style_name = style.name.lower() + config = FONT_CONFIG.get(style_name, FONT_CONFIG["plain"]) + return config["size"] + + +def _get_font_family(style: si.ParagraphStyle) -> str: + """Get font family key ('serif' or 'sans') for a paragraph style.""" + style_name = style.name.lower() + config = FONT_CONFIG.get(style_name, FONT_CONFIG["plain"]) + return config["family"] + + +# Font metrics - loaded lazily +_font_metrics: tp.Optional[dict] = None + +def _load_font_metrics(): + """Load font metrics from font files using fonttools. + + Loads all fonts defined in FONTS configuration, using fallbacks as needed. + """ + global _font_metrics + if _font_metrics is not None: + return _font_metrics + + try: + from fontTools.ttLib import TTFont + except ImportError: + _logger.warning("fonttools not available, using estimated character widths") + _font_metrics = {} + return _font_metrics + + _font_metrics = {} + + for font_key in FONTS.keys(): + resolved = _resolve_font_config(font_key) + if resolved is None: + continue + font_path = FONTS_DIR / resolved["file"] + try: + font = TTFont(font_path) + _font_metrics[font_key] = { + 'cmap': font.getBestCmap(), + 'hmtx': font['hmtx'], + 'upem': font['head'].unitsPerEm, + } + except Exception as e: + _logger.warning(f"Failed to load {font_key} font from {resolved['file']}: {e}") + + return _font_metrics + +def get_char_width_screen(char: str, style: si.ParagraphStyle) -> float: + """Get character width in screen units using font metrics.""" + if not char: + return 0.0 + + metrics = _load_font_metrics() + + font_key = _get_font_family(style) + + if font_key not in metrics: + # Fallback to estimated widths based on font size + font_size = _get_font_size_pt(style) + # Approximate: average char width is ~0.5 * font size, scaled to screen units + return font_size * 0.5 * device.rmc_config.screen_dpi / 72 + + font = metrics[font_key] + cmap = font['cmap'] + hmtx = font['hmtx'] + upem = font['upem'] + + # Get glyph width in font units + glyph_name = cmap.get(ord(char)) + if glyph_name: + width_fu = hmtx[glyph_name][0] + else: + width_fu = 500 # fallback + + # Convert to screen units: font_units * (font_size_screen / upem) + font_size_pt = _get_font_size_pt(style) + font_size_screen = font_size_pt * device.rmc_config.screen_dpi / 72 + + return width_fu * font_size_screen / upem + + +def get_text_width_screen(text: str, style: si.ParagraphStyle) -> float: + """Get total width of text string in screen units.""" + return sum(get_char_width_screen(c, style) for c in text) + + +def wrap_text_to_width(text: str, max_width: float, style: si.ParagraphStyle) -> tp.List[str]: + """ + Wrap text to fit within max_width, breaking at word boundaries. + Preserves leading whitespace on the first line. + + :param text: The text to wrap + :param max_width: Maximum width in screen units + :param style: Paragraph style (affects character widths) + :return: List of lines + """ + if not text: + return [''] + + # Preserve leading whitespace + leading_spaces = '' + stripped_text = text.lstrip(' ') + if len(stripped_text) < len(text): + leading_spaces = text[:len(text) - len(stripped_text)] + + # Now wrap the stripped text + words = stripped_text.split(' ') + lines = [] + current_line = '' + current_width = 0.0 + space_width = get_char_width_screen(' ', style) + + # Account for leading spaces in the first line's width + leading_width = get_text_width_screen(leading_spaces, style) if leading_spaces else 0.0 + first_line = True + + for word in words: + if not word: # Skip empty strings from consecutive spaces + continue + + word_width = get_text_width_screen(word, style) + + if current_line: + # Check if adding this word (with space) would exceed width + test_width = current_width + space_width + word_width + if test_width <= max_width: + current_line += ' ' + word + current_width = test_width + else: + # Start new line + lines.append(current_line) + current_line = word + current_width = word_width + first_line = False + else: + # First word on line + if first_line and leading_spaces: + current_line = leading_spaces + word + current_width = leading_width + word_width + else: + current_line = word + current_width = word_width + + # Don't forget the last line + if current_line: + lines.append(current_line) + + return lines if lines else [''] diff --git a/src/rmc/exporters/svg/layout.py b/src/rmc/exporters/svg/layout.py new file mode 100644 index 0000000..c94a8d7 --- /dev/null +++ b/src/rmc/exporters/svg/layout.py @@ -0,0 +1,493 @@ +"""Text layout calculation, anchor positioning, and bounding box computation.""" + +import logging +import typing as tp + +from rmscene import CrdtId +from rmscene import scene_items as si +from rmscene.text import TextDocument + +# Import from sibling modules +from . import device +from . import fonts +from .paragraph_styles import ( + ParagraphStyleConfig, + get_style_config, + BULLET_SECTION_GAP, + TEXT_WRAP_MARGIN, + TEXT_TOP_Y, +) + +_logger = logging.getLogger(__name__) + +# Special anchor IDs for text document boundaries +TEXT_DOCUMENT_TOP_Y_CRDT_ID = 0xfffffffffffe +TEXT_DOCUMENT_BOTTOM_Y_CRDT_ID = 0xffffffffffff + + +class ParagraphLayoutInfo(tp.TypedDict): + """Layout information for a single paragraph, calculated during text flow.""" + + # Paragraph identity + paragraph: object # TextDocument paragraph object + paragraph_index: int + prev_style: tp.Optional[si.ParagraphStyle] + + # Y-offset tracking (core layout calculation) + y_offset_before: float # y_offset before processing this paragraph + y_offset_after: float # y_offset after processing this paragraph (includes all spacing) + line_height: float + soft_line_height: float + + # Style configuration (encapsulates all style-specific behavior) + style_config: ParagraphStyleConfig + is_list_style: bool + prev_para_is_list_style: bool # is previous paragraph a list? + + # Word wrapping calculations + available_width: float + content: str # str(paragraph) + num_soft_breaks: int # Count of LINE_SEPARATOR characters + segments: tp.List[str] # content.split(LINE_SEPARATOR) + total_wrapped_lines: int + extra_wrapped_lines: int # Beyond segments count + + # Spacing (applied to y_offset_after) + space_after: float # style_config.space_after (e.g., after headings) + item_spacing: float # style_config.item_spacing + + +def calculate_paragraph_layouts( + text: tp.Optional[si.Text], +) -> tp.Iterator[ParagraphLayoutInfo]: + """ + Generator that yields layout information for each paragraph in the text. + + The calculation follows the reMarkable text layout algorithm: + 1. Check for list style transitions (add BULLET_SECTION_GAP if transitioning from non-list to list) + 2. Add line_height to y_offset + 3. Count soft line breaks (LINE_SEPARATOR \\u2028) and add their height + 4. Calculate available width after accounting for list indentation (checkbox/bullet/numbered) + 5. Perform word wrapping to determine extra wrapped lines + 6. Add extra spacing (space_after for headings, item_spacing for lists) + + Each paragraph's y_offset_after includes ALL spacing (soft breaks, wrapped lines, + space_after, item_spacing), ready for the next paragraph or for positioning. + + :param text: Text object to process (may be None) + :yield: ParagraphLayoutInfo for each paragraph in document order + """ + LINE_SEPARATOR = '\u2028' + + if text is None: + return + + doc = TextDocument.from_scene_item(text) + if not doc.contents: + return + + y_offset = TEXT_TOP_Y + prev_style = None + prev_style_config: tp.Optional[ParagraphStyleConfig] = None + + for i, p in enumerate(doc.contents): + # Get style configuration - replaces LINE_HEIGHTS/SOFT_LINE_HEIGHTS lookups + style_config = get_style_config(p.style.value) + line_height = style_config.line_height + soft_line_height = style_config.soft_line_height + + # Add section gap when transitioning from non-list to list style + is_list_style = style_config.is_list_style + prev_was_list_style = prev_style_config.is_list_style if prev_style_config else False + if is_list_style and not prev_was_list_style and prev_style is not None: + y_offset += BULLET_SECTION_GAP + + # Capture state AFTER bullet section gap but BEFORE line_height + # This matches how draw_text calculates ypos = text.pos_y + y_offset (after gap, after line_height) + y_offset_before = y_offset + + # Advance by paragraph line height + y_offset += line_height + + # Get paragraph content for analysis + content = str(p) + + # Account for soft line breaks (LINE_SEPARATOR) + num_soft_breaks = content.count(LINE_SEPARATOR) + if num_soft_breaks > 0: + y_offset += num_soft_breaks * soft_line_height + + # Calculate available width - use style_config.width_reduction() + available_width = text.width - TEXT_WRAP_MARGIN + available_width -= style_config.width_reduction(device.rmc_config.scale) + + # Calculate word wrapping + # Split by LINE_SEPARATOR first, then wrap each segment + segments = content.split(LINE_SEPARATOR) + total_wrapped_lines = 0 + for segment in segments: + if segment.strip(): + wrapped = fonts.wrap_text_to_width(segment, available_width, p.style.value) + total_wrapped_lines += len(wrapped) + else: + total_wrapped_lines += 1 # Empty segment counts as one line + + # Calculate extra wrapped lines beyond the segment count + extra_wrapped_lines = total_wrapped_lines - len(segments) + if extra_wrapped_lines > 0: + y_offset += extra_wrapped_lines * soft_line_height + + # Add extra space after certain paragraph styles (e.g., headings) + space_after = style_config.space_after + if space_after > 0: + y_offset += space_after + + # Add list item spacing + item_spacing = style_config.item_spacing + if item_spacing > 0: + y_offset += item_spacing + + # Yield complete layout information for this paragraph + yield { + 'paragraph': p, + 'paragraph_index': i, + 'prev_style': prev_style, + 'y_offset_before': y_offset_before, + 'y_offset_after': y_offset, + 'line_height': line_height, + 'soft_line_height': soft_line_height, + 'is_list_style': is_list_style, + 'prev_para_is_list_style': prev_was_list_style, + 'style_config': style_config, + 'available_width': available_width, + 'content': content, + 'num_soft_breaks': num_soft_breaks, + 'segments': segments, + 'total_wrapped_lines': total_wrapped_lines, + 'extra_wrapped_lines': extra_wrapped_lines, + 'space_after': space_after, + 'item_spacing': item_spacing, + } + + prev_style = p.style.value + prev_style_config = style_config + + +def get_text_bounding_box(text: tp.Optional[si.Text]) -> tp.Tuple[float, float, float, float]: + """ + Get the bounding box of the text content. + + Uses calculate_paragraph_layouts() to ensure layout calculation matches + build_anchor_pos() and draw_text() exactly. + + :return: x_min, x_max, y_min, y_max in screen units + """ + if text is None: + return (0, 0, 0, 0) + + # Collect Y positions as paragraphs are laid out + y_positions = [] + + for layout in calculate_paragraph_layouts(text): + # Record Y position after this paragraph's line height is added + # (before soft breaks and wrapping) + y_base = text.pos_y + layout['y_offset_before'] + layout['line_height'] + y_positions.append(y_base) + + # If there were soft breaks or extra wrapped lines, record final position too + if layout['num_soft_breaks'] > 0 or layout['extra_wrapped_lines'] > 0: + y_positions.append(text.pos_y + layout['y_offset_after']) + + if not y_positions: + return (0, 0, 0, 0) + + # Text spans from pos_x to pos_x + width + x_min = text.pos_x + x_max = text.pos_x + text.width + + # Y range from first to last text line (with padding for text height) + y_min = y_positions[0] - 50 # approximate text ascent + y_max = y_positions[-1] + 20 # approximate text descent + + return (x_min, x_max, y_min, y_max) + + +def build_anchor_pos(text: tp.Optional[si.Text], extended: bool = False): + """ + Find the anchor positions for every text node, including special top and + bottom of text anchors. + + Uses calculate_paragraph_layouts() to ensure layout calculation matches + get_text_bounding_box() and draw_text() exactly. + + :param text: the root text of the remarkable file + :param extended: if True, return all computed values; if False (default), return only anchor_pos for backward compatibility + :return: If extended=False: anchor_pos dict + If extended=True: (anchor_pos, newline_offsets, anchor_x_pos, anchor_soft_offset) where: + - anchor_pos maps CrdtId -> y position (includes soft line offset) + - newline_offsets maps newline CrdtIds -> offset to subtract (line height + prev SPACE_AFTER) + - anchor_x_pos maps CrdtId -> x position (for all characters) + - anchor_soft_offset maps CrdtId -> soft line offset applied to that character + """ + LINE_SEPARATOR = '\u2028' + + # Initialize tracking structures + anchor_pos = { + CrdtId(0, TEXT_DOCUMENT_TOP_Y_CRDT_ID): 100, + CrdtId(0, TEXT_DOCUMENT_BOTTOM_Y_CRDT_ID): 100, + } + newline_offsets: tp.Dict[CrdtId, int] = {} + anchor_x_pos: tp.Dict[CrdtId, float] = {} + anchor_soft_offset: tp.Dict[CrdtId, float] = {} + + if text is None: + if extended: + return anchor_pos, newline_offsets, anchor_x_pos, anchor_soft_offset + else: + return anchor_pos + + # Track special anchor positions + last_content_y_offset = TEXT_TOP_Y + first_line_height = None + + for layout in calculate_paragraph_layouts(text): + p = layout['paragraph'] + i = layout['paragraph_index'] + + # Capture first line height for top anchor + if first_line_height is None: + first_line_height = layout['line_height'] + + # Y position for this paragraph (after line_height is added) + ypos = text.pos_y + layout['y_offset_before'] + layout['line_height'] + anchor_pos[p.start_id] = ypos + + # Track newline offsets for paragraph boundaries + if i > 0 and layout['prev_style'] is not None: + prev_style_config = get_style_config(layout['prev_style']) + prev_space_after = prev_style_config.space_after + offset = layout['line_height'] + prev_space_after + # When transitioning from non-list to list, BULLET_SECTION_GAP was added to y_offset. + # Include it in newline_offset so strokes anchored to paragraph start align correctly. + if layout['is_list_style'] and not layout['prev_para_is_list_style']: + offset += BULLET_SECTION_GAP + newline_offsets[p.start_id] = offset + + # Track character-level positions (unique to build_anchor_pos) + # This is the only part that can't be shared, as it needs per-character iteration + current_soft_offset = 0 + cumulative_x = 0.0 + available_width = layout['available_width'] + soft_line_height = layout['soft_line_height'] + + # Track word boundaries for proper wrapping + current_word_start_x = 0.0 + current_word_chars = [] # List of (id, char) tuples in current word + + for subp in p.contents: + for j, k in enumerate(subp.i): + char = subp.s[j] if j < len(subp.s) else '' + + if char == LINE_SEPARATOR: + # Explicit soft line break + # First, finalize positions for any pending word + for word_k, word_char in current_word_chars: + anchor_pos[word_k] = ypos + current_soft_offset + current_word_chars = [] + + anchor_pos[k] = ypos + current_soft_offset + anchor_x_pos[k] = text.pos_x + cumulative_x + anchor_soft_offset[k] = current_soft_offset + + current_soft_offset += soft_line_height + cumulative_x = 0.0 + current_word_start_x = 0.0 + elif char == ' ': + # Space - finalize current word and check if next word needs to wrap + for word_k, word_char in current_word_chars: + anchor_pos[word_k] = ypos + current_soft_offset + current_word_chars = [] + + anchor_pos[k] = ypos + current_soft_offset + anchor_x_pos[k] = text.pos_x + cumulative_x + anchor_soft_offset[k] = current_soft_offset + + cumulative_x += fonts.get_char_width_screen(char, p.style.value) + current_word_start_x = cumulative_x + else: + # Regular character - add to current word + char_width = fonts.get_char_width_screen(char, p.style.value) + + # Check if adding this character would exceed the line width + # If so, wrap the entire current word to the next line + if cumulative_x + char_width > available_width and current_word_start_x > 0: + # Wrap: move to next line + current_soft_offset += soft_line_height + + # Recalculate X positions for current word on new line + new_x = 0.0 + for word_k, word_char in current_word_chars: + anchor_x_pos[word_k] = text.pos_x + new_x + anchor_pos[word_k] = ypos + current_soft_offset + anchor_soft_offset[word_k] = current_soft_offset + new_x += fonts.get_char_width_screen(word_char, p.style.value) + + cumulative_x = new_x + current_word_start_x = 0.0 + + # Add this character + anchor_x_pos[k] = text.pos_x + cumulative_x + anchor_soft_offset[k] = current_soft_offset + current_word_chars.append((k, char)) + cumulative_x += char_width + + # Finalize any remaining word + for word_k, word_char in current_word_chars: + anchor_pos[word_k] = ypos + current_soft_offset + + last_content_y_offset = layout['y_offset_after'] + + # Update special anchors + doc = TextDocument.from_scene_item(text) + if doc.contents and first_line_height is not None: + first_y_offset = TEXT_TOP_Y + first_line_height + anchor_pos[CrdtId(0, TEXT_DOCUMENT_TOP_Y_CRDT_ID)] = text.pos_y + first_y_offset + anchor_pos[CrdtId(0, TEXT_DOCUMENT_BOTTOM_Y_CRDT_ID)] = text.pos_y + last_content_y_offset + + if extended: + return anchor_pos, newline_offsets, anchor_x_pos, anchor_soft_offset + else: + # Backward compatibility: return anchor_pos with newline_offsets pre-applied + adjusted_anchor_pos = dict(anchor_pos) + for crdt_id, offset in newline_offsets.items(): + if crdt_id in adjusted_anchor_pos: + adjusted_anchor_pos[crdt_id] -= offset + return adjusted_anchor_pos + + +def get_anchor(item: si.Group, anchor_pos, newline_offsets=None, text_pos_x=None, anchor_x_pos=None, anchor_soft_offset=None): + """ + Get the anchor position for a group. + + :param item: The group to get anchor for + :param anchor_pos: Map of CrdtId -> y position + :param newline_offsets: Map of newline CrdtIds -> offset to subtract + :param text_pos_x: X position of text block (fallback for TEXT_CHAR anchors) + :param anchor_x_pos: Map of CrdtId -> x position for characters + :param anchor_soft_offset: Map of CrdtId -> soft line offset for TEXT_CHAR adjustment + """ + if newline_offsets is None: + newline_offsets = {} + if anchor_x_pos is None: + anchor_x_pos = {} + if anchor_soft_offset is None: + anchor_soft_offset = {} + + anchor_x = 0.0 + anchor_y = 0.0 + if item.anchor_id is not None: + assert item.anchor_origin_x is not None + anchor_x = item.anchor_origin_x.value + + # For TEXT_CHAR anchors (anchor_type=1) with anchor_origin_x=0, + # use the computed character X position + if item.anchor_type is not None and item.anchor_type.value == 1 and anchor_x == 0: + if item.anchor_id.value in anchor_x_pos: + anchor_x = anchor_x_pos[item.anchor_id.value] + _logger.debug("TEXT_CHAR anchor: using char x=%.1f for %s", + anchor_x, item.anchor_id.value) + elif text_pos_x is not None: + anchor_x = text_pos_x + _logger.debug("TEXT_CHAR anchor: fallback to text_pos_x=%.1f", text_pos_x) + + if item.anchor_id.value in anchor_pos: + anchor_y = anchor_pos[item.anchor_id.value] + + # For TEXT_CHAR anchors, subtract excess soft offset beyond first line + # This is because stroke coordinates are relative to the first content line, + # not the specific soft line the anchor character is on + if item.anchor_type is not None and item.anchor_type.value == 1: + soft_off = anchor_soft_offset.get(item.anchor_id.value, 0) + # Only subtract offset beyond first line (assume first line is at soft_off = 60) + # This brings anchors from line 2+ back to line 1 + first_line_offset = 60 # SOFT_LINE_HEIGHTS default for first content line + if soft_off > first_line_offset: + excess = soft_off - first_line_offset + anchor_y -= excess + _logger.debug("TEXT_CHAR anchor: subtracting excess soft_offset=%.1f for %s", + excess, item.anchor_id.value) + + # Apply newline_offset for paragraph boundary anchors + if item.anchor_id.value in newline_offsets: + # For type 1 (TEXT_CHAR) anchors at paragraph starts, check if the paragraph has content + # Empty paragraphs should NOT have newline_offset applied for type 1 anchors + # because the stroke is likely meant to align with content below, not above + should_apply_newline_offset = True + + if item.anchor_type is not None and item.anchor_type.value == 1: + # Check if this paragraph has content by looking for character positions + # near the anchor ID in anchor_x_pos + anchor_part1 = item.anchor_id.value.part1 + anchor_part2 = item.anchor_id.value.part2 + has_content = any( + CrdtId(anchor_part1, anchor_part2 + offset) in anchor_x_pos + for offset in range(1, 15) + ) + if not has_content: + should_apply_newline_offset = False + _logger.debug("TEXT_CHAR anchor at empty para: %s -> y=%.1f (no newline shift)", + item.anchor_id.value, anchor_y) + + if should_apply_newline_offset: + anchor_y -= newline_offsets[item.anchor_id.value] + _logger.debug("Group anchor: %s -> y=%.1f (newline, shifted up by %.1f)", + item.anchor_id.value, anchor_y, newline_offsets[item.anchor_id.value]) + else: + _logger.debug("Group anchor: %s -> y=%.1f", item.anchor_id.value, anchor_y) + else: + _logger.warning("Group anchor: %s is unknown!", item.anchor_id.value) + + return anchor_x, anchor_y + + +def get_bounding_box(item: si.Group, + anchor_pos: tp.Dict[CrdtId, int], + newline_offsets: tp.Dict[CrdtId, int] = None, + text_pos_x: float = None, + anchor_x_pos: tp.Dict[CrdtId, float] = None, + anchor_soft_offset: tp.Dict[CrdtId, float] = None, + default: tp.Tuple[int, int, int, int] = None) \ + -> tp.Tuple[int, int, int, int]: + """Get the bounding box of the given item. + + Default bounds are device dimensions, expanding to include any content beyond. + """ + if newline_offsets is None: + newline_offsets = {} + if anchor_x_pos is None: + anchor_x_pos = {} + if anchor_soft_offset is None: + anchor_soft_offset = {} + # Compute default based on current device settings (can't use in parameter default + # because those are evaluated at function definition time, not call time) + if default is None: + default = (- device.rmc_config.screen_width // 2, device.rmc_config.screen_width // 2, 0, device.rmc_config.screen_height) + + x_min, x_max, y_min, y_max = default + + for child_id in item.children: + child = item.children[child_id] + if isinstance(child, si.Group): + anchor_x, anchor_y = get_anchor(child, anchor_pos, newline_offsets, text_pos_x, anchor_x_pos, anchor_soft_offset) + x_min_t, x_max_t, y_min_t, y_max_t = get_bounding_box(child, anchor_pos, newline_offsets, text_pos_x, anchor_x_pos, anchor_soft_offset, (0, 0, 0, 0)) + x_min = min(x_min, x_min_t + anchor_x) + x_max = max(x_max, x_max_t + anchor_x) + y_min = min(y_min, y_min_t + anchor_y) + y_max = max(y_max, y_max_t + anchor_y) + elif isinstance(child, si.Line): + x_min = min([x_min] + [p.x for p in child.points]) + x_max = max([x_max] + [p.x for p in child.points]) + y_min = min([y_min] + [p.y for p in child.points]) + y_max = max([y_max] + [p.y for p in child.points]) + + return x_min, x_max, y_min, y_max diff --git a/src/rmc/exporters/svg/paragraph_styles.py b/src/rmc/exporters/svg/paragraph_styles.py new file mode 100644 index 0000000..07cfd66 --- /dev/null +++ b/src/rmc/exporters/svg/paragraph_styles.py @@ -0,0 +1,413 @@ +"""Paragraph style configuration classes for SVG text rendering. + +This module consolidates all paragraph style-specific behavior into a class hierarchy, +eliminating scattered constants and if/elif chains from layout.py and rendering.py. +""" + +import typing as tp +from abc import ABC, abstractmethod + +from rmscene import scene_items as si + +if tp.TYPE_CHECKING: + from .device import SvgRenderConfig + + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +# Base line heights (in screen units) +BASE_LINE_HEIGHT = 69.5 +SOFT_LINE_BASE = 60 +SOFT_LINE_SMALL = 40 + +# Checkbox constants +CHECKBOX_SIZE = 12 # Size in points +CHECKBOX_TEXT_GAP = 4 # Gap between checkbox and text in points +CHECKBOX_ITEM_SPACING = 30 # Extra spacing between checkbox items in screen units +CHECKBOX2_INDENT = 12 # Indentation for nested checkbox in points + +# Bullet constants +BULLET_SIZE = 4 # Bullet circle diameter in points +BULLET_INDENT = 12 # Indentation for bullet text in points +BULLET2_INDENT = 24 # Indentation for nested bullet text in points +BULLET_GAP = 6 # Gap between bullet and text in points +BULLET_ITEM_SPACING = 30 # Extra spacing between bullet items in screen units +BULLET_SECTION_GAP = 30 # Gap before bullet section starts (from non-bullet paragraph) + +# Numbered list constants +NUMBERED_INDENT = 12 # Indentation for numbered list text in points +NUMBERED2_INDENT = 24 # Indentation for nested numbered list text in points +NUMBERED_GAP = 4 # Gap between number and text in points +NUMBERED2_OFFSET = 12 # Offset for nested numbered list number position + +# Text wrap margin +TEXT_WRAP_MARGIN = 236 # Approximate margin in screen units + +# Text positioning +TEXT_TOP_Y = -88 + +class ParagraphStyleConfig(ABC): + """Abstract base class for paragraph style configurations. + + Each subclass encapsulates: + - Layout calculations (line heights, spacing, width reductions) + - Rendering behavior (marker drawing) + """ + + @property + @abstractmethod + def line_height(self) -> float: + """Vertical spacing for paragraph (screen units).""" + pass + + @property + def soft_line_height(self) -> float: + """Vertical spacing for soft line breaks (screen units).""" + return SOFT_LINE_SMALL # Default for list styles + + @property + def space_after(self) -> float: + """Extra spacing after paragraph (screen units). Only HEADING has non-zero.""" + return 0.0 + + @property + def item_spacing(self) -> float: + """Extra spacing after list items (screen units).""" + return 0.0 + + @property + def is_list_style(self) -> bool: + """Whether this style is a list style (for section gap logic).""" + return False + + @property + def needs_counter(self) -> bool: + """Whether this style uses a counter (for numbered lists).""" + return False + + @property + def counter_key(self) -> tp.Optional[str]: + """Key for counter tracking (e.g., 'numbered', 'numbered2'). None if no counter.""" + return None + + def width_reduction(self, scale: float) -> float: + """How much width (in screen units) the marker consumes. + + :param scale: Device scale factor (72.0 / DPI) + :return: Width reduction in screen units + """ + return 0.0 + + def text_x_offset(self, scale: float) -> float: + """X offset for text after marker (in screen units). + + :param scale: Device scale factor (72.0 / DPI) + :return: X offset in screen units + """ + return 0.0 + + def draw_marker( + self, + xpos: float, + ypos: float, + output: tp.TextIO, + rmc_config: "SvgRenderConfig", + counter: tp.Optional[int] = None, + ) -> None: + """Render the marker SVG (checkbox/bullet/number). + + :param xpos: Text block X position in screen units + :param ypos: Current line Y position in screen units + :param output: File-like object to write SVG to + :param rmc_config: Device configuration for coordinate conversion + :param counter: Counter value for numbered lists (1, 2, 3...) + """ + pass # Default: no marker + + +class PlainStyleConfig(ParagraphStyleConfig): + """Normal text style.""" + + @property + def line_height(self) -> float: + return BASE_LINE_HEIGHT + + @property + def soft_line_height(self) -> float: + return SOFT_LINE_BASE + + +class BoldStyleConfig(ParagraphStyleConfig): + """Bold text style.""" + + @property + def line_height(self) -> float: + return BASE_LINE_HEIGHT + + @property + def soft_line_height(self) -> float: + return SOFT_LINE_SMALL + + +class HeadingStyleConfig(ParagraphStyleConfig): + """Heading style with large serif text and extra space after.""" + + @property + def line_height(self) -> float: + return BASE_LINE_HEIGHT * 2 + + @property + def soft_line_height(self) -> float: + return SOFT_LINE_BASE + + @property + def space_after(self) -> float: + return BASE_LINE_HEIGHT * 0.3 + + +class Bullet1StyleConfig(ParagraphStyleConfig): + """First-level bullet list item (circle marker).""" + + @property + def line_height(self) -> float: + return BASE_LINE_HEIGHT / 2 + + @property + def item_spacing(self) -> float: + return BULLET_ITEM_SPACING + + @property + def is_list_style(self) -> bool: + return True + + def width_reduction(self, scale: float) -> float: + return BULLET_INDENT / scale + + def text_x_offset(self, scale: float) -> float: + return BULLET_INDENT / scale + + def draw_marker(self, xpos, ypos, output, rmc_config, counter=None): + bullet_x = rmc_config.xx(xpos) + BULLET_INDENT - BULLET_SIZE - BULLET_GAP + bullet_y = rmc_config.yy(ypos) - BULLET_SIZE / 2 - 1 + output.write( + f'\t\t\t\n' + ) + + +class Bullet2StyleConfig(ParagraphStyleConfig): + """Second-level bullet list item (dash marker).""" + + @property + def line_height(self) -> float: + return BASE_LINE_HEIGHT / 2 + + @property + def item_spacing(self) -> float: + return BULLET_ITEM_SPACING + + @property + def is_list_style(self) -> bool: + return True + + def width_reduction(self, scale: float) -> float: + return BULLET2_INDENT / scale + + def text_x_offset(self, scale: float) -> float: + return BULLET2_INDENT / scale + + def draw_marker(self, xpos, ypos, output, rmc_config, counter=None): + bullet_x = rmc_config.xx(xpos) + BULLET2_INDENT - BULLET_SIZE - BULLET_GAP + bullet_y = rmc_config.yy(ypos) - BULLET_SIZE / 2 - 1 + output.write( + f'\t\t\t\n' + ) + + +class Checkbox1StyleConfig(ParagraphStyleConfig): + """First-level checkbox (checked or unchecked).""" + + def __init__(self, is_checked: bool = False): + self._is_checked = is_checked + + @property + def line_height(self) -> float: + return BASE_LINE_HEIGHT / 2 + + @property + def item_spacing(self) -> float: + return CHECKBOX_ITEM_SPACING + + @property + def is_list_style(self) -> bool: + return True + + @property + def is_checked(self) -> bool: + return self._is_checked + + def width_reduction(self, scale: float) -> float: + return (CHECKBOX_SIZE + CHECKBOX_TEXT_GAP) / scale + + def text_x_offset(self, scale: float) -> float: + return (CHECKBOX_SIZE + CHECKBOX_TEXT_GAP) / scale + + def draw_marker(self, xpos, ypos, output, rmc_config, counter=None): + checkbox_id = "checkbox-checked" if self._is_checked else "checkbox-unchecked" + checkbox_x = rmc_config.xx(xpos) + checkbox_y = rmc_config.yy(ypos) - CHECKBOX_SIZE + 2 + output.write( + f'\t\t\t\n' + ) + + +class Checkbox2StyleConfig(ParagraphStyleConfig): + """Second-level (nested) checkbox (checked or unchecked).""" + + def __init__(self, is_checked: bool = False): + self._is_checked = is_checked + + @property + def line_height(self) -> float: + return BASE_LINE_HEIGHT / 2 + + @property + def item_spacing(self) -> float: + return CHECKBOX_ITEM_SPACING + + @property + def is_list_style(self) -> bool: + return True + + @property + def is_checked(self) -> bool: + return self._is_checked + + def width_reduction(self, scale: float) -> float: + return (CHECKBOX2_INDENT + CHECKBOX_SIZE + CHECKBOX_TEXT_GAP) / scale + + def text_x_offset(self, scale: float) -> float: + return (CHECKBOX2_INDENT + CHECKBOX_SIZE + CHECKBOX_TEXT_GAP) / scale + + def draw_marker(self, xpos, ypos, output, rmc_config, counter=None): + checkbox_id = "checkbox-checked" if self._is_checked else "checkbox-unchecked" + checkbox_x = rmc_config.xx(xpos) + CHECKBOX2_INDENT + checkbox_y = rmc_config.yy(ypos) - CHECKBOX_SIZE + 2 + output.write( + f'\t\t\t\n' + ) + +class Numbered1StyleConfig(ParagraphStyleConfig): + """First-level numbered list item.""" + + @property + def line_height(self) -> float: + return BASE_LINE_HEIGHT / 2 + + @property + def item_spacing(self) -> float: + return BULLET_ITEM_SPACING + + @property + def is_list_style(self) -> bool: + return True + + @property + def needs_counter(self) -> bool: + return True + + @property + def counter_key(self) -> str: + return "numbered" + + def width_reduction(self, scale: float) -> float: + return NUMBERED_INDENT / scale + + def text_x_offset(self, scale: float) -> float: + return NUMBERED_INDENT / scale + + def draw_marker(self, xpos, ypos, output, rmc_config, counter=None): + number_text = f"{counter}." + number_x = rmc_config.xx(xpos) + number_y = rmc_config.yy(ypos) + output.write( + f'\t\t\t{number_text}\n' + ) + + +class Numbered2StyleConfig(ParagraphStyleConfig): + """Second-level (nested) numbered list item.""" + + @property + def line_height(self) -> float: + return BASE_LINE_HEIGHT / 2 + + @property + def item_spacing(self) -> float: + return BULLET_ITEM_SPACING + + @property + def is_list_style(self) -> bool: + return True + + @property + def needs_counter(self) -> bool: + return True + + @property + def counter_key(self) -> str: + return "numbered2" + + def width_reduction(self, scale: float) -> float: + return NUMBERED2_INDENT / scale + + def text_x_offset(self, scale: float) -> float: + return NUMBERED2_INDENT / scale + + def draw_marker(self, xpos, ypos, output, rmc_config, counter=None): + number_text = f"{counter}." + number_x = rmc_config.xx(xpos) + NUMBERED2_OFFSET + number_y = rmc_config.yy(ypos) + output.write( + f'\t\t\t{number_text}\n' + ) + + +# ============================================================================= +# STYLE REGISTRY AND FACTORY +# ============================================================================= + +# Singleton instances for styles +_STYLE_INSTANCES: tp.Dict[si.ParagraphStyle, ParagraphStyleConfig] = { + si.ParagraphStyle.BASIC: PlainStyleConfig(), + si.ParagraphStyle.PLAIN: PlainStyleConfig(), + si.ParagraphStyle.BOLD: BoldStyleConfig(), + si.ParagraphStyle.HEADING: HeadingStyleConfig(), + si.ParagraphStyle.BULLET: Bullet1StyleConfig(), + si.ParagraphStyle.BULLET2: Bullet2StyleConfig(), + si.ParagraphStyle.CHECKBOX: Checkbox1StyleConfig(is_checked=False), + si.ParagraphStyle.CHECKBOX_CHECKED: Checkbox1StyleConfig(is_checked=True), + si.ParagraphStyle.CHECKBOX2: Checkbox2StyleConfig(is_checked=False), + si.ParagraphStyle.CHECKBOX2_CHECKED: Checkbox2StyleConfig(is_checked=True), + si.ParagraphStyle.NUMBERED: Numbered1StyleConfig(), + si.ParagraphStyle.NUMBERED2: Numbered2StyleConfig(), +} + + +def get_style_config(style: si.ParagraphStyle) -> ParagraphStyleConfig: + """Get the style configuration for a paragraph style. + + :param style: ParagraphStyle enum value + :return: Corresponding ParagraphStyleConfig instance + :raises KeyError: If style is not recognized + """ + if style in _STYLE_INSTANCES: + return _STYLE_INSTANCES[style] + raise KeyError(f"Unknown paragraph style: {style}") diff --git a/src/rmc/exporters/svg/rendering.py b/src/rmc/exporters/svg/rendering.py new file mode 100644 index 0000000..400cbec --- /dev/null +++ b/src/rmc/exporters/svg/rendering.py @@ -0,0 +1,506 @@ +"""SVG rendering pipeline, CSS generation, and text rendering.""" + +import base64 +import logging +import string +import typing as tp +from pathlib import Path +from xml.sax.saxutils import escape as _escape_attrib + +from rmscene import read_tree, SceneTree +from rmscene import scene_items as si +from rmscene.text import TextDocument + +# Import from device module +from .device import rmc_config + +# Import from fonts module +from .fonts import ( + FONTS, + FONTS_DIR, + FONT_CONFIG, + FONT_WEIGHT_INLINE_BOLD, + FONT_FAMILY_SANS, + get_font_family_sans, + get_font_family_serif, + _resolve_font_config, + wrap_text_to_width, +) + +# Import from layout module +from .layout import ( + build_anchor_pos, + get_anchor, + get_bounding_box, +) + +# Import from paragraph_styles module +from .paragraph_styles import ( + get_style_config, + BULLET_SECTION_GAP, + TEXT_WRAP_MARGIN, + TEXT_TOP_Y, +) + +# Import writing tools +from ..writing_tools import Pen + +_logger = logging.getLogger(__name__) + + +# ============================================================================= +# RENDERING CODE +# ============================================================================= + +SVG_HEADER = string.Template(""" +""") + + +def rm_to_svg(rm_path, svg_path): + """Convert `rm_path` to SVG at `svg_path`. + + :param rm_path: Path to .rm file + :param svg_path: Path to output SVG file + """ + with open(rm_path, "rb") as infile, open(svg_path, "wt") as outfile: + tree = read_tree(infile) + tree_to_svg(tree, outfile) + + +def read_template_svg(template_path: Path) -> str: + lines = template_path.read_text().splitlines() + return "\n".join(lines[2:-2]) + + +def tree_to_svg(tree: SceneTree, output, include_template: Path | None = None): + """Convert Blocks to SVG. + + :param tree: The scene tree to convert + :param output: Output file object to write SVG to + :param include_template: Optional template SVG to include as background + """ + + # find the anchor pos for further use + # newline_offsets contains the offset to subtract for strokes anchored to newlines + # anchor_x_pos contains computed X positions for characters + anchor_pos, newline_offsets, anchor_x_pos, anchor_soft_offset = build_anchor_pos(tree.root_text, extended=True) + _logger.debug("anchor_pos: %s", anchor_pos) + _logger.debug("newline_offsets: %s", newline_offsets) + + # Get text_pos_x for TEXT_CHAR anchor calculations + text_pos_x = tree.root_text.pos_x if tree.root_text is not None else None + + # find the extremum along x and y (for strokes) + # get_bounding_box defaults to device dimensions, expanding if content extends beyond + x_min, x_max, y_min, y_max = get_bounding_box( + tree.root, anchor_pos, newline_offsets, text_pos_x, anchor_x_pos, anchor_soft_offset + ) + + width_pt = rmc_config.xx(x_max - x_min + 1) + height_pt = rmc_config.yy(y_max - y_min + 1) + _logger.debug("x_min, x_max, y_min, y_max: %.1f, %.1f, %.1f, %.1f ; scaled %.1f, %.1f, %.1f, %.1f", + x_min, x_max, y_min, y_max, rmc_config.xx(x_min), rmc_config.xx(x_max), rmc_config.yy(y_min), rmc_config.yy(y_max)) + + # add svg header + output.write(SVG_HEADER.substitute(width=width_pt, + height=height_pt, + viewbox=f"{rmc_config.xx(x_min)} {rmc_config.yy(y_min)} {width_pt} {height_pt}") + "\n") + + if include_template is not None: + output.write(read_template_svg(include_template)) + output.write(f'\n\t\n') + + output.write(f'\t\n') + + if tree.root_text is not None: + draw_text(tree.root_text, output) + + draw_group(tree.root, output, anchor_pos, newline_offsets, text_pos_x, anchor_x_pos, anchor_soft_offset) + + # Closing page group + output.write('\t\n') + # END notebook + output.write('\n') + + +def draw_group(item: si.Group, output, anchor_pos, newline_offsets=None, text_pos_x=None, anchor_x_pos=None, anchor_soft_offset=None): + if newline_offsets is None: + newline_offsets = {} + if anchor_x_pos is None: + anchor_x_pos = {} + if anchor_soft_offset is None: + anchor_soft_offset = {} + anchor_x, anchor_y = get_anchor(item, anchor_pos, newline_offsets, text_pos_x, anchor_x_pos, anchor_soft_offset) + output.write(f'\t\t\n') + for child_id in item.children: + child = item.children[child_id] + _logger.debug("Group child: %s %s", child_id, type(child)) + if _logger.root.level == logging.DEBUG: + output.write(f'\t\t\n') + if isinstance(child, si.Group): + draw_group(child, output, anchor_pos, newline_offsets, text_pos_x, anchor_x_pos, anchor_soft_offset) + elif isinstance(child, si.Line): + draw_stroke(child, output) + output.write(f'\t\t\n') + + +def draw_stroke(item: si.Line, output): + if _logger.root.level == logging.DEBUG: + _logger.debug("Writing line: %s", item) + output.write(f'\t\t\t\n') + + pen = Pen.create(item.tool.value, item.color.value, item.thickness_scale) + + last_xpos = -1. + last_ypos = -1. + last_segment_width = segment_width = 0 + for point_idx, point in enumerate(item.points): + xpos = point.x + ypos = point.y + if point_idx % pen.segment_length == 0: + if point_idx > 0: + output.write('"/>\n') + + segment_color = pen.get_segment_color(point.speed, point.direction, point.width, point.pressure, + last_segment_width) + segment_width = pen.get_segment_width(point.speed, point.direction, point.width, point.pressure, + last_segment_width) + segment_opacity = pen.get_segment_opacity(point.speed, point.direction, point.width, point.pressure, + last_segment_width) + output.write('\t\t\t\n') + + +# Cache for base64-encoded fonts (loaded once per session) +# Maps font_key -> {"data": base64_string, "config": resolved_config} +_font_data_cache: tp.Optional[tp.Dict[str, tp.Dict[str, tp.Any]]] = None + + +def _load_font_data() -> tp.Dict[str, tp.Dict[str, tp.Any]]: + """Load and base64-encode all fonts defined in FONTS configuration. + + Returns a dict mapping font keys to their base64-encoded data and resolved config. + Uses fallback fonts if primary fonts are not available. + """ + global _font_data_cache + if _font_data_cache is not None: + return _font_data_cache + + _font_data_cache = {} + for font_key in FONTS.keys(): + resolved = _resolve_font_config(font_key) + if resolved is None: + continue + font_path = FONTS_DIR / resolved["file"] + try: + font_data = font_path.read_bytes() + _font_data_cache[font_key] = { + "data": base64.b64encode(font_data).decode("ascii"), + "config": resolved, + } + except Exception as e: + _logger.warning(f"Failed to load font data for {font_key}: {e}") + + return _font_data_cache + + +def _generate_font_face_css() -> str: + """Generate @font-face CSS declarations for all fonts in FONTS configuration. + + Uses resolved font configs (including fallbacks) and appropriate MIME types. + """ + font_data = _load_font_data() + css_rules = [] + + for font_key, entry in font_data.items(): + config = entry["config"] + data = entry["data"] + + # Determine MIME type based on format + mime_type = "font/woff2" if config["format"] == "woff2" else "font/ttf" + + css_rules.append(f'''@font-face {{ + font-family: "{config["family"]}"; + src: url("data:{mime_type};base64,{data}") format("{config["format"]}"); + font-style: {config["style"]}; + font-weight: {config["weight_range"]}; + }}''') + + return "\n ".join(css_rules) + + +def _generate_font_css() -> str: + """Generate CSS for all font styles from FONT_CONFIG.""" + css_rules = [] + + # Use resolved font families (which account for fallbacks) + serif_family = get_font_family_serif() + sans_family = get_font_family_sans() + + for style_name, config in FONT_CONFIG.items(): + family = serif_family if config["family"] == "serif" else sans_family + fallback = "serif" if config["family"] == "serif" else "sans-serif" + + css_rules.append(f'''text.{style_name} {{ + font-family: "{family}", {fallback}; + font-size: {config["size"]}pt; + font-weight: {config["weight"]}; + }}''') + + return "\n ".join(css_rules) + + +def draw_text(text: si.Text, output): + output.write('\t\t') + + # Get config values for default text style + plain_config = FONT_CONFIG["plain"] + + output.write(f''' + + + + + + + + + + + + + + + + ''') + + LINE_SEPARATOR = '\u2028' + y_offset = TEXT_TOP_Y + + # Track previous style for section gaps + prev_style = None + prev_style_config = None + + # Counter management - keyed by counter_key + counters: tp.Dict[str, int] = {} + + doc = TextDocument.from_scene_item(text) + for p_idx, p in enumerate(doc.contents): + # Get style configuration + style_config = get_style_config(p.style.value) + line_height = style_config.line_height + soft_line_height = style_config.soft_line_height + + # Add section gap when transitioning from non-list to list style + is_list_style = style_config.is_list_style + prev_was_list_style = prev_style_config.is_list_style if prev_style_config else False + if is_list_style and not prev_was_list_style and prev_style is not None: + y_offset += BULLET_SECTION_GAP + + # Counter management - generalized via style_config + counter_value = None + if style_config.needs_counter: + key = style_config.counter_key + # Reset counter if previous style used a different counter + prev_key = prev_style_config.counter_key if prev_style_config else None + if prev_key != key: + counters[key] = 0 + counters[key] = counters.get(key, 0) + 1 + counter_value = counters[key] + + y_offset += line_height + + xpos = text.pos_x + ypos = text.pos_y + y_offset + style_name = p.style.value.name.lower() + + # Draw marker using style_config (checkbox/bullet/number) + style_config.draw_marker(xpos, ypos, output, rmc_config, counter=counter_value) + + # Calculate text X offset using style_config + text_xpos = xpos + style_config.text_x_offset(rmc_config.scale) + + # Render text with inline formatting support + # First, collect all text segments with their formatting + all_segments = [] + for subp in p.contents: + props = getattr(subp, 'properties', {}) + is_bold = props.get('font-weight') == 'bold' + is_italic = props.get('font-style') == 'italic' + + text_content = str(subp) + + # Split by LINE_SEPARATOR but keep track of segments + parts = text_content.split(LINE_SEPARATOR) + for i, part in enumerate(parts): + if part: + all_segments.append({ + 'text': part, + 'bold': is_bold, + 'italic': is_italic, + 'newline_after': False + }) + if i < len(parts) - 1: + # Mark that a newline follows + if all_segments: + all_segments[-1]['newline_after'] = True + else: + # Empty line at start + all_segments.append({ + 'text': '', + 'bold': False, + 'italic': False, + 'newline_after': True + }) + + # Now group segments into lines + lines = [] + current_line = [] + for seg in all_segments: + if seg['text']: + current_line.append(seg) + if seg['newline_after']: + lines.append(current_line) + current_line = [] + if current_line: + lines.append(current_line) + + line_offset = 0 + wrapped_line_count = 0 # Track total wrapped lines for y_offset + + for line_parts in lines: + # Skip empty lines + full_text = ''.join(part['text'] for part in line_parts) + if not full_text.strip(): + line_offset += 1 + wrapped_line_count += 1 + continue + + # Check if we need tspans (for inline formatting) + needs_tspans = any(part['bold'] or part['italic'] for part in line_parts) + + # Apply word wrapping based on available width + available_width = text.width - TEXT_WRAP_MARGIN + available_width -= style_config.width_reduction(rmc_config.scale) + + if needs_tspans: + # For inline formatting, we need to wrap while preserving spans + # Simple approach: wrap the full text, then re-apply formatting + wrapped_lines = wrap_text_to_width(full_text, available_width, p.style.value) + + for wrapped_idx, wrapped_text in enumerate(wrapped_lines): + line_ypos = ypos + (line_offset * soft_line_height) + + if _logger.root.level == logging.DEBUG: + output.write(f'\t\t\t\n') + + # Re-apply formatting to wrapped text + # Find which parts of the original segments map to this wrapped line + output.write(f'\t\t\t') + + # Track position in wrapped text to apply formatting + remaining = wrapped_text + pos_in_original = sum(len(wrap_text_to_width(full_text, available_width, p.style.value)[i]) + 1 + for i in range(wrapped_idx)) if wrapped_idx > 0 else 0 + + # Simple approach: render wrapped text with original formatting spans + char_pos = 0 + for part in line_parts: + part_text = part['text'] + part_start = full_text.find(part_text, char_pos) + part_end = part_start + len(part_text) + + # Find overlap with wrapped line + wrap_start = pos_in_original + wrap_end = pos_in_original + len(wrapped_text) + + overlap_start = max(part_start, wrap_start) + overlap_end = min(part_end, wrap_end) + + if overlap_start < overlap_end: + overlap_text = full_text[overlap_start:overlap_end] + escaped_text = _escape_attrib(overlap_text) + if part['bold'] and part['italic']: + output.write(f'{escaped_text}') + elif part['bold']: + output.write(f'{escaped_text}') + elif part['italic']: + output.write(f'{escaped_text}') + else: + output.write(escaped_text) + + char_pos = part_end + + output.write('\n') + line_offset += 1 + + wrapped_line_count += len(wrapped_lines) + else: + # Simple text - apply word wrapping + wrapped_lines = wrap_text_to_width(full_text, available_width, p.style.value) + + for wrapped_idx, wrapped_text in enumerate(wrapped_lines): + if _logger.root.level == logging.DEBUG: + output.write(f'\t\t\t\n') + + line_ypos = ypos + (line_offset * soft_line_height) + output.write(f'\t\t\t{_escape_attrib(wrapped_text)}\n') + line_offset += 1 + + wrapped_line_count += len(wrapped_lines) + + # Use wrapped line count minus 1 for y_offset (first line is already accounted for) + # But we also need to account for LINE_SEPARATOR breaks in the original content + content = str(p) + num_soft_breaks = content.count(LINE_SEPARATOR) + # Additional wrapped lines beyond soft breaks + extra_wrapped_lines = wrapped_line_count - len(lines) + if num_soft_breaks > 0 or extra_wrapped_lines > 0: + total_extra = num_soft_breaks + extra_wrapped_lines + y_offset += total_extra * soft_line_height + + # Add extra space after certain paragraph styles (e.g., headings) + space_after = style_config.space_after + if space_after > 0: + y_offset += space_after + + # Add list item spacing + item_spacing = style_config.item_spacing + if item_spacing > 0: + y_offset += item_spacing + + # Update previous style for next iteration + prev_style = p.style.value + prev_style_config = style_config + + output.write('\t\t\n') diff --git a/src/rmc/exporters/writing_tools.py b/src/rmc/exporters/writing_tools.py index dc4a6ce..a2f7ad3 100644 --- a/src/rmc/exporters/writing_tools.py +++ b/src/rmc/exporters/writing_tools.py @@ -6,9 +6,10 @@ import logging import math +import sys +from rmscene.scene_items import HARDCODED_COLORMAP, PenColor from rmscene.scene_items import Pen as PenType -from rmscene.scene_items import PenColor _logger = logging.getLogger(__name__) @@ -26,13 +27,18 @@ PenColor.BLUE: (78, 105, 201), PenColor.RED: (179, 62, 57), PenColor.GRAY_OVERLAP: (125, 125, 125), - #! Skipped as different colors are used for highlights - #! PenColor.HIGHLIGHT = ... + # TODO: color mechanism is broken, as HIGHLIGHT is used for all the + # highlighter colors. It is better though to produce a highlight color + # than to make rmc crash. + # Note that similar single-color name issues happen for ballpoint pen and + # paintbrusher (though color works for fineliner, caligraphy pen, and + # marker). + PenColor.HIGHLIGHT: (247, 232, 81), PenColor.GREEN_2: (161, 216, 125), PenColor.CYAN: (139, 208, 229), PenColor.MAGENTA: (183, 130, 205), PenColor.YELLOW_2: (247, 232, 81), -} +} | {v: k for k, v in HARDCODED_COLORMAP.items()} def clamp(value): @@ -45,10 +51,18 @@ def clamp(value): class Pen: def __init__(self, name, base_width, base_color_id): self.base_width = base_width - self.base_color = RM_PALETTE[base_color_id] + + color = RM_PALETTE.get(base_color_id, (0, 0, 0)) + opacity = 1 + if len(color) == 4: + *color, opacity = color + opacity = opacity / 255.0 + + self.base_color = color + self.base_opacity = opacity self.name = name self.segment_length = 1000 - self.base_opacity = 1 + # initial stroke values self.stroke_linecap = "round" self.stroke_opacity = 1 @@ -105,7 +119,7 @@ def create(cls, pen_nr, color_id, width): return MechanicalPencil(width, color_id) # Highlighter elif pen_nr in (PenType.HIGHLIGHTER_1, PenType.HIGHLIGHTER_2): - width = 15 + width = 25 return Highlighter(width, color_id) elif pen_nr == PenType.SHADER: # TODO: check if this is correct @@ -118,7 +132,7 @@ def create(cls, pen_nr, color_id, width): elif pen_nr == PenType.ERASER: color_id = 2 return Eraser(width, color_id) - raise Exception(f'Unknown pen_nr: {pen_nr}') + raise Exception(f"Unknown pen_nr: {pen_nr}") class Fineliner(Pen): @@ -135,20 +149,6 @@ def get_segment_width(self, speed, direction, width, pressure, last_width): segment_width = (0.5 + pressure / 255) + (width / 4) - 0.5 * ((speed / 4) / 50) return segment_width - def get_segment_color(self, speed, direction, width, pressure, last_width): - intensity = (0.1 * - ((speed / 4) / 35)) + (1.2 * pressure / 255) + 0.5 - intensity = clamp(intensity) - # using segment color not opacity because the dots interfere with each other. - # Color must be 255 rgb - segment_color = [min(int(abs(intensity - 1) * 255), 60)] * 3 - return "rgb" + str(tuple(segment_color)) - - # def get_segment_opacity(self, speed, direction, width, pressure, last_width): - # segment_opacity = (0.2 * - ((speed / 4) / 35)) + (0.8 * pressure / 255) - # segment_opacity *= segment_opacity - # segment_opacity = self.clamp(segment_opacity) - # return segment_opacity - class Marker(Pen): def __init__(self, base_width, base_color_id): @@ -156,7 +156,9 @@ def __init__(self, base_width, base_color_id): self.segment_length = 3 def get_segment_width(self, speed, direction, width, pressure, last_width): - segment_width = 0.9 * ((width / 4) - 0.4 * self.direction_to_tilt(direction)) + (0.1 * last_width) + segment_width = 0.9 * ( + (width / 4) - 0.4 * self.direction_to_tilt(direction) + ) + (0.1 * last_width) return segment_width @@ -166,22 +168,25 @@ def __init__(self, base_width, base_color_id): self.segment_length = 2 def get_segment_width(self, speed, direction, width, pressure, last_width): - segment_width = 0.7 * ((((0.8 * self.base_width) + (0.5 * pressure / 255)) * (width / 4)) - - (0.25 * self.direction_to_tilt(direction) ** 1.8) - (0.6 * (speed / 4) / 50)) + segment_width = 0.7 * ( + (((0.8 * self.base_width) + (0.5 * pressure / 255)) * (width / 4)) + - (0.25 * self.direction_to_tilt(direction) ** 1.8) + - (0.6 * (speed / 4) / 50) + ) # segment_width = 1.3*(((self.base_width * 0.4) * pressure) - 0.5 * ((self.direction_to_tilt(direction) ** 0.5)) + (0.5 * last_width)) max_width = self.base_width * 10 segment_width = segment_width if segment_width < max_width else max_width return segment_width def get_segment_opacity(self, speed, direction, width, pressure, last_width): - segment_opacity = (0.1 * - ((speed / 4) / 35)) + (1 * pressure / 255) + segment_opacity = (0.1 * -((speed / 4) / 35)) + (1 * pressure / 255) segment_opacity = clamp(segment_opacity) - 0.1 return segment_opacity class MechanicalPencil(Pen): def __init__(self, base_width, base_color_id): - super().__init__("Mechanical Pencil", base_width ** 2, base_color_id) + super().__init__("Mechanical Pencil", base_width**2, base_color_id) self.base_opacity = 0.7 @@ -193,40 +198,31 @@ def __init__(self, base_width, base_color_id): self.opacity = 1 def get_segment_width(self, speed, direction, width, pressure, last_width): - segment_width = 0.7 * (((1 + (1.4 * pressure / 255)) * (width / 4)) - - (0.5 * self.direction_to_tilt(direction)) - ((speed / 4) / 50)) # + (0.2 * last_width) + segment_width = 0.7 * ( + ((1 + (1.4 * pressure / 255)) * (width / 4)) + - (0.5 * self.direction_to_tilt(direction)) + - ((speed / 4) / 50) + ) # + (0.2 * last_width) return segment_width - def get_segment_color(self, speed, direction, width, pressure, last_width): - intensity = ((pressure / 255) ** 1.5 - 0.2 * ((speed / 4) / 50)) * 1.5 - intensity = clamp(intensity) - # using segment color not opacity because the dots interfere with each other. - # Color must be 255 rgb - rev_intensity = abs(intensity - 1) - segment_color = [int(rev_intensity * (255 - self.base_color[0])), - int(rev_intensity * (255 - self.base_color[1])), - int(rev_intensity * (255 - self.base_color[2]))] - - return "rgb" + str(tuple(segment_color)) - class Highlighter(Pen): def __init__(self, base_width, base_color_id): super().__init__("Highlighter", base_width, base_color_id) self.stroke_linecap = "square" self.base_opacity = 0.3 - self.stroke_opacity = 0.2 + self.stroke_opacity = 0.3 class Shader(Pen): - def __init__(self, base_width, base_color_id): super().__init__("Shader", base_width, base_color_id) self.stroke_linecap = "round" - self.base_opacity = 0.1 + # self.base_opacity = 0.1 # self.stroke_opacity = 0.2 self.name = "Shader" + class Eraser(Pen): def __init__(self, base_width, base_color_id): super().__init__("Eraser", base_width * 2, base_color_id) @@ -246,6 +242,8 @@ def __init__(self, base_width, base_color_id): self.segment_length = 2 def get_segment_width(self, speed, direction, width, pressure, last_width): - segment_width = 0.9 * (((1 + pressure / 255) * (width / 4)) - - 0.3 * self.direction_to_tilt(direction)) + (0.1 * last_width) + segment_width = 0.9 * ( + ((1 + pressure / 255) * (width / 4)) + - 0.3 * self.direction_to_tilt(direction) + ) + (0.1 * last_width) return segment_width diff --git a/tests/rm/checklist.rm b/tests/rm/checklist.rm new file mode 100644 index 0000000..bd6aa4c Binary files /dev/null and b/tests/rm/checklist.rm differ diff --git a/tests/rm/checklist_expected_rmpp.pdf b/tests/rm/checklist_expected_rmpp.pdf new file mode 100644 index 0000000..ef570bb Binary files /dev/null and b/tests/rm/checklist_expected_rmpp.pdf differ diff --git a/tests/rm/colours.rm b/tests/rm/colours.rm new file mode 100644 index 0000000..197014e Binary files /dev/null and b/tests/rm/colours.rm differ diff --git a/tests/rm/colours_expected_rmpp.pdf b/tests/rm/colours_expected_rmpp.pdf new file mode 100644 index 0000000..93bef05 Binary files /dev/null and b/tests/rm/colours_expected_rmpp.pdf differ diff --git a/tests/rm/more_anchoring.rm b/tests/rm/more_anchoring.rm new file mode 100644 index 0000000..ab2b45b Binary files /dev/null and b/tests/rm/more_anchoring.rm differ diff --git a/tests/rm/more_anchoring_expected_rmpp.pdf b/tests/rm/more_anchoring_expected_rmpp.pdf new file mode 100644 index 0000000..197c489 Binary files /dev/null and b/tests/rm/more_anchoring_expected_rmpp.pdf differ diff --git a/tests/rm/sentinel_bug.rm b/tests/rm/sentinel_bug.rm new file mode 100644 index 0000000..8687b6f Binary files /dev/null and b/tests/rm/sentinel_bug.rm differ diff --git a/tests/rm/text.rm b/tests/rm/text.rm new file mode 100644 index 0000000..a6ccfae Binary files /dev/null and b/tests/rm/text.rm differ diff --git a/tests/rm/text_expected_rmpp.pdf b/tests/rm/text_expected_rmpp.pdf new file mode 100644 index 0000000..cecad42 Binary files /dev/null and b/tests/rm/text_expected_rmpp.pdf differ