presenterm-0.15.1/.cargo_vcs_info.json0000644000000001360000000000100133040ustar { "git": { "sha1": "68b7b2198c40acd9a227eab379b3b7323f533cd6" }, "path_in_vcs": "" }presenterm-0.15.1/.editorconfig000064400000000000000000000000541046102023000145500ustar 00000000000000[*.sh] indent_style = space indent_size = 4 presenterm-0.15.1/.github/FUNDING.yml000064400000000000000000000000231046102023000152440ustar 00000000000000github: mfontanini presenterm-0.15.1/.github/workflows/docs.yaml000064400000000000000000000013451046102023000173100ustar 00000000000000name: Deploy docs on: push: branches: - master permissions: contents: write jobs: build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Install cargo-binstall uses: cargo-bins/cargo-binstall@v1.10.22 - name: Install mdbook run: | cargo binstall -y mdbook@0.4.44 mdbook-alerts@0.7.0 - name: Build the book run: | cd docs mdbook build - name: Deploy build to gh-pages branch uses: crazy-max/ghaction-github-pages@v4 with: target_branch: gh-pages build_dir: docs/book env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} presenterm-0.15.1/.github/workflows/merge.yaml000064400000000000000000000040171046102023000174560ustar 00000000000000on: pull_request: push: branches: - master name: Merge checks jobs: check: name: Checks runs-on: ubuntu-latest steps: - name: Checkout sources uses: actions/checkout@v4 - name: Install rust toolchain uses: dtolnay/rust-toolchain@1.82.0 with: components: clippy - name: Run cargo check run: cargo check --features sixel - name: Run cargo test run: cargo test - name: Run cargo clippy run: cargo clippy -- -D warnings - name: Install nightly toolchain uses: dtolnay/rust-toolchain@nightly with: components: rustfmt - name: Run cargo fmt run: cargo +nightly fmt --all -- --check - name: Install uv uses: astral-sh/setup-uv@v5 - name: Install weasyprint run: | uv venv source ./.venv/bin/activate uv pip install weasyprint - name: Export demo presentation as PDF and HTML run: | cat >/tmp/config.yaml <> "$GITHUB_OUTPUT" echo "git_hash=$git_hash" >> "$GITHUB_OUTPUT" echo "latest_nightly_hash=$latest_nightly_hash" >> "$GITHUB_OUTPUT" publish-github: name: Publish on GitHub runs-on: ${{ matrix.config.OS }} needs: vars # Don't run this if the nightly hash already points to the current hash if: needs.vars.outputs.git_hash != needs.vars.outputs.latest_nightly_hash strategy: fail-fast: false matrix: config: - { OS: ubuntu-latest, TARGET: "x86_64-unknown-linux-gnu" } - { OS: ubuntu-latest, TARGET: "x86_64-unknown-linux-musl" } - { OS: ubuntu-latest, TARGET: "i686-unknown-linux-gnu" } - { OS: ubuntu-latest, TARGET: "i686-unknown-linux-musl" } - { OS: ubuntu-latest, TARGET: "armv5te-unknown-linux-gnueabi" } - { OS: ubuntu-latest, TARGET: "armv7-unknown-linux-gnueabihf" } - { OS: ubuntu-latest, TARGET: "aarch64-unknown-linux-gnu" } - { OS: ubuntu-latest, TARGET: "aarch64-unknown-linux-musl" } - { OS: macos-latest, TARGET: "x86_64-apple-darwin" } - { OS: macos-latest, TARGET: "aarch64-apple-darwin" } - { OS: windows-latest, TARGET: "x86_64-pc-windows-msvc" } - { OS: windows-latest, TARGET: "i686-pc-windows-msvc" } steps: - name: Checkout the repository uses: actions/checkout@v4 - name: Build binary uses: houseabsolute/actions-rust-cross@a448c4b13769d56b63b035024fef8577e1d81915 with: command: build toolchain: 1.82.0 target: ${{ matrix.config.TARGET }} args: "--locked --release" - name: Prepare release assets shell: bash run: | mkdir release/ cp {LICENSE,README.md} release/ cp target/${{ matrix.config.TARGET }}/release/presenterm release/ mv release/ presenterm-${{ env.RELEASE_VERSION }}/ - name: Create release artifacts shell: bash run: | if [ "${{ matrix.config.OS }}" = "windows-latest" ]; then 7z a -tzip "presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip" \ presenterm-${{ env.RELEASE_VERSION }} sha512sum "presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip" \ > presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip.sha512 else tar -czvf presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \ presenterm-${{ env.RELEASE_VERSION }}/ shasum -a 512 presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \ > presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz.sha512 fi - name: Upload the release uses: svenstaro/upload-release-action@e2a63377780a8bacc68dcac9b0979ee20ad5a791 with: repo_token: ${{ secrets.GITHUB_TOKEN }} tag: nightly file: presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.* file_glob: true overwrite: true prerelease: true release_name: Nightly body: | This is a nightly build based on ref [${{ needs.vars.outputs.git_hash }}](https://github.com/mfontanini/presenterm/commit/${{ needs.vars.outputs.git_hash }}) Generated on `${{ needs.vars.outputs.timestamp }}` presenterm-0.15.1/.github/workflows/release.yaml000064400000000000000000000077411046102023000200060ustar 00000000000000name: Release on: push: tags: - "v*.*.*" jobs: changelog: name: Parse changelog runs-on: ubuntu-latest outputs: notes: ${{ steps.parse.outputs.notes }} steps: - name: Checkout the repository uses: actions/checkout@v3 - name: Parse release notes id: parse shell: bash run: | release_version=v${GITHUB_REF:11} r=$(./scripts/parse-changelog.sh "${release_version}") r="${r//'%'/'%25'}" r="${r//$'\n'/'%0A'}" r="${r//$'\r'/'%0D'}" echo "notes=$r" >> "$GITHUB_OUTPUT" publish-github: name: Publish on GitHub runs-on: ${{ matrix.config.OS }} needs: changelog strategy: fail-fast: false matrix: config: - { OS: ubuntu-latest, TARGET: "x86_64-unknown-linux-gnu" } - { OS: ubuntu-latest, TARGET: "x86_64-unknown-linux-musl" } - { OS: ubuntu-latest, TARGET: "i686-unknown-linux-gnu" } - { OS: ubuntu-latest, TARGET: "i686-unknown-linux-musl" } - { OS: ubuntu-latest, TARGET: "armv5te-unknown-linux-gnueabi" } - { OS: ubuntu-latest, TARGET: "armv7-unknown-linux-gnueabihf" } - { OS: ubuntu-latest, TARGET: "aarch64-unknown-linux-gnu" } - { OS: ubuntu-latest, TARGET: "aarch64-unknown-linux-musl" } - { OS: macos-latest, TARGET: "x86_64-apple-darwin" } - { OS: macos-latest, TARGET: "aarch64-apple-darwin" } - { OS: windows-latest, TARGET: "x86_64-pc-windows-msvc" } - { OS: windows-latest, TARGET: "i686-pc-windows-msvc" } steps: - name: Checkout the repository uses: actions/checkout@v4 - name: Set the release version shell: bash run: echo "RELEASE_VERSION=${GITHUB_REF:11}" >> $GITHUB_ENV - name: Build binary uses: houseabsolute/actions-rust-cross@a448c4b13769d56b63b035024fef8577e1d81915 with: command: build toolchain: 1.82.0 target: ${{ matrix.config.TARGET }} args: "--locked --release" - name: Prepare release assets shell: bash run: | mkdir release/ cp {LICENSE,README.md} release/ cp target/${{ matrix.config.TARGET }}/release/presenterm release/ mv release/ presenterm-${{ env.RELEASE_VERSION }}/ - name: Create release artifacts shell: bash run: | if [ "${{ matrix.config.OS }}" = "windows-latest" ]; then 7z a -tzip "presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip" \ presenterm-${{ env.RELEASE_VERSION }} sha512sum "presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip" \ > presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip.sha512 else tar -czvf presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \ presenterm-${{ env.RELEASE_VERSION }}/ shasum -a 512 presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \ > presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz.sha512 fi - name: Upload the release uses: svenstaro/upload-release-action@e2a63377780a8bacc68dcac9b0979ee20ad5a791 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.* file_glob: true overwrite: true release_name: v${{ env.RELEASE_VERSION }} tag: ${{ github.ref }} body: | ${{ needs.changelog.outputs.notes }} publish-crates-io: name: Publish on crates.io needs: publish-github runs-on: ubuntu-latest steps: - name: Checkout the repository uses: actions/checkout@v4 - name: Install rust toolchain uses: dtolnay/rust-toolchain@1.82.0 - name: Publish run: cargo publish --locked --token ${{ secrets.CARGO_TOKEN }} presenterm-0.15.1/.gitignore000064400000000000000000000000101046102023000140530ustar 00000000000000/target presenterm-0.15.1/CHANGELOG.md000064400000000000000000001377041046102023000137210ustar 00000000000000# v0.15.1 - 2025-08-01 ## Fixes * Disable OSC 11 when running in tmux ([#696](https://github.com/mfontanini/presenterm/issues/696)). * Follow custom theme symlinks ([#692](https://github.com/mfontanini/presenterm/issues/692)). # v0.15.0 - 2025-07-13 ## Breaking changes * The behavior for "jump next fast" and "jump previous fast" keybindings (defaults to `n` and `p`) now jumps straight from one slide to the next/previous one ignoring pauses. Before this used to "reveal" all pauses when jumping forward before going to the next slide. This behavior was weird and unintuitive so now fast jumps go straight into the next/previous slides. The action of "showing all pauses on the current slide" can now be done by pressing `s` ([#678](https://github.com/mfontanini/presenterm/issues/678)). ## New features * Allow specifying where a [snippet's execution output will go](https://mfontanini.github.io/presenterm/features/code/execution.html#output-placing) ([#658](https://github.com/mfontanini/presenterm/issues/658)). * Add `include` comment command to [import markdown files](https://mfontanini.github.io/presenterm/features/commands.html#including-external-markdown-files) ([#651](https://github.com/mfontanini/presenterm/issues/651)) ([#683](https://github.com/mfontanini/presenterm/issues/683)). * Allow [validating snippets without explicitly executing them](https://mfontanini.github.io/presenterm/features/code/execution.html#validating-snippets) by using `--validate-snippets` switch ([#645](https://github.com/mfontanini/presenterm/issues/645)) ([#637](https://github.com/mfontanini/presenterm/issues/637)). * Support iterm2 image protocol when running in tmux ([#661](https://github.com/mfontanini/presenterm/issues/661)). * Add support for [d2 diagrams](https://mfontanini.github.io/presenterm/features/code/d2.html) ([#657](https://github.com/mfontanini/presenterm/issues/657)). * Errors encountered when parsing markdown now always display the file, line, and column where the error was found, as well as the markdown line that caused the error ([#674](https://github.com/mfontanini/presenterm/issues/674)) ([#653](https://github.com/mfontanini/presenterm/issues/653)) ([#684](https://github.com/mfontanini/presenterm/issues/684)) ([#685](https://github.com/mfontanini/presenterm/issues/685)). * Superscript via `^this^` and `this` syntaxes is supported when using the kitty terminal. For other terminals we try to use unicode half block characters which cover a portion of the ASCII charset. ([#606](https://github.com/mfontanini/presenterm/issues/606))([#617](https://github.com/mfontanini/presenterm/issues/617) ) ([#665](https://github.com/mfontanini/presenterm/issues/665)). * Allow [alternative snippet executors](https://mfontanini.github.io/presenterm/features/code/execution.html#alternative-executors) for languages that support execution. This allows, for example, runnig rust code via `rust-script` or python code via `pytest` ([#614](https://github.com/mfontanini/presenterm/issues/614)). * Allow using env var `PRESENTERM_CONFIG_FILE` to point to the config file ([#663](https://github.com/mfontanini/presenterm/issues/663)) - thanks @Silver-Golden. * Set background color via OSC 11 to avoid having a colored edge around the presentation ([#623](https://github.com/mfontanini/presenterm/issues/623)) ([#624](https://github.com/mfontanini/presenterm/issues/624)) ([#627](https://github.com/mfontanini/presenterm/issues/627)). * Add support for markdown footnotes ([#616](https://github.com/mfontanini/presenterm/issues/616)). * Runtime errors are now centered rather than being left aligned with some fixed margin ([#638](https://github.com/mfontanini/presenterm/issues/638)). * Allow [configuring number of newlines](https://mfontanini.github.io/presenterm/features/commands.html#number-of-lines-in-between-list-items) in between list items ([#628](https://github.com/mfontanini/presenterm/issues/628)). * Allow 3 digit hex colors ([#609](https://github.com/mfontanini/presenterm/issues/609)) - thanks @peterc-s. * Allow [configuring font](https://mfontanini.github.io/presenterm/configuration/settings.html#pdf-font) used in PDF export ([#608](https://github.com/mfontanini/presenterm/issues/608)). * Added `uv` as an alternative executor for python code ([#662](https://github.com/mfontanini/presenterm/issues/662)) - thanks @JanNeuendorf. * Allow multiline slide titles ([#679](https://github.com/mfontanini/presenterm/issues/679)). * Add support for multiline slide titles ([#682](https://github.com/mfontanini/presenterm/issues/682)) - thanks @barr-israel. * Add support for multiline subtitle ([#680](https://github.com/mfontanini/presenterm/issues/680)) - thanks @barr-israel. * Add support for syntax highlighting and execution for F# ([#650](https://github.com/mfontanini/presenterm/issues/650)) - thanks @mnebes. * Use text style/colors in rust-script errors ([#644](https://github.com/mfontanini/presenterm/issues/644)). * Added `rust-script-pedantic` alternative executor for rust ([#640](https://github.com/mfontanini/presenterm/issues/640)). ## Fixes * Consider rect start row when capping max terminal rows ([#656](https://github.com/mfontanini/presenterm/issues/656)). * Skip speaker notes slide on `skip_slide` ([#625](https://github.com/mfontanini/presenterm/issues/625)). * Don't loop on 0 bytes read when querying capabilities ([#620](https://github.com/mfontanini/presenterm/issues/620)). * Make code snippet language specifiers case insensitive ([#613](https://github.com/mfontanini/presenterm/issues/613)) - thanks @peterc-s. * Bump dependencies ([#681](https://github.com/mfontanini/presenterm/issues/681)) - thanks @barr-israel. ## Chore * Refactored code to make it more easily testeable, and added lots of tests to ensure markdown is rendered as expected. This will hopefully reduce the number of errors found after each release ([#660](https://github.com/mfontanini/presenterm/issues/660)) ([#659](https://github.com/mfontanini/presenterm/issues/659)) ([#655](https://github.com/mfontanini/presenterm/issues/655)) ([#647](https://github.com/mfontanini/presenterm/issues/647)). * Bump rust version to 1.82 ([#611](https://github.com/mfontanini/presenterm/issues/611)). * Perform better validation around matching HTML tags ([#668](https://github.com/mfontanini/presenterm/issues/668)). * Don't run nightly job if the git hash hasn't changed ([#667](https://github.com/mfontanini/presenterm/issues/667)) ([#675](https://github.com/mfontanini/presenterm/issues/675)) ([#669](https://github.com/mfontanini/presenterm/issues/669)). * Display an error when using http(s) urls in image tags ([#666](https://github.com/mfontanini/presenterm/issues/666)). * Update Catppuccin themes to use palettes ([#672](https://github.com/mfontanini/presenterm/issues/672)) - thanks @jmcharter. ## Docs * Add custom introduction slides example ([#633](https://github.com/mfontanini/presenterm/issues/633)). * Add mention of `winget` ([#621](https://github.com/mfontanini/presenterm/issues/621)) - thanks @DeveloperPaul123. * Fix incorrect note callout ([#610](https://github.com/mfontanini/presenterm/issues/610)) - thanks @Sacquer. * Add a note to export pdf using `uv` ([#646](https://github.com/mfontanini/presenterm/issues/646)) - thanks @PitiBouchon. * Clarify why no remote urls work with images ([#664](https://github.com/mfontanini/presenterm/issues/664)) - thanks @ryuheechul. ## ❤️ Sponsors Thanks to the following users who supported _presenterm_ via a [github sponsorship](https://github.com/sponsors/mfontanini) in this release: * [@0atman](https://github.com/0atman) * [@orhun](https://github.com/orhun) * [@gwpl](https://github.com/gwpl) # v0.14.0 - 2025-05-17 ## New features * Add support for [exporting presentations as HTML files](https://mfontanini.github.io/presenterm/features/exports.html#html) ([#566](https://github.com/mfontanini/presenterm/issues/566)) ([#595](https://github.com/mfontanini/presenterm/issues/595)) ([#575](https://github.com/mfontanini/presenterm/issues/575)) ([#599](https://github.com/mfontanini/presenterm/issues/599)) - thanks @JustSimplyKyle. * Snippet execution output now contains configurable padding and built-in themes default to the same padding as snippets (2 spaces horizontally, one line vertically) ([#592](https://github.com/mfontanini/presenterm/issues/592)) ([#593](https://github.com/mfontanini/presenterm/issues/593)). * Add highlighting and execution support for Jsonnet ([#585](https://github.com/mfontanini/presenterm/issues/585)) - thanks @imobachgs. * Allow [configuring snippets](https://mfontanini.github.io/presenterm/configuration/settings.html#sequential-snippet-execution) to be executed sequentially during exports ([#584](https://github.com/mfontanini/presenterm/issues/584)). ## Fixes * Skip slides with pauses correctly ([#598](https://github.com/mfontanini/presenterm/issues/598)). * Avoid printing text if there's no vertical space for it, which otherwise looks bad particularly when using font size > 1 ([#594](https://github.com/mfontanini/presenterm/issues/594)). * Execute snippets only once during export ([#583](https://github.com/mfontanini/presenterm/issues/583)). * Don't add an extra pause after lists if there's nothing left ([#580](https://github.com/mfontanini/presenterm/issues/580)). * Allow interleaved spans and variables in footer ([#577](https://github.com/mfontanini/presenterm/issues/577)). * Truly center `+exec_replace` snippet output ([#572](https://github.com/mfontanini/presenterm/issues/572)). ## Docs * Added link to public presentation using presenterm ([#589](https://github.com/mfontanini/presenterm/issues/589)) - thanks @pwnwriter. * Rename parameter name to the correct one in docs ([#570](https://github.com/mfontanini/presenterm/issues/570)) - thanks @DzuWe. * Fix typo in highlighting.md ([#586](https://github.com/mfontanini/presenterm/issues/586)) - thanks @0atman. ## Chore * Bump dependencies ([#596](https://github.com/mfontanini/presenterm/issues/596)). ## ❤️ Sponsors Thanks to the following users who supported _presenterm_ via a [github sponsorship](https://github.com/sponsors/mfontanini) in this release: * [@0atman](https://github.com/0atman) * [@orhun](https://github.com/orhun) # v0.13.0 - 2025-04-25 ## Breaking changes * The CLI parameter to generate the JSON schema for the config file (`--generate-config-file-schema`) is now hidden behind a `json-schema` feature flag. The JSON schema file for the latest version is already publicly available at `https://github.com/mfontanini/presenterm/blob/${VERSION}/config-file-schema.json`, so anyone can use it without having to generate it by hand. This allows cutting down the number of dependencies in this project quite a bit ([#563](https://github.com/mfontanini/presenterm/issues/563)). ## New features * Support for [slide transitions](https://mfontanini.github.io/presenterm/features/slide-transitions.html) is now available ([#530](https://github.com/mfontanini/presenterm/issues/530)): * Add fade slide transition ([#534](https://github.com/mfontanini/presenterm/issues/534)). * Add slide horizontally slide transition animation ([#528](https://github.com/mfontanini/presenterm/issues/528)). * Add `collapse_horizontal` slide transition ([#560](https://github.com/mfontanini/presenterm/issues/560)). * Add `--output` option to specify the path where the output file is written to during an export ([#526](https://github.com/mfontanini/presenterm/issues/526)) - thanks @marianozunino. * Allow specifying [start/end lines](https://mfontanini.github.io/presenterm/features/code/highlighting.html#including-external-code-snippets) in file snippet type ([#565](https://github.com/mfontanini/presenterm/issues/565)). * Allow letting [pauses become new slides](https://mfontanini.github.io/presenterm/configuration/settings.html#pause-behavior) when exporting ([#557](https://github.com/mfontanini/presenterm/issues/557)). * Allow [using images on right in footer](https://mfontanini.github.io/presenterm/features/themes/definition.html#footer-images) ([#554](https://github.com/mfontanini/presenterm/issues/554)). * Add [`max_rows` configuration](https://mfontanini.github.io/presenterm/configuration/settings.html#maximum-presentation-height) to cap vertical size ([#531](https://github.com/mfontanini/presenterm/issues/531)). * Add julia language highlighting and execution support ([#561](https://github.com/mfontanini/presenterm/issues/561)). ## Fixes * Center overflow lines when using centered text ([#546](https://github.com/mfontanini/presenterm/issues/546)). * Don't add extra space before heading if prefix in theme is empty ([#542](https://github.com/mfontanini/presenterm/issues/542)). * Use no typst background in terminal-* built in themes ([#535](https://github.com/mfontanini/presenterm/issues/535)). * Use `std::env::temp_dir` in the `external_snippet` test ([#533](https://github.com/mfontanini/presenterm/issues/533)) - thanks @Medovi. * Respect `extends` in a theme set via `path` in front matter ([#532](https://github.com/mfontanini/presenterm/issues/532)). ## Misc * Refactor async renders (e.g. mermaid/typst/latex `+render` blocks, `+exec` blocks, etc) to work truly asynchronously. This causes the output to be polled faster, and causes jumping to a slide that contains an async render to take a likely negligible (but maybe noticeable) amount of time to be jumped to. This was needed for slide transitions to work seemlessly ([#556](https://github.com/mfontanini/presenterm/issues/556)). * Get rid of `textproperties` ([#529](https://github.com/mfontanini/presenterm/issues/529)). * Add links to presentations using presenterm ([#544](https://github.com/mfontanini/presenterm/issues/544)) - thanks @orhun. ## Performance improvements * A few performance improvements had to be done for slide transitions to work seemlessly: * Pre-scale ASCII images when transitions are enabled ([#550](https://github.com/mfontanini/presenterm/issues/550)). * Pre-scale generated images ([#553](https://github.com/mfontanini/presenterm/issues/553)). * Cache resized ASCII images ([#547](https://github.com/mfontanini/presenterm/issues/547)). ## ❤️ Sponsors Thanks to the following users who supported _presenterm_ via a [github sponsorship](https://github.com/sponsors/mfontanini) in this release: * [@0atman](https://github.com/0atman) * [@orhun](https://github.com/orhun) * [@fipoac](https://github.com/fipoac) # v0.12.0 - 2025-03-24 ## Breaking changes * Using incremental lists now adds an extra pause before and after a list. Use the `defaults.incremental_lists` [configuration parameter](https://mfontanini.github.io/presenterm/features/commands.html#incremental-lists-behavior) to go back to the previous behavior ([#487](https://github.com/mfontanini/presenterm/issues/487)) ([#498](https://github.com/mfontanini/presenterm/issues/498)). ## New features * [PDF exports](https://mfontanini.github.io/presenterm/features/pdf-export.html) are now generated by invoking [weasyprint](https://pypi.org/project/weasyprint/) rather than by using the now deprecated _presenterm-export_. This gets rid of the need for _tmux_ and opens up the door for other export formats ([#509](https://github.com/mfontanini/presenterm/issues/509)) ([#517](https://github.com/mfontanini/presenterm/issues/517)). * PDF export dimensions can now also be [specified in the config file](https://mfontanini.github.io/presenterm/configuration/settings.html#pdf-export-size) rather than always having them inferred by the terminal size ([#511](https://github.com/mfontanini/presenterm/issues/511)). * Allow specifying path for temporary files generated during presentation export ([#518](https://github.com/mfontanini/presenterm/issues/518)). * Respect font sizes in generated PDF ([#510](https://github.com/mfontanini/presenterm/issues/510)). * Add [`skip_slide` comment command](https://mfontanini.github.io/presenterm/features/commands.html#skip-slide) to avoid including a slide in the final presentation ([#505](https://github.com/mfontanini/presenterm/issues/505)). * Add [`alignment` comment](https://mfontanini.github.io/presenterm/features/commands.html#text-alignment) command to specify text alignment for the remainder of a slide ([#493](https://github.com/mfontanini/presenterm/issues/493)) ([#522](https://github.com/mfontanini/presenterm/issues/522)). * Add `--current-theme` CLI parameter to display the theme being used ([#489](https://github.com/mfontanini/presenterm/issues/489)). * Add gruvbox dark theme ([#483](https://github.com/mfontanini/presenterm/issues/483)) - thanks @ret2src. ## Fixes * Fix broken ANSI escape code parsing which would cause command output to sometimes be incorrectly parsed and therefore led to its colors/attributes not being respected ([#500](https://github.com/mfontanini/presenterm/issues/500)). * Center lists correctly ([#512](https://github.com/mfontanini/presenterm/issues/512)) ([#520](https://github.com/mfontanini/presenterm/issues/520)). * Respect end slide shorthand in speaker notes mode ([#494](https://github.com/mfontanini/presenterm/issues/494)). * Use more visible colors in snippet execution output in terminal-light/dark themes ([#485](https://github.com/mfontanini/presenterm/issues/485)). * Show error if sixel mode is selected but disabled ([#525](https://github.com/mfontanini/presenterm/issues/525)). ## CI * Add nightly build job ([#496](https://github.com/mfontanini/presenterm/issues/496)). ## Docs * Fix typo in README.md ([#490](https://github.com/mfontanini/presenterm/issues/490)) - thanks @eltociear. * Correctly include layout pic ([#495](https://github.com/mfontanini/presenterm/issues/495)) - thanks @Tuxified. ## Misc * Cleanup text attributes ([#519](https://github.com/mfontanini/presenterm/issues/519)). * Refactor snippet processing ([#484](https://github.com/mfontanini/presenterm/issues/484)). ## Sponsors It is now possible to sponsor this project via [github sponsors](https://github.com/sponsors/mfontanini). Thanks to [@0atman](https://github.com/0atman) for being the first project sponsor! # v0.11.0 - 2025-03-08 ## Breaking changes * Footer templates are now sanitized, and any variables surrounded in braces that aren't supported (e.g. `{potato}`) will now cause _presenterm_ to display an error. If you'd like to use braces in contexts where you're not trying to reference a variable you can use double braces, e.g. `live at {{PotatoConf}}` ([#442](https://github.com/mfontanini/presenterm/issues/442)) ([#467](https://github.com/mfontanini/presenterm/issues/467)) ([#469](https://github.com/mfontanini/presenterm/issues/469)) ([#471](https://github.com/mfontanini/presenterm/issues/471)). ## New features * [Add support for kitty's font size protocol](https://mfontanini.github.io/presenterm/features/introduction.html#font-sizes). This is now used by default in built in themes in a few components such as the intro slide's title and slide titles. See the [example presentation gif](https://github.com/mfontanini/presenterm/blob/master/docs/src/assets/demo.gif) to check out how this looks like. Terminal suport for this feature is detected on startup and will be ignored if unsupported. This requires _kitty_ >= 0.40.0 ([#438](https://github.com/mfontanini/presenterm/issues/438)) ([#460](https://github.com/mfontanini/presenterm/issues/460)) ([#470](https://github.com/mfontanini/presenterm/issues/470)). * [Allow specifying font size in a comment command](https://mfontanini.github.io/presenterm/features/commands.html#font-size), which causes any subsequent text in a slide to use the specified font size. Just like the above, only supported in _kitty_ >= 0.40.0 for now ([#458](https://github.com/mfontanini/presenterm/issues/458)). * [Footers can now contain images](https://mfontanini.github.io/presenterm/features/themes/definition.html#footer-images) in the left and center components. This allows including some form of branding/company logo to your presentations ([#450](https://github.com/mfontanini/presenterm/issues/450)) ([#476](https://github.com/mfontanini/presenterm/issues/476)). * [Footers can now contain inline markdown](https://mfontanini.github.io/presenterm/features/themes/definition.html#template-footers), which allows using bold, italics, `` tags for colors, etc ([#466](https://github.com/mfontanini/presenterm/issues/466)). * [Presentation titles can now contain inline markdown](https://mfontanini.github.io/presenterm/features/introduction.html#introduction-slide) ([#464](https://github.com/mfontanini/presenterm/issues/464)). * [Introduce palette.classes in themes](https://mfontanini.github.io/presenterm/features/themes/definition.html#color-palette) to allow specifying combinations of foreground/background colors that can be referenced via the `class` attribute in `` tags ([#468](https://github.com/mfontanini/presenterm/issues/468)). * It's now possible to [configure the alignment](https://mfontanini.github.io/presenterm/configuration/settings.html#maximum-presentation-width) to use when `max_columns` is configured and the terminal width is larger than it ([#475](https://github.com/mfontanini/presenterm/issues/475)). * Add support for wikilinks ([#448](https://github.com/mfontanini/presenterm/issues/448)). ## Fixes * Don't get stuck if tmux doesn't passthrough ([#456](https://github.com/mfontanini/presenterm/issues/456)). * Don't squash image if terminal's font aspect ratio is not 2:1 ([#446](https://github.com/mfontanini/presenterm/issues/446)). * Fail if `--config-file` points to non existent file ([#474](https://github.com/mfontanini/presenterm/issues/474)). * Use right script name for kotlin files when executing ([#462](https://github.com/mfontanini/presenterm/issues/462)). * Respect lists that start at non 1 indexes ([#459](https://github.com/mfontanini/presenterm/issues/459)). * Jump to right slide on code attribute change ([#478](https://github.com/mfontanini/presenterm/issues/478)). ## Improvements * Remove `result` return type from builder fns that don't need it ([#465](https://github.com/mfontanini/presenterm/issues/465)). * Refactor theme code ([#463](https://github.com/mfontanini/presenterm/issues/463)). * Restructure `terminal` code and add test for margins/layouts ([#443](https://github.com/mfontanini/presenterm/issues/443)). * Use `fastrand` instead of `rand` ([#441](https://github.com/mfontanini/presenterm/issues/441)). * Avoid cloning strings when styling them ([#440](https://github.com/mfontanini/presenterm/issues/440)). # v0.10.1 - 2025-02-14 ## Fixes * Don't error out if `options` in front matter doesn't include `auto_render_languages` ([#454](https://github.com/mfontanini/presenterm/pull/454)). * Bump sixel-rs to 0.4.1 to fix build in aarch64 and riscv64 ([#452](https://github.com/mfontanini/presenterm/pull/452)) - thanks @Xeonacid. # v0.10.0 - 2025-02-02 ## New features * Support for presentation speaker notes ([#389](https://github.com/mfontanini/presenterm/issues/389)) ([#419](https://github.com/mfontanini/presenterm/issues/419)) ([#421](https://github.com/mfontanini/presenterm/issues/421)) ([#425](https://github.com/mfontanini/presenterm/issues/425)) - thanks @dmackdev. * Add support for colored text via inline `span` HTML tags ([#390](https://github.com/mfontanini/presenterm/issues/390)). * Add a color palette in themes to allow reusing colors across the theme and using predefined colors inside `span` tags ([#427](https://github.com/mfontanini/presenterm/issues/427)). * Add support for github/gitlab style markdown alerts ([#423](https://github.com/mfontanini/presenterm/issues/423)) ([#430](https://github.com/mfontanini/presenterm/issues/430)). * Allow using `+image` on code blocks to consume their output as an image ([#429](https://github.com/mfontanini/presenterm/issues/429)). * Allow multiline comment commands ([#424](https://github.com/mfontanini/presenterm/issues/424)). * Allow auto rendering mermaid/typst/latex code blocks ([#418](https://github.com/mfontanini/presenterm/issues/418)). * Allow capping max columns on presentation ([#417](https://github.com/mfontanini/presenterm/issues/417)). * Automatically detect kitty support, including when running inside tmux ([#406](https://github.com/mfontanini/presenterm/issues/406)). * Use kitty image protocol in ghostty ([#405](https://github.com/mfontanini/presenterm/issues/405)). * Force color output in rust, c, and c++ compiler executions ([#401](https://github.com/mfontanini/presenterm/issues/401)). * Add graphql code highlighting ([#385](https://github.com/mfontanini/presenterm/issues/385)) - thanks @GV14982. * Add tcl code highlighting ([#387](https://github.com/mfontanini/presenterm/issues/387)) - thanks @jtplaarj. * Add Haskell executor ([#414](https://github.com/mfontanini/presenterm/issues/414)) - thanks @feature-not-a-bug. * Add C# to code executors ([#399](https://github.com/mfontanini/presenterm/issues/399)) - thanks @giggio. * Add R to executors ([#393](https://github.com/mfontanini/presenterm/issues/393)) - thanks @jonocarroll. ## Fixes * Check for `term_program` before `term` to determine emulator ([#420](https://github.com/mfontanini/presenterm/issues/420)). * Allow jumping back to column in column layout ([#396](https://github.com/mfontanini/presenterm/issues/396)). * Ignore comments that start with `vim:` prefix ([#395](https://github.com/mfontanini/presenterm/issues/395)). * Respect `+no_background` on a `+exec_replace` block ([#383](https://github.com/mfontanini/presenterm/issues/383)). ## Docs * Document tmux active session bug ([#402](https://github.com/mfontanini/presenterm/issues/402)). * Add notes on running `bat` directly ([#397](https://github.com/mfontanini/presenterm/issues/397)). # v0.9.0 - 2024-10-06 ## Breaking changes * Default themes now no longer use a progress bar based footer. Instead they use indicator of the current page number and the total number of pages. If you'd like to preserve the old behavior, you can override the theme by using `footer.style = progress_bar` in [your theme](https://mfontanini.github.io/presenterm/guides/themes.html#setting-themes). * Links that include a title (e.g. `[my title](http://example.com)`) now have their title rendered as well. Removing a link's title will make it look the same as they used to. ## New features * Use "template" footer in built-in themes ([#358](https://github.com/mfontanini/presenterm/issues/358)). * Allow including external code snippets ([#328](https://github.com/mfontanini/presenterm/issues/328)) ([#372](https://github.com/mfontanini/presenterm/issues/372)). * Add `+no_background` property to remove background from code blocks ([#363](https://github.com/mfontanini/presenterm/issues/363)) ([#368](https://github.com/mfontanini/presenterm/issues/368)). * Show colored output from snippet execution output ([#316](https://github.com/mfontanini/presenterm/issues/316)). * Style markdown inside block quotes ([#350](https://github.com/mfontanini/presenterm/issues/350)) ([#351](https://github.com/mfontanini/presenterm/issues/351)). * Allow using all intro slide variables in footer template ([#338](https://github.com/mfontanini/presenterm/issues/338)). * Include hidden line prefix in executors file ([#337](https://github.com/mfontanini/presenterm/issues/337)). * Show link labels and titles ([#334](https://github.com/mfontanini/presenterm/issues/334)). * Add `+exec_replace` which executes snippets and replaces them with their output ([#330](https://github.com/mfontanini/presenterm/issues/330)) ([#371](https://github.com/mfontanini/presenterm/issues/371)). * Always show snippet execution bar ([#329](https://github.com/mfontanini/presenterm/issues/329)). * Handle suspend signal (SIGTSTP) ([#318](https://github.com/mfontanini/presenterm/issues/318)). * Allow closing with `q` ([#321](https://github.com/mfontanini/presenterm/issues/321)). * Add event, location, and date labels in intro slide ([#317](https://github.com/mfontanini/presenterm/issues/317)). * Use transparent background in mermaid charts ([#314](https://github.com/mfontanini/presenterm/issues/314)). * Add `+acquire_terminal` to acquire the terminal when running snippets ([#366](https://github.com/mfontanini/presenterm/issues/366)) ([#376](https://github.com/mfontanini/presenterm/pull/376)). * Add PHP executor ([#332](https://github.com/mfontanini/presenterm/issues/332)). * Add Racket syntax highlighting ([#367](https://github.com/mfontanini/presenterm/issues/367)). * Add TOML highlighting ([#361](https://github.com/mfontanini/presenterm/issues/361)). ## Fixes * Wrap code snippets if they don't fit in terminal ([#320](https://github.com/mfontanini/presenterm/issues/320)). * Allow list-themes/acknowledgements to run without path ([#359](https://github.com/mfontanini/presenterm/issues/359)). * Translate tabs in code snippets to 4 spaces ([#356](https://github.com/mfontanini/presenterm/issues/356)). * Add padding to right of code block wrapped lines ([#354](https://github.com/mfontanini/presenterm/issues/354)). * Don't wrap code snippet separator line ([#353](https://github.com/mfontanini/presenterm/issues/353)). * Show block quote prefix when wrapping ([#352](https://github.com/mfontanini/presenterm/issues/352)). * Don't crash on code block with only hidden-line-prefixed lines ([#347](https://github.com/mfontanini/presenterm/issues/347)). * Canonicalize resources path ([#333](https://github.com/mfontanini/presenterm/issues/333)). * Execute script relative to current working directory ([#323](https://github.com/mfontanini/presenterm/issues/323)). * Support rendering mermaid charts on windows ([#319](https://github.com/mfontanini/presenterm/issues/319)). ## Improvements * Add example on how column layouts and pauses interact ([#348](https://github.com/mfontanini/presenterm/issues/348)). * Rename `jump_to_vertical_center` -> `jump_to_middle` in docs ([#342](https://github.com/mfontanini/presenterm/issues/342)). * Document `all` snippet highlighting keyword ([#335](https://github.com/mfontanini/presenterm/issues/335)). # v0.8.0 - 2024-07-29 ## Breaking changes * Force users to explicitly enable snippet execution ([#276](https://github.com/mfontanini/presenterm/issues/276)) ([#281](https://github.com/mfontanini/presenterm/issues/281)). ## New features * Code snippet execution for various programming languages ([#253](https://github.com/mfontanini/presenterm/issues/253)) ([#255](https://github.com/mfontanini/presenterm/issues/255)) ([#256](https://github.com/mfontanini/presenterm/issues/256)) ([#258](https://github.com/mfontanini/presenterm/issues/258)) ([#282](https://github.com/mfontanini/presenterm/issues/282)). * Allow executing compiled snippets in windows ([#303](https://github.com/mfontanini/presenterm/issues/303)). * Add support for hidden lines in code snippets ([#283](https://github.com/mfontanini/presenterm/issues/283)) ([#254](https://github.com/mfontanini/presenterm/issues/254)) - thanks @dmackdev. * Support [mermaid](https://mermaid.js.org/) snippet rendering to image via `+render` attribute ([#268](https://github.com/mfontanini/presenterm/issues/268)). * Allow scaling images dynamically based on terminal size ([#288](https://github.com/mfontanini/presenterm/issues/288)) ([#291](https://github.com/mfontanini/presenterm/issues/291)). * Allow scaling images generated via `+render` code blocks (mermaid, typst, latex) ([#290](https://github.com/mfontanini/presenterm/issues/290)). * Show `stderr` output from code execution ([#252](https://github.com/mfontanini/presenterm/issues/252)) - thanks @dmackdev. * Wait for code execution process to exit completely ([#250](https://github.com/mfontanini/presenterm/issues/250)) - thanks @dmackdev. * Generate images in `+render` code snippets asynchronously ([#273](https://github.com/mfontanini/presenterm/issues/273)) ([#293](https://github.com/mfontanini/presenterm/issues/293)) ([#284](https://github.com/mfontanini/presenterm/issues/284)) ([#279](https://github.com/mfontanini/presenterm/issues/279)). * Dim non highlighted code snippet lines ([#287](https://github.com/mfontanini/presenterm/issues/287)). * Shrink snippet execution to match code block width ([#286](https://github.com/mfontanini/presenterm/issues/286)). * Include code snippet execution output in generated PDF ([#295](https://github.com/mfontanini/presenterm/issues/295)). * Cache `+render` block images ([#270](https://github.com/mfontanini/presenterm/issues/270)). * Add kotlin script executor ([#257](https://github.com/mfontanini/presenterm/issues/257)) - thanks @dmackdev. * Add nushell code execution ([#274](https://github.com/mfontanini/presenterm/issues/274)) ([#275](https://github.com/mfontanini/presenterm/issues/275)) - thanks @PitiBouchon. * Add rust-script as a new code executor ([#269](https://github.com/mfontanini/presenterm/issues/269)) - @ZhangHanDong. * Allow custom themes to extend others ([#265](https://github.com/mfontanini/presenterm/issues/265)). * Allow jumping fast between slides ([#244](https://github.com/mfontanini/presenterm/issues/244)). * Allow explicitly disabling footer in certain slides ([#239](https://github.com/mfontanini/presenterm/issues/239)). * Allow using image paths in typst ([#235](https://github.com/mfontanini/presenterm/issues/235)). * Add JSON schema for validation,completion,documentation ([#228](https://github.com/mfontanini/presenterm/issues/228)) ([#236](https://github.com/mfontanini/presenterm/issues/236)) - thanks @mikavilpas. * Allow having multiple authors ([#227](https://github.com/mfontanini/presenterm/issues/227)). ## Fixes * Avoid re-rendering code output and auto rendered blocks ([#280](https://github.com/mfontanini/presenterm/issues/280)). * Use unicode width to calculate execution output's line len ([#261](https://github.com/mfontanini/presenterm/issues/261)). * Display background color behind '\t' in code exec output ([#245](https://github.com/mfontanini/presenterm/issues/245)). * Close child process stdin by default ([#297](https://github.com/mfontanini/presenterm/issues/297)). ## Improvements * Update install instructions for Arch Linux ([#248](https://github.com/mfontanini/presenterm/issues/248)) - thanks @orhun. * Fix all clippy warnings ([#231](https://github.com/mfontanini/presenterm/issues/231)) - thanks @mikavilpas. * Include strict `_front_matter_parsing` in default config ([#229](https://github.com/mfontanini/presenterm/issues/229)) - thanks @mikavilpas. * `CHANGELOG.md` contains clickable links to issues ([#230](https://github.com/mfontanini/presenterm/issues/230)) - thanks @mikavilpas. * Add Support for Ruby Code Highlighting ([#226](https://github.com/mfontanini/presenterm/issues/226)) - thanks @pranavrao145. * Use ".presenterm" as prefix for tmp files ([#306](https://github.com/mfontanini/presenterm/issues/306)). * Add more descriptive error message when loading image fails ([#298](https://github.com/mfontanini/presenterm/issues/298)). * Align all error messages to left ([#301](https://github.com/mfontanini/presenterm/issues/301)). # v0.7.0 - 2024-03-02 ## New features * Add color to prefix in block quote ([#218](https://github.com/mfontanini/presenterm/issues/218)). * Allow having code blocks without background ([#215](https://github.com/mfontanini/presenterm/issues/215) [#216](https://github.com/mfontanini/presenterm/issues/216)). * Allow validating whether presentation overflows terminal ([#209](https://github.com/mfontanini/presenterm/issues/209) [#211](https://github.com/mfontanini/presenterm/issues/211)). * Add parameter to list themes ([#207](https://github.com/mfontanini/presenterm/issues/207)). * Add catppuccin themes ([#197](https://github.com/mfontanini/presenterm/issues/197) [#205](https://github.com/mfontanini/presenterm/issues/205) [#206](https://github.com/mfontanini/presenterm/issues/206)) - thanks @Mawdac. * Detect konsole terminal emulator ([#204](https://github.com/mfontanini/presenterm/issues/204)). * Allow customizing slide title style ([#201](https://github.com/mfontanini/presenterm/issues/201)). ## Fixes * Don't crash in present mode ([#210](https://github.com/mfontanini/presenterm/issues/210)). * Set colors properly before displaying an error ([#212](https://github.com/mfontanini/presenterm/issues/212)). ## Improvements * Suggest a tool is missing when spawning returns ENOTFOUND ([#221](https://github.com/mfontanini/presenterm/issues/221)). * Sort input file list ([#202](https://github.com/mfontanini/presenterm/issues/202)) - thanks @bmwiedemann. * Add more example presentations ([#217](https://github.com/mfontanini/presenterm/issues/217)). * Add Scoop to package managers ([#200](https://github.com/mfontanini/presenterm/issues/200)) - thanks @nagromc. * Remove support for uncommon image formats ([#208](https://github.com/mfontanini/presenterm/issues/208)). # v0.6.1 - 2024-02-11 ## Fixes * Don't escape symbols in block quotes ([#195](https://github.com/mfontanini/presenterm/issues/195)). * Respect `XDG_CONFIG_HOME` when loading configuration files and custom themes ([#193](https://github.com/mfontanini/presenterm/issues/193)). # v0.6.0 - 2024-02-09 ## Breaking changes * The default configuration file and custom themes paths have been changed in Windows and macOS to be compliant to where those platforms store these types of files. See the [configuration guide](https://mfontanini.github.io/presenterm/guides/configuration.html) to learn more. ## New features * Add `f` keys, tab, and backspace as possible bindings ([#188](https://github.com/mfontanini/presenterm/issues/188)). * Add support for multiline block quotes ([#184](https://github.com/mfontanini/presenterm/issues/184)). * Use theme color as background on ascii-blocks mode images ([#182](https://github.com/mfontanini/presenterm/issues/182)). * Blend ascii-blocks image semi-transparent borders ([#185](https://github.com/mfontanini/presenterm/issues/185)). * Respect Windows/macOS config paths for configuration ([#181](https://github.com/mfontanini/presenterm/issues/181)). * Allow making front matter strict parsing optional ([#190](https://github.com/mfontanini/presenterm/issues/190)). ## Fixes * Don't add an extra line after an end slide shorthand ([#187](https://github.com/mfontanini/presenterm/issues/187)). * Don't clear input state on key release event ([#183](https://github.com/mfontanini/presenterm/issues/183)). # v0.5.0 - 2024-01-26 ## New features * Support images on Windows ([#120](https://github.com/mfontanini/presenterm/issues/120)). * Support animated gifs on kitty terminal ([#157](https://github.com/mfontanini/presenterm/issues/157) [#161](https://github.com/mfontanini/presenterm/issues/161)). * Support images on tmux running in kitty terminal ([#166](https://github.com/mfontanini/presenterm/issues/166)). * Improve sixel support ([#169](https://github.com/mfontanini/presenterm/issues/169) [#172](https://github.com/mfontanini/presenterm/issues/172)). * Use synchronized updates to remove flickering when switching slides ([#156](https://github.com/mfontanini/presenterm/issues/156)). * Add newlines command ([#167](https://github.com/mfontanini/presenterm/issues/167)). * Detect image protocol instead of relying on viuer ([#160](https://github.com/mfontanini/presenterm/issues/160)). * Turn documentation into mdbook ([#141](https://github.com/mfontanini/presenterm/issues/141) [#147](https://github.com/mfontanini/presenterm/issues/147)) - thanks @pwnwriter. * Allow using thematic breaks to end slides ([#138](https://github.com/mfontanini/presenterm/issues/138)). * Allow specifying the preferred image protocol via `--image-protocol` / config file ([#136](https://github.com/mfontanini/presenterm/issues/136) [#170](https://github.com/mfontanini/presenterm/issues/170)). * Add slide index modal ([#128](https://github.com/mfontanini/presenterm/issues/128) [#139](https://github.com/mfontanini/presenterm/issues/139) [#133](https://github.com/mfontanini/presenterm/issues/133) [#158](https://github.com/mfontanini/presenterm/issues/158)). * Allow defining custom keybindings in config file ([#132](https://github.com/mfontanini/presenterm/issues/132) [#155](https://github.com/mfontanini/presenterm/issues/155)). * Add key bindings modal ([#152](https://github.com/mfontanini/presenterm/issues/152)). * Prioritize CLI args `--theme` over anything else ([#116](https://github.com/mfontanini/presenterm/issues/116)). * Allow enabling automatic list pauses ([#106](https://github.com/mfontanini/presenterm/issues/106) [#109](https://github.com/mfontanini/presenterm/issues/109) [#110](https://github.com/mfontanini/presenterm/issues/110)). * Allow passing in config file path via CLI arg ([#174](https://github.com/mfontanini/presenterm/issues/174)). ## Fixes * Shrink columns layout dimensions correctly when shrinking left ([#113](https://github.com/mfontanini/presenterm/issues/113)). * Explicitly set execution output foreground color in built-in themes ([#122](https://github.com/mfontanini/presenterm/issues/122)). * Detect sixel early and fallback to ascii blocks properly ([#135](https://github.com/mfontanini/presenterm/issues/135)). * Exit with a clap error on missing path ([#150](https://github.com/mfontanini/presenterm/issues/150)). * Don't blow up if presentation file temporarily disappears ([#154](https://github.com/mfontanini/presenterm/issues/154)). * Parse front matter properly in presence of \r\n ([#162](https://github.com/mfontanini/presenterm/issues/162)). * Don't preload graphics mode when generating pdf metadata ([#168](https://github.com/mfontanini/presenterm/issues/168)). * Ignore key release events ([#119](https://github.com/mfontanini/presenterm/issues/119)). ## Improvements * Validate that config file contains the right attributes ([#107](https://github.com/mfontanini/presenterm/issues/107)). * Display first presentation load error as any other ([#118](https://github.com/mfontanini/presenterm/issues/118)). * Add hashes for windows artifacts ([#126](https://github.com/mfontanini/presenterm/issues/126)). * Remove arch packaging files ([#111](https://github.com/mfontanini/presenterm/issues/111)). * Lower CPU and memory usage when displaying images ([#157](https://github.com/mfontanini/presenterm/issues/157)). # v0.4.1 - 2023-12-22 ## New features * Cause an error if an unknown field name is found on a theme, config file, or front matter ([#102](https://github.com/mfontanini/presenterm/issues/102)). ## Fixes * Explicitly disable kitty/iterm protocols when printing images in export PDF mode as this was causing PDF generation in macOS to fail ([#101](https://github.com/mfontanini/presenterm/issues/101)). # v0.4.0 - 2023-12-16 ## New features * Add support for all of [bat](https://github.com/sharkdp/bat)'s code highlighting themes ([#67](https://github.com/mfontanini/presenterm/issues/67)). * Add `terminal-dark` and `terminal-light` themes that preserve the terminal's colors and background ([#68](https://github.com/mfontanini/presenterm/issues/68) [#69](https://github.com/mfontanini/presenterm/issues/69)). * Allow placing themes in `$HOME/.config/presenterm/themes` to make them available automatically as if they were built-in themes ([#73](https://github.com/mfontanini/presenterm/issues/73)). * Allow configuring the default theme in `$HOME/.config/presenterm/config.yaml` ([#74](https://github.com/mfontanini/presenterm/issues/74)). * Add support for rendering _LaTeX_ and _typst_ code blocks automatically as images ([#75](https://github.com/mfontanini/presenterm/issues/75) [#76](https://github.com/mfontanini/presenterm/issues/76) [#79](https://github.com/mfontanini/presenterm/issues/79) [#81](https://github.com/mfontanini/presenterm/issues/81)). * Add syntax highlighting support for _nix_ and _diff_ ([#78](https://github.com/mfontanini/presenterm/issues/78) [#82](https://github.com/mfontanini/presenterm/issues/82)). * Add comment command to jump into the middle of a slide ([#86](https://github.com/mfontanini/presenterm/issues/86)). * Add configuration option to have implicit slide ends ([#87](https://github.com/mfontanini/presenterm/issues/87) [#89](https://github.com/mfontanini/presenterm/issues/89)). * Add configuration option to have custom comment-command prefix ([#91](https://github.com/mfontanini/presenterm/issues/91)). # v0.3.0 - 2023-11-24 ## New features * Support more languages in code blocks thanks to [bat](https://github.com/sharkdp/bat)'s syntax sets ([#21](https://github.com/mfontanini/presenterm/issues/21) [#53](https://github.com/mfontanini/presenterm/issues/53)). * Add shell script executable code blocks ([#17](https://github.com/mfontanini/presenterm/issues/17)). * Allow exporting presentation to PDF ([#43](https://github.com/mfontanini/presenterm/issues/43) [#60](https://github.com/mfontanini/presenterm/issues/60)). * Pauses no longer create new slides ([#18](https://github.com/mfontanini/presenterm/issues/18) [#25](https://github.com/mfontanini/presenterm/issues/25) [#34](https://github.com/mfontanini/presenterm/issues/34) [#42](https://github.com/mfontanini/presenterm/issues/42)). * Allow display code block line numbers ([#46](https://github.com/mfontanini/presenterm/issues/46)). * Allow code block selective line highlighting ([#48](https://github.com/mfontanini/presenterm/issues/48)). * Allow code block dynamic line highlighting ([#49](https://github.com/mfontanini/presenterm/issues/49)). * Support animated gifs when using the iterm2 image protocol ([#56](https://github.com/mfontanini/presenterm/issues/56)). * Nix flake packaging ([#11](https://github.com/mfontanini/presenterm/issues/11) [#27](https://github.com/mfontanini/presenterm/issues/27)). * Arch repo packaging ([#10](https://github.com/mfontanini/presenterm/issues/10)). * Ignore vim-like code folding tags in comments. * Add keybinding to refresh assets in presentation ([#38](https://github.com/mfontanini/presenterm/issues/38)). * Template style footer is now one row above bottom ([#39](https://github.com/mfontanini/presenterm/issues/39)). * Add `light` theme. ## Fixes * Don't crash on Windows when terminal window size can't be found ([#14](https://github.com/mfontanini/presenterm/issues/14)). * Don't reset numbers on ordered lists when using pauses in between ([#19](https://github.com/mfontanini/presenterm/issues/19)). * Show proper line number when parsing a comment command fails ([#29](https://github.com/mfontanini/presenterm/issues/29) [#40](https://github.com/mfontanini/presenterm/issues/40)). * Don't reset the default footer when overriding theme in presentation without setting footer ([#52](https://github.com/mfontanini/presenterm/issues/52)). * Don't let code blocks/block quotes that don't fit on the screen cause images to overlap with text ([#57](https://github.com/mfontanini/presenterm/issues/57)). # v0.2.1 - 2023-10-18 ## New features * Binary artifacts are now automatically generated when a new release is done ([#5](https://github.com/mfontanini/presenterm/issues/5)) - thanks @pwnwriter. # v0.2.0 - 2023-10-17 ## New features * [Column layouts](https://github.com/mfontanini/presenterm/blob/26e2eb28884675aac452f4c6e03f98413654240c/docs/layouts.md) that let you structure slides into columns. * Support for `percent` margin rather than only a fixed number of columns. * Spacebar now moves the presentation into the next slide. * Add support for `center` footer when using the `template` mode. * **Breaking**: themes now only use colors in hex format. ## Fixes * Allow using `sh` as language for code block ([#3](https://github.com/mfontanini/presenterm/issues/3)). * Minimum size for code blocks is now prioritized over minimum margin. * Overflowing lines in lists will now correctly be padded to align all text under the same starting column. * Running `cargo run` will now rebuild the tool if any of the built-in themes changed. * `alignment` was removed from certain elements (like `list`) as it didn't really make sense. * `default.alignment` is now no longer supported and by default we use left alignment. Use `default.margin` to specify the margins to use. # v0.1.0 - 2023-10-08 ## Features * Define your presentation in a single markdown file. * Image rendering support for iterm2, terminals that support the kitty graphics protocol, or sixel. * Customize your presentation's look by defining themes, including colors, margins, layout (left/center aligned content), footer for every slide, etc. * Code highlighting for a wide list of programming languages. * Support for an introduction slide that displays the presentation title and your name. * Support for slide titles. * Create pauses in between each slide so that it progressively renders for a more interactive presentation. * Text formatting support for **bold**, _italics_, ~strikethrough~, and `inline code`. * Automatically reload your presentation every time it changes for a fast development loop. presenterm-0.15.1/Cargo.lock0000644000001170150000000000100112640ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] name = "anstream" version = "0.6.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] name = "anstyle-parse" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "anstyle-wincon" version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" dependencies = [ "anstyle", "once_cell_polyfill", "windows-sys 0.59.0", ] [[package]] name = "anyhow" version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bincode" version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" dependencies = [ "serde", ] [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" [[package]] name = "bumpalo" version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytemuck" version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" [[package]] name = "byteorder-lite" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "caseless" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" dependencies = [ "unicode-normalization", ] [[package]] name = "cc" version = "1.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362" dependencies = [ "shlex", ] [[package]] name = "cfg-if" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" [[package]] name = "clap" version = "4.5.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" dependencies = [ "clap_builder", "clap_derive", ] [[package]] name = "clap_builder" version = "4.5.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", ] [[package]] name = "clap_derive" version = "4.5.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" dependencies = [ "heck", "proc-macro2", "quote", "syn", ] [[package]] name = "clap_lex" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "color_quant" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "comrak" version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fefab951771fc3beeed0773ce66a4f7b706273fc6c4c95b08dd1615744abcf5" dependencies = [ "caseless", "entities", "memchr", "slug", "typed-arena", "unicode_categories", ] [[package]] name = "crc32fast" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" dependencies = [ "cfg-if", ] [[package]] name = "crossterm" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags 2.9.1", "crossterm_winapi", "document-features", "mio", "parking_lot", "rustix", "signal-hook", "signal-hook-mio", "winapi", ] [[package]] name = "crossterm_winapi" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" dependencies = [ "winapi", ] [[package]] name = "deranged" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" dependencies = [ "powerfmt", ] [[package]] name = "deunicode" version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "directories" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", "redox_users", "windows-sys 0.60.2", ] [[package]] name = "document-features" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" dependencies = [ "litrs", ] [[package]] name = "dyn-clone" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "entities" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", "windows-sys 0.60.2", ] [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fdeflate" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" dependencies = [ "simd-adler32", ] [[package]] name = "flate2" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", "miniz_oxide", ] [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "getrandom" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", "wasi", ] [[package]] name = "gif" version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" dependencies = [ "color_quant", "weezl", ] [[package]] name = "glob" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" [[package]] name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "image" version = "0.25.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" dependencies = [ "bytemuck", "byteorder-lite", "color_quant", "gif", "num-traits", "png", "zune-core", "zune-jpeg", ] [[package]] name = "indexmap" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", "hashbrown", ] [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itertools" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itoa" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "libc" version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" [[package]] name = "libredox" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638" dependencies = [ "bitflags 2.9.1", "libc", ] [[package]] name = "linux-raw-sys" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "litrs" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" [[package]] name = "lock_api" version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" dependencies = [ "autocfg", "scopeguard", ] [[package]] name = "log" version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "make-cmd" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8ca8afbe8af1785e09636acb5a41e08a765f5f0340568716c18a8700ba3c0d3" [[package]] name = "memchr" version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "merge-struct" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d82012d21e24135b839b6b9bebd622b7ff0cb40071498bc2d066d3a6d04dd4a" dependencies = [ "serde", "serde_json", ] [[package]] name = "miniz_oxide" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", "simd-adler32", ] [[package]] name = "mio" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", "log", "wasi", "windows-sys 0.59.0", ] [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" [[package]] name = "onig" version = "6.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" dependencies = [ "bitflags 2.9.1", "libc", "once_cell", "onig_sys", ] [[package]] name = "onig_sys" version = "69.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" dependencies = [ "cc", "pkg-config", ] [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "os_pipe" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db335f4760b14ead6290116f2427bf33a14d4f0617d49f78a246de10c1831224" dependencies = [ "libc", "windows-sys 0.59.0", ] [[package]] name = "parking_lot" version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", "windows-targets 0.52.6", ] [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "plist" version = "1.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" dependencies = [ "base64", "indexmap", "quick-xml", "serde", "time", ] [[package]] name = "png" version = "0.17.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" dependencies = [ "bitflags 1.3.2", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "presenterm" version = "0.15.1" dependencies = [ "anyhow", "base64", "bincode", "clap", "comrak", "crossterm", "directories", "fastrand", "flate2", "hex", "image", "itertools", "libc", "merge-struct", "once_cell", "os_pipe", "rstest", "schemars", "serde", "serde_json", "serde_yaml", "sixel-rs", "socket2", "strum", "syntect", "tempfile", "thiserror 2.0.12", "tl", "unicode-width", "vte", ] [[package]] name = "proc-macro2" version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" dependencies = [ "unicode-ident", ] [[package]] name = "quick-xml" version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8927b0664f5c5a98265138b7e3f90aa19a6b21353182469ace36d4ac527b7b1b" dependencies = [ "memchr", ] [[package]] name = "quote" version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] [[package]] name = "redox_syscall" version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" dependencies = [ "bitflags 2.9.1", ] [[package]] name = "redox_users" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom", "libredox", "thiserror 2.0.12", ] [[package]] name = "regex" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "relative-path" version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] name = "rstest" version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" dependencies = [ "rstest_macros", "rustc_version", ] [[package]] name = "rstest_macros" version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" dependencies = [ "cfg-if", "glob", "proc-macro2", "quote", "regex", "relative-path", "rustc_version", "syn", "unicode-ident", ] [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ "semver", ] [[package]] name = "rustix" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" dependencies = [ "bitflags 2.9.1", "errno", "libc", "linux-raw-sys", "windows-sys 0.59.0", ] [[package]] name = "rustversion" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ "winapi-util", ] [[package]] name = "schemars" version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "schemars_derive", "serde", "serde_json", ] [[package]] name = "schemars_derive" version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", "syn", ] [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semver" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" [[package]] name = "serde" version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "serde_derive_internals" version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "serde_json" version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", "memchr", "ryu", "serde", ] [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ "indexmap", "itoa", "ryu", "serde", "unsafe-libyaml", ] [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook" version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" dependencies = [ "libc", "signal-hook-registry", ] [[package]] name = "signal-hook-mio" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", "mio", "signal-hook", ] [[package]] name = "signal-hook-registry" version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" dependencies = [ "libc", ] [[package]] name = "simd-adler32" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" [[package]] name = "sixel-rs" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29059f94eafb4ace543bb8777c7e2fdac7c6aeadca9adb28ef2eab977a62f7f8" dependencies = [ "sixel-sys", ] [[package]] name = "sixel-sys" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb46e0cd5569bf910390844174a5a99d52dd40681fff92228d221d9f8bf87dea" dependencies = [ "make-cmd", ] [[package]] name = "slug" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" dependencies = [ "deunicode", "wasm-bindgen", ] [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", "windows-sys 0.52.0", ] [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" dependencies = [ "heck", "proc-macro2", "quote", "rustversion", "syn", ] [[package]] name = "syn" version = "2.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "syntect" version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1" dependencies = [ "bincode", "bitflags 1.3.2", "flate2", "fnv", "once_cell", "onig", "plist", "regex-syntax", "serde", "serde_derive", "serde_json", "thiserror 1.0.69", "walkdir", ] [[package]] name = "tempfile" version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ "fastrand", "once_cell", "rustix", "windows-sys 0.59.0", ] [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl 1.0.69", ] [[package]] name = "thiserror" version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ "thiserror-impl 2.0.12", ] [[package]] name = "thiserror-impl" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "thiserror-impl" version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "time" version = "0.3.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", "serde", "time-core", "time-macros", ] [[package]] name = "time-core" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" [[package]] name = "time-macros" version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" dependencies = [ "num-conv", "time-core", ] [[package]] name = "tinyvec" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" dependencies = [ "tinyvec_macros", ] [[package]] name = "tinyvec_macros" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tl" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b130bd8a58c163224b44e217b4239ca7b927d82bf6cc2fea1fc561d15056e3f7" [[package]] name = "typed-arena" version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "unicode-normalization" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" dependencies = [ "tinyvec", ] [[package]] name = "unicode-width" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" [[package]] name = "unicode_categories" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" [[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "vte" version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" dependencies = [ "arrayvec", "memchr", ] [[package]] name = "walkdir" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", "winapi-util", ] [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", "proc-macro2", "quote", "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", "syn", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" dependencies = [ "unicode-ident", ] [[package]] name = "weezl" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ "winapi-i686-pc-windows-gnu", "winapi-x86_64-pc-windows-gnu", ] [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ "windows-targets 0.53.2", ] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows-targets" version = "0.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" dependencies = [ "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", "windows_i686_gnullvm 0.53.0", "windows_i686_msvc 0.53.0", "windows_x86_64_gnu 0.53.0", "windows_x86_64_gnullvm 0.53.0", "windows_x86_64_msvc 0.53.0", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "zune-core" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" [[package]] name = "zune-jpeg" version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c9e525af0a6a658e031e95f14b7f889976b74a11ba0eca5a5fc9ac8a1c43a6a" dependencies = [ "zune-core", ] presenterm-0.15.1/Cargo.toml0000644000000054420000000000100113070ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" name = "presenterm" version = "0.15.1" authors = ["Matias Fontanini"] build = "build.rs" autobins = false autoexamples = false autotests = false autobenches = false description = "A terminal slideshow presentation tool" readme = "README.md" license = "BSD-2-Clause" repository = "https://github.com/mfontanini/presenterm" [profile.bench] opt-level = 3 debug = 0 [profile.dev] opt-level = 0 debug = 2 panic = "abort" [profile.release] opt-level = 3 lto = true codegen-units = 1 debug = 0 panic = "unwind" [profile.test] opt-level = 0 debug = 2 [[bin]] name = "presenterm" path = "src/main.rs" [dependencies.anyhow] version = "1" [dependencies.base64] version = "0.22" [dependencies.bincode] version = "1.3" [dependencies.clap] version = "4.4" features = [ "derive", "string", "env", ] [dependencies.comrak] version = "0.39" default-features = false [dependencies.crossterm] version = "0.29" features = [ "events", "windows", ] default-features = false [dependencies.directories] version = "6.0" [dependencies.fastrand] version = "2.3" [dependencies.flate2] version = "1.0" [dependencies.hex] version = "0.4" [dependencies.image] version = "0.25" features = [ "gif", "jpeg", "png", ] default-features = false [dependencies.itertools] version = "0.14" [dependencies.libc] version = "0.2" [dependencies.merge-struct] version = "0.1.0" [dependencies.once_cell] version = "1.19" [dependencies.os_pipe] version = "1.1.5" [dependencies.schemars] version = "0.8" optional = true [dependencies.serde] version = "1.0" features = ["derive"] [dependencies.serde_json] version = "1.0" [dependencies.serde_yaml] version = "0.9" [dependencies.sixel-rs] version = "0.4.1" optional = true [dependencies.socket2] version = "0.5.8" [dependencies.strum] version = "0.27" features = ["derive"] [dependencies.syntect] version = "5.2" features = [ "parsing", "default-themes", "regex-onig", "plist-load", ] default-features = false [dependencies.tempfile] version = "3.10" default-features = false [dependencies.thiserror] version = "2" [dependencies.tl] version = "0.7" [dependencies.unicode-width] version = "0.2" [dependencies.vte] version = "0.15" [dev-dependencies.rstest] version = "0.25" default-features = false [features] default = [] json-schema = ["dep:schemars"] sixel = ["sixel-rs"] presenterm-0.15.1/Cargo.toml.orig0000644000000031640000000000100122450ustar [package] name = "presenterm" authors = ["Matias Fontanini"] description = "A terminal slideshow presentation tool" repository = "https://github.com/mfontanini/presenterm" license = "BSD-2-Clause" version = "0.15.1" edition = "2021" [dependencies] anyhow = "1" base64 = "0.22" bincode = "1.3" clap = { version = "4.4", features = ["derive", "string", "env"] } comrak = { version = "0.39", default-features = false } crossterm = { version = "0.29", default-features = false, features = ["events", "windows"] } directories = "6.0" hex = "0.4" fastrand = "2.3" flate2 = "1.0" image = { version = "0.25", features = ["gif", "jpeg", "png"], default-features = false } sixel-rs = { version = "0.4.1", optional = true } merge-struct = "0.1.0" itertools = "0.14" once_cell = "1.19" schemars = { version = "0.8", optional = true } serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" serde_json = "1.0" syntect = { version = "5.2", features = ["parsing", "default-themes", "regex-onig", "plist-load"], default-features = false } socket2 = "0.5.8" strum = { version = "0.27", features = ["derive"] } tempfile = { version = "3.10", default-features = false } tl = "0.7" thiserror = "2" unicode-width = "0.2" os_pipe = "1.1.5" libc = "0.2" vte = "0.15" [dev-dependencies] rstest = { version = "0.25", default-features = false } [features] default = [] sixel = ["sixel-rs"] json-schema = ["dep:schemars"] [profile.dev] opt-level = 0 debug = true panic = "abort" [profile.test] opt-level = 0 debug = true [profile.release] opt-level = 3 debug = false panic = "unwind" lto = true codegen-units = 1 [profile.bench] opt-level = 3 debug = false presenterm-0.15.1/Cargo.toml.orig000064400000000000000000000031641046102023000147670ustar 00000000000000[package] name = "presenterm" authors = ["Matias Fontanini"] description = "A terminal slideshow presentation tool" repository = "https://github.com/mfontanini/presenterm" license = "BSD-2-Clause" version = "0.15.1" edition = "2021" [dependencies] anyhow = "1" base64 = "0.22" bincode = "1.3" clap = { version = "4.4", features = ["derive", "string", "env"] } comrak = { version = "0.39", default-features = false } crossterm = { version = "0.29", default-features = false, features = ["events", "windows"] } directories = "6.0" hex = "0.4" fastrand = "2.3" flate2 = "1.0" image = { version = "0.25", features = ["gif", "jpeg", "png"], default-features = false } sixel-rs = { version = "0.4.1", optional = true } merge-struct = "0.1.0" itertools = "0.14" once_cell = "1.19" schemars = { version = "0.8", optional = true } serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" serde_json = "1.0" syntect = { version = "5.2", features = ["parsing", "default-themes", "regex-onig", "plist-load"], default-features = false } socket2 = "0.5.8" strum = { version = "0.27", features = ["derive"] } tempfile = { version = "3.10", default-features = false } tl = "0.7" thiserror = "2" unicode-width = "0.2" os_pipe = "1.1.5" libc = "0.2" vte = "0.15" [dev-dependencies] rstest = { version = "0.25", default-features = false } [features] default = [] sixel = ["sixel-rs"] json-schema = ["dep:schemars"] [profile.dev] opt-level = 0 debug = true panic = "abort" [profile.test] opt-level = 0 debug = true [profile.release] opt-level = 3 debug = false panic = "unwind" lto = true codegen-units = 1 [profile.bench] opt-level = 3 debug = false presenterm-0.15.1/LICENSE000064400000000000000000000024561046102023000131100ustar 00000000000000BSD 2-Clause License Copyright (c) 2023, Matias Fontanini All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. presenterm-0.15.1/README.md000064400000000000000000000137061046102023000133620ustar 00000000000000presenterm === [![crates-badge]][crates-package] [![brew-badge]][brew-package] [![nix-badge]][nix-package] [![arch-badge]][arch-package] [![scoop-badge]][scoop-package] [brew-badge]: https://img.shields.io/homebrew/v/presenterm [brew-package]: https://formulae.brew.sh/formula/presenterm [nix-badge]: https://img.shields.io/badge/Packaged_for-Nix-5277C3.svg?logo=nixos&labelColor=73C3D5 [nix-package]: https://search.nixos.org/packages?size=1&show=presenterm [crates-badge]: https://img.shields.io/crates/v/presenterm [crates-package]: https://crates.io/crates/presenterm [arch-badge]: https://img.shields.io/archlinux/v/extra/x86_64/presenterm [arch-package]: https://archlinux.org/packages/extra/x86_64/presenterm/ [scoop-badge]: https://img.shields.io/scoop/v/presenterm [scoop-package]: https://scoop.sh/#/apps?q=presenterm&id=a462290f824b50f180afbaa6d8c7c1e6e0952e3a _presenterm_ lets you create presentations in markdown format and run them from your terminal, with support for image and animated gifs, highly customizable themes, code highlighting, exporting presentations into PDF format, and plenty of other features. This is how the [demo presentation](/examples/demo.md) looks like when running in the [kitty terminal](https://sw.kovidgoyal.net/kitty/): ![](/docs/src/assets/demo.gif) Check the rest of the example presentations in the [examples directory](/examples). # Documentation Visit the [documentation][docs-introduction] to get started. # Features * Presentations consist of one [or more][docs-include] markdown files. * [Images and animated gifs][docs-images] on terminals like _kitty_, _iterm2_, and _wezterm_. * [Customizable themes][docs-themes] including colors, margins, layout (left/center aligned content), footer for every slide, etc. Several [built-in themes][docs-builtin-themes] can give your presentation the look you want without having to define your own. * Code highlighting for a [wide list of programming languages][docs-code-highlight]. * [Font sizes][docs-font-sizes] for terminals that support them. * [Selective/dynamic][docs-selective-highlight] code highlighting that only highlights portions of code at a time. * [Column layouts][docs-layout]. * [mermaid graph rendering][docs-mermaid]. * [d2 graph rendering][docs-d2]. * [_LaTeX_ and _typst_ formula rendering][docs-latex]. * [Introduction slide][docs-intro-slide] that displays the presentation title and your name. * [Slide titles][docs-slide-titles]. * [Snippet execution][docs-code-execute] for various programming languages. * [Export presentations to PDF and HTML][docs-exports]. * [Slide transitions][docs-slide-transitions]. * [Pause][docs-pauses] portions of your slides. * [Custom key bindings][docs-key-bindings]. * [Automatically reload your presentation][docs-hot-reload] every time it changes for a fast development loop. * [Define speaker notes][docs-speaker-notes] to aid you during presentations. See the [introduction page][docs-introduction] to learn more. # presenterm in action Here are some talks and demos that feature _presenterm_: - [Bringing Terminal Aesthetics to the Web With Rust][bringing-terminal-aesthetics] by [Orhun Parmaksız][orhun-github] - [7 Rust Terminal Tools That You Should Use][rust-terminal-tools] by [Orhun Parmaksız][orhun-github] - [Renaissance of Terminal User Interfaces with Rust][renaissance-tui] by [Orhun Parmaksız][orhun-github] - [Using Nix on Apple Silicon and declarative development environments][NiXOS-and-Dev] by [pwnwriter][pwnwriter-github] Gave a talk using _presenterm_? We would love to feature it here! Open a PR or issue to get it added. [docs-introduction]: https://mfontanini.github.io/presenterm/ [docs-basics]: https://mfontanini.github.io/presenterm/features/introduction.html [docs-intro-slide]: https://mfontanini.github.io/presenterm/features/introduction.html#introduction-slide [docs-slide-titles]: https://mfontanini.github.io/presenterm/features/introduction.html#slide-titles [docs-font-sizes]: https://mfontanini.github.io/presenterm/features/introduction.html#font-sizes [docs-pauses]: https://mfontanini.github.io/presenterm/features/commands.html#pauses [docs-images]: https://mfontanini.github.io/presenterm/features/images.html [docs-include]: https://mfontanini.github.io/presenterm/features/commands.html#including-external-markdown-files [docs-themes]: https://mfontanini.github.io/presenterm/features/themes/introduction.html [docs-builtin-themes]: https://mfontanini.github.io/presenterm/features/themes/introduction.html#built-in-themes [docs-code-highlight]: https://mfontanini.github.io/presenterm/features/code/highlighting.html [docs-code-execute]: https://mfontanini.github.io/presenterm/features/code/execution.html [docs-selective-highlight]: https://mfontanini.github.io/presenterm/features/code/highlighting.html#selective-highlighting [docs-slide-transitions]: https://mfontanini.github.io/presenterm/features/slide-transitions.html [docs-layout]: https://mfontanini.github.io/presenterm/features/layout.html [docs-mermaid]: https://mfontanini.github.io/presenterm/features/code/mermaid.html [docs-d2]: https://mfontanini.github.io/presenterm/features/code/d2.html [docs-latex]: https://mfontanini.github.io/presenterm/features/code/latex.html [docs-exports]: https://mfontanini.github.io/presenterm/features/exports.html [docs-key-bindings]: https://mfontanini.github.io/presenterm/configuration/settings.html#key-bindings [docs-hot-reload]: https://mfontanini.github.io/presenterm/features/introduction.html#hot-reload [docs-speaker-notes]: https://mfontanini.github.io/presenterm/features/speaker-notes.html [bat]: https://github.com/sharkdp/bat [syntect]: https://github.com/trishume/syntect [bringing-terminal-aesthetics]: https://www.youtube.com/watch?v=iepbyYrF_YQ [rust-terminal-tools]: https://www.youtube.com/watch?v=ATiKwUiqnAU [renaissance-tui]: https://www.youtube.com/watch?v=hWG51Mc1DlM [orhun-github]: https://github.com/orhun [NiXOS-and-Dev]: https://github.com/pwnwriter/PTN11 [pwnwriter-github]: https://github.com/pwnwriter presenterm-0.15.1/build.rs000064400000000000000000000030541046102023000135430ustar 00000000000000use std::{ env, fs::{self, File}, io::{self, BufWriter, Write}, }; // Take all files under `themes` and turn them into a file that contains a hashmap with their // contents by name. This is pulled in theme.rs to construct themes. fn build_themes(out_dir: &str) -> io::Result<()> { let output_path = format!("{out_dir}/themes.rs"); let mut output_file = BufWriter::new(File::create(output_path)?); output_file.write_all(b"use std::collections::BTreeMap as Map;\n")?; output_file.write_all(b"use once_cell::sync::Lazy;\n")?; output_file.write_all(b"static THEMES: Lazy> = Lazy::new(|| Map::from([\n")?; let mut paths = fs::read_dir("themes")?.collect::>>()?; paths.sort_by_key(|e| e.path()); for theme_file in paths { let metadata = theme_file.metadata()?; if !metadata.is_file() { panic!("found non file in themes directory"); } let path = theme_file.path(); let contents = fs::read(&path)?; let file_name = path.file_name().unwrap().to_string_lossy(); let theme_name = file_name.split_once('.').unwrap().0; // TODO this wastes a bit of space output_file.write_all(format!("(\"{theme_name}\", {contents:?}.as_slice()),\n").as_bytes())?; } output_file.write_all(b"]));\n")?; // Rebuild if anything changes. println!("cargo:rerun-if-changed=themes"); Ok(()) } fn main() -> io::Result<()> { let out_dir = env::var("OUT_DIR").unwrap(); build_themes(&out_dir)?; Ok(()) } presenterm-0.15.1/config-file-schema.json000064400000000000000000000644731046102023000164250ustar 00000000000000{ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Config", "type": "object", "properties": { "bindings": { "$ref": "#/definitions/KeyBindingsConfig" }, "d2": { "$ref": "#/definitions/D2Config" }, "defaults": { "description": "The default configuration for the presentation.", "allOf": [ { "$ref": "#/definitions/DefaultsConfig" } ] }, "export": { "$ref": "#/definitions/ExportConfig" }, "mermaid": { "$ref": "#/definitions/MermaidConfig" }, "options": { "$ref": "#/definitions/OptionsConfig" }, "snippet": { "$ref": "#/definitions/SnippetConfig" }, "speaker_notes": { "$ref": "#/definitions/SpeakerNotesConfig" }, "transition": { "anyOf": [ { "$ref": "#/definitions/SlideTransitionConfig" }, { "type": "null" } ] }, "typst": { "$ref": "#/definitions/TypstConfig" } }, "additionalProperties": false, "definitions": { "D2Config": { "type": "object", "properties": { "scale": { "description": "The scaling parameter to be used in the d2 CLI.", "default": null, "type": [ "number", "null" ], "format": "float" } }, "additionalProperties": false }, "DefaultsConfig": { "type": "object", "properties": { "image_protocol": { "description": "The image protocol to use.", "allOf": [ { "$ref": "#/definitions/ImageProtocol" } ] }, "incremental_lists": { "description": "The configuration for lists when incremental lists are enabled.", "allOf": [ { "$ref": "#/definitions/IncrementalListsConfig" } ] }, "max_columns": { "description": "A max width in columns that the presentation must always be capped to.", "default": 65535, "type": "integer", "format": "uint16", "minimum": 0.0 }, "max_columns_alignment": { "description": "The alignment the presentation should have if `max_columns` is set and the terminal is larger than that.", "allOf": [ { "$ref": "#/definitions/MaxColumnsAlignment" } ] }, "max_rows": { "description": "A max height in rows that the presentation must always be capped to.", "default": 65535, "type": "integer", "format": "uint16", "minimum": 0.0 }, "max_rows_alignment": { "description": "The alignment the presentation should have if `max_rows` is set and the terminal is larger than that.", "allOf": [ { "$ref": "#/definitions/MaxRowsAlignment" } ] }, "terminal_font_size": { "description": "Override the terminal font size when in windows or when using sixel.", "default": 16, "type": "integer", "format": "uint8", "minimum": 1.0 }, "theme": { "description": "The theme to use by default in every presentation unless overridden.", "type": [ "string", "null" ] }, "validate_overflows": { "description": "Validate that the presentation does not overflow the terminal screen.", "allOf": [ { "$ref": "#/definitions/ValidateOverflows" } ] } }, "additionalProperties": false }, "ExportConfig": { "description": "The export configuration.", "type": "object", "properties": { "dimensions": { "description": "The dimensions to use for presentation exports.", "anyOf": [ { "$ref": "#/definitions/ExportDimensionsConfig" }, { "type": "null" } ] }, "pauses": { "description": "Whether pauses should create new slides.", "allOf": [ { "$ref": "#/definitions/PauseExportPolicy" } ] }, "pdf": { "description": "The PDF specific export configs.", "allOf": [ { "$ref": "#/definitions/PdfExportConfig" } ] }, "snippets": { "description": "The policy for executable snippets when exporting.", "allOf": [ { "$ref": "#/definitions/SnippetsExportPolicy" } ] } }, "additionalProperties": false }, "ExportDimensionsConfig": { "description": "The dimensions to use for presentation exports.", "type": "object", "required": [ "columns", "rows" ], "properties": { "columns": { "description": "The number of columns.", "type": "integer", "format": "uint16", "minimum": 0.0 }, "rows": { "description": "The number of rows.", "type": "integer", "format": "uint16", "minimum": 0.0 } }, "additionalProperties": false }, "ExportFontsConfig": { "description": "The fonts used for exports.", "type": "object", "required": [ "normal" ], "properties": { "bold": { "description": "The path to the font file to be used for the \"bold\" variable of this font.", "type": [ "string", "null" ] }, "bold_italic": { "description": "The path to the font file to be used for the \"bold+italic\" variable of this font.", "type": [ "string", "null" ] }, "italic": { "description": "The path to the font file to be used for the \"italic\" variable of this font.", "type": [ "string", "null" ] }, "normal": { "description": "The path to the font file to be used for the \"normal\" variable of this font.", "type": "string" } }, "additionalProperties": false }, "ImageProtocol": { "oneOf": [ { "description": "Automatically detect the best image protocol to use.", "type": "string", "enum": [ "auto" ] }, { "description": "Use the iTerm2 image protocol.", "type": "string", "enum": [ "iterm2" ] }, { "description": "Use the iTerm2 image protocol in multipart mode.", "type": "string", "enum": [ "iterm2-multipart" ] }, { "description": "Use the kitty protocol in \"local\" mode, meaning both presenterm and the terminal run in the same host and can share the filesystem to communicate.", "type": "string", "enum": [ "kitty-local" ] }, { "description": "Use the kitty protocol in \"remote\" mode, meaning presenterm and the terminal run in different hosts and therefore can only communicate via terminal escape codes.", "type": "string", "enum": [ "kitty-remote" ] }, { "description": "Use the sixel protocol. Note that this requires compiling presenterm using the --features sixel flag.", "type": "string", "enum": [ "sixel" ] }, { "description": "The default image protocol to use when no other is specified.", "type": "string", "enum": [ "ascii-blocks" ] } ] }, "IncrementalListsConfig": { "description": "The configuration for lists when incremental lists are enabled.", "type": "object", "properties": { "pause_after": { "description": "Whether to pause after a list ends.", "default": null, "type": [ "boolean", "null" ] }, "pause_before": { "description": "Whether to pause before a list begins.", "default": null, "type": [ "boolean", "null" ] } }, "additionalProperties": false }, "KeyBinding": { "type": "string" }, "KeyBindingsConfig": { "type": "object", "properties": { "close_modal": { "description": "The key binding to close the currently open modal.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "execute_code": { "description": "The key binding to execute a piece of shell code.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "exit": { "description": "The key binding to close the application.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "first_slide": { "description": "The key binding to jump to the first slide.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "go_to_slide": { "description": "The key binding to jump to a specific slide.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "last_slide": { "description": "The key binding to jump to the last slide.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "next": { "description": "The keys that cause the presentation to move forwards.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "next_fast": { "description": "The keys that cause the presentation to jump to the next slide \"fast\".\n\n\"fast\" means for slides that contain pauses, we will skip all pauses and jump straight to the next slide.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "previous": { "description": "The keys that cause the presentation to move backwards.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "previous_fast": { "description": "The keys that cause the presentation to move backwards \"fast\".\n\n\"fast\" means for slides that contain pauses, we will skip all pauses and jump straight to the previous slide.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "reload": { "description": "The key binding to reload the presentation.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "skip_pauses": { "description": "The key binding to show the entire slide, after skipping any pauses in it.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "suspend": { "description": "The key binding to suspend the application.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "toggle_bindings": { "description": "The key binding to toggle the key bindings modal.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "toggle_slide_index": { "description": "The key binding to toggle the slide index modal.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } } }, "additionalProperties": false }, "LanguageSnippetExecutionConfig": { "description": "The snippet execution configuration for a specific programming language.", "type": "object", "required": [ "commands", "filename" ], "properties": { "alternative": { "description": "Alternative executors for this language.", "type": "object", "additionalProperties": { "$ref": "#/definitions/SnippetExecutorConfig" } }, "commands": { "description": "The commands to be ran when executing snippets for this programming language.", "type": "array", "items": { "type": "array", "items": { "type": "string" } } }, "environment": { "description": "The environment variables to set before invoking every command.", "default": {}, "type": "object", "additionalProperties": { "type": "string" } }, "filename": { "description": "The filename to use for the snippet input file.", "type": "string" }, "hidden_line_prefix": { "description": "The prefix to use to hide lines visually but still execute them.", "type": [ "string", "null" ] } } }, "MaxColumnsAlignment": { "description": "The alignment to use when `defaults.max_columns` is set.", "oneOf": [ { "description": "Align the presentation to the left.", "type": "string", "enum": [ "left" ] }, { "description": "Align the presentation on the center.", "type": "string", "enum": [ "center" ] }, { "description": "Align the presentation to the right.", "type": "string", "enum": [ "right" ] } ] }, "MaxRowsAlignment": { "description": "The alignment to use when `defaults.max_rows` is set.", "oneOf": [ { "description": "Align the presentation to the top.", "type": "string", "enum": [ "top" ] }, { "description": "Align the presentation on the center.", "type": "string", "enum": [ "center" ] }, { "description": "Align the presentation to the bottom.", "type": "string", "enum": [ "bottom" ] } ] }, "MermaidConfig": { "type": "object", "properties": { "scale": { "description": "The scaling parameter to be used in the mermaid CLI.", "default": 2, "type": "integer", "format": "uint32", "minimum": 0.0 } }, "additionalProperties": false }, "OptionsConfig": { "type": "object", "properties": { "auto_render_languages": { "description": "Assume snippets for these languages contain `+render` and render them automatically.", "type": "array", "items": { "$ref": "#/definitions/SnippetLanguage" } }, "command_prefix": { "description": "The prefix to use for commands.", "type": [ "string", "null" ] }, "end_slide_shorthand": { "description": "Whether to treat a thematic break as a slide end.", "type": [ "boolean", "null" ] }, "image_attributes_prefix": { "description": "The prefix to use for image attributes.", "type": [ "string", "null" ] }, "implicit_slide_ends": { "description": "Whether slides are automatically terminated when a slide title is found.", "type": [ "boolean", "null" ] }, "incremental_lists": { "description": "Show all lists incrementally, by implicitly adding pauses in between elements.", "type": [ "boolean", "null" ] }, "list_item_newlines": { "description": "The number of newlines in between list items.", "type": [ "integer", "null" ], "format": "uint8", "minimum": 1.0 }, "strict_front_matter_parsing": { "description": "Whether to be strict about parsing the presentation's front matter.", "type": [ "boolean", "null" ] } }, "additionalProperties": false }, "PauseExportPolicy": { "description": "The policy for pauses when exporting.", "oneOf": [ { "description": "Whether to ignore pauses.", "type": "string", "enum": [ "ignore" ] }, { "description": "Create a new slide when a pause is found.", "type": "string", "enum": [ "new_slide" ] } ] }, "PdfExportConfig": { "description": "The PDF export specific configs.", "type": "object", "properties": { "fonts": { "description": "The path to the font file to be used.", "anyOf": [ { "$ref": "#/definitions/ExportFontsConfig" }, { "type": "null" } ] } }, "additionalProperties": false }, "SlideTransitionConfig": { "type": "object", "required": [ "animation" ], "properties": { "animation": { "description": "The slide transition style.", "allOf": [ { "$ref": "#/definitions/SlideTransitionStyleConfig" } ] }, "duration_millis": { "description": "The amount of time to take to perform the transition.", "default": 1000, "type": "integer", "format": "uint16", "minimum": 0.0 }, "frames": { "description": "The number of frames in a transition.", "default": 30, "type": "integer", "format": "uint", "minimum": 0.0 } }, "additionalProperties": false }, "SlideTransitionStyleConfig": { "oneOf": [ { "description": "Slide horizontally.", "type": "object", "required": [ "style" ], "properties": { "style": { "type": "string", "enum": [ "slide_horizontal" ] } }, "additionalProperties": false }, { "description": "Fade the new slide into the previous one.", "type": "object", "required": [ "style" ], "properties": { "style": { "type": "string", "enum": [ "fade" ] } }, "additionalProperties": false }, { "description": "Collapse the current slide into the center of the screen.", "type": "object", "required": [ "style" ], "properties": { "style": { "type": "string", "enum": [ "collapse_horizontal" ] } }, "additionalProperties": false } ] }, "SnippetConfig": { "type": "object", "properties": { "exec": { "description": "The properties for snippet execution.", "allOf": [ { "$ref": "#/definitions/SnippetExecConfig" } ] }, "exec_replace": { "description": "The properties for snippet execution.", "allOf": [ { "$ref": "#/definitions/SnippetExecReplaceConfig" } ] }, "render": { "description": "The properties for snippet auto rendering.", "allOf": [ { "$ref": "#/definitions/SnippetRenderConfig" } ] }, "validate": { "description": "Whether to validate snippets.", "default": false, "type": "boolean" } }, "additionalProperties": false }, "SnippetExecConfig": { "type": "object", "properties": { "custom": { "description": "Custom snippet executors.", "type": "object", "additionalProperties": { "$ref": "#/definitions/LanguageSnippetExecutionConfig" } }, "enable": { "description": "Whether to enable snippet execution.", "default": false, "type": "boolean" } }, "additionalProperties": false }, "SnippetExecReplaceConfig": { "type": "object", "required": [ "enable" ], "properties": { "enable": { "description": "Whether to enable snippet replace-executions, which automatically run code snippets without the user's intervention.", "type": "boolean" } }, "additionalProperties": false }, "SnippetExecutorConfig": { "description": "A snippet executor configuration.", "type": "object", "required": [ "commands", "filename" ], "properties": { "commands": { "description": "The commands to be ran when executing snippets for this programming language.", "type": "array", "items": { "type": "array", "items": { "type": "string" } } }, "environment": { "description": "The environment variables to set before invoking every command.", "default": {}, "type": "object", "additionalProperties": { "type": "string" } }, "filename": { "description": "The filename to use for the snippet input file.", "type": "string" } } }, "SnippetLanguage": { "description": "The language of a code snippet.", "oneOf": [ { "type": "string", "enum": [ "Ada", "Asp", "Awk", "Bash", "BatchFile", "C", "CMake", "Crontab", "CSharp", "Clojure", "Cpp", "Css", "D2", "DLang", "Diff", "Docker", "Dotenv", "Elixir", "Elm", "Erlang", "File", "Fish", "FSharp", "Go", "GraphQL", "Haskell", "Html", "Java", "JavaScript", "Json", "Jsonnet", "Julia", "Kotlin", "Latex", "Lua", "Makefile", "Mermaid", "Markdown", "Nix", "Nushell", "OCaml", "Perl", "Php", "Protobuf", "Puppet", "Python", "R", "Racket", "Ruby", "Rust", "RustScript", "Scala", "Shell", "Sql", "Swift", "Svelte", "Tcl", "Terraform", "Toml", "TypeScript", "Typst", "Xml", "Yaml", "Verilog", "Vue", "Zig", "Zsh" ] }, { "type": "object", "required": [ "Unknown" ], "properties": { "Unknown": { "type": "string" } }, "additionalProperties": false } ] }, "SnippetRenderConfig": { "type": "object", "properties": { "threads": { "description": "The number of threads to use when rendering.", "default": 2, "type": "integer", "format": "uint", "minimum": 0.0 } }, "additionalProperties": false }, "SnippetsExportPolicy": { "description": "The policy for executable snippets when exporting.", "oneOf": [ { "description": "Render all executable snippets in parallel.", "type": "string", "enum": [ "parallel" ] }, { "description": "Render all executable snippets sequentially.", "type": "string", "enum": [ "sequential" ] } ] }, "SpeakerNotesConfig": { "type": "object", "properties": { "always_publish": { "description": "Whether to always publish speaker notes.", "default": false, "type": "boolean" }, "listen_address": { "description": "The address in which to listen for speaker note events.", "default": "127.255.255.255:59418", "type": "string" }, "publish_address": { "description": "The address in which to publish speaker notes events.", "default": "127.255.255.255:59418", "type": "string" } }, "additionalProperties": false }, "TypstConfig": { "type": "object", "properties": { "ppi": { "description": "The pixels per inch when rendering latex/typst formulas.", "default": 300, "type": "integer", "format": "uint32", "minimum": 0.0 } }, "additionalProperties": false }, "ValidateOverflows": { "type": "string", "enum": [ "never", "always", "when_presenting", "when_developing" ] } } }presenterm-0.15.1/config.sample.yaml000064400000000000000000000057371046102023000155210ustar 00000000000000--- # yaml-language-server: $schema=https://raw.githubusercontent.com/mfontanini/presenterm/master/config-file-schema.json defaults: # override the terminal font size when in windows or when using sixel. terminal_font_size: 16 # the theme to use by default in every presentation unless overridden. theme: dark # the image protocol to use. image_protocol: kitty-local typst: # the pixels per inch when rendering latex/typst formulas. ppi: 300 mermaid: # the scale parameter passed to the mermaid CLI (mmdc). scale: 2 options: # whether slides are automatically terminated when a slide title is found. implicit_slide_ends: false # the prefix to use for commands. command_prefix: "" # show all lists incrementally, by implicitly adding pauses in between elements. incremental_lists: false # this option tells presenterm you don't care about extra parameters in # presentation's front matter. This can be useful if you're trying to load a # presentation made for another tool strict_front_matter_parsing: true # whether to treat a thematic break as a slide end. end_slide_shorthand: false snippet: exec: # enable code snippet execution. Use at your own risk! enable: true exec_replace: # enable code snippet automatic execution + replacing the snippet with its output. Use at your own risk! enable: true render: # the number of threads to use when rendering `+render` code snippets. threads: 2 speaker_notes: # The endpoint to listen for speaker note events. listen_address: "127.0.0.1:59418" # The endpoint to publish speaker note events. publish_address: "127.0.0.1:59418" # Whether to always publish speaker notes even when `--publish-speaker-notes` is not set. always_publish: false bindings: # the keys that cause the presentation to move forwards. next: ["l", "j", "", "", "", " "] # the keys that cause the presentation to move forwards fast. next_fast: ["n"] # the keys that cause the presentation to move backwards. previous: ["h", "k", "", "", ""] # the keys that cause the presentation to move backwards fast previous_fast: ["p"] # the key binding to jump to the first slide. first_slide: ["gg"] # the key binding to jump to the last slide. last_slide: ["G"] # the key binding to jump to a specific slide. go_to_slide: ["G"] # the key binding to execute a piece of shell code. execute_code: [""] # the key binding to reload the presentation. reload: [""] # the key binding to toggle the slide index modal. toggle_slide_index: [""] # the key binding to toggle the key bindings modal. toggle_bindings: ["?"] # the key binding to close the currently open modal. close_modal: [""] # the key binding to close the application. exit: ["", "q"] # the key binding to suspend the application. suspend: [""] # the key binding to show all pauses in the current slide. show_pauses: ["s"] presenterm-0.15.1/docs/.gitignore000064400000000000000000000000061046102023000150100ustar 00000000000000book/ presenterm-0.15.1/docs/book.toml000064400000000000000000000012501046102023000146510ustar 00000000000000[book] authors = ["mfontanini"] language = "en" multilingual = false src = "src" title = "presenterm documentation" [preprocessor] [preprocessor.alerts] [output] [output.html] git-repository-url = "https://github.com/mfontanini/presenterm" default-theme = "navy" [output.html.redirect] # Redirects for broken links after 02/02/2025 restructuring. "/guides/basics.html" = "../features/introduction.html" "/guides/installation.html" = "../install.html" "/guides/code-highlight.html" = "../features/code/highlighting.html" "/guides/mermaid.html" = "../features/code/mermaid.html" # Redirects for HTML export changes on 05/17/2025. "/features/pdf-export.html" = "exports.html" presenterm-0.15.1/docs/src/SUMMARY.md000064400000000000000000000017201046102023000152720ustar 00000000000000# Summary [Introduction](./introduction.md) # Docs - [Install](./install.md) - [Features](./features/introduction.md) - [Images](./features/images.md). - [Commands](./features/commands.md). - [Layout](./features/layout.md). - [Code](./features/code/highlighting.md) - [Execution](./features/code/execution.md) - [Mermaid diagrams](./features/code/mermaid.md) - [LaTeX and typst](./features/code/latex.md) - [D2](./features/code/d2.md) - [Themes](./features/themes/introduction.md) - [Definition](./features/themes/definition.md) - [Exports](./features/exports.md) - [Slide transitions](./features/slide-transitions.md) - [Speaker notes](./features/speaker-notes.md) - [Configuration](./configuration/introduction.md) - [Options](./configuration/options.md) - [Settings](./configuration/settings.md) # Internals - [Parse](./internals/parse.md) --- [Acknowledgements](./acknowledgements.md) presenterm-0.15.1/docs/src/acknowledgements.md000064400000000000000000000020041046102023000174630ustar 00000000000000## Acknowledgements This tool is heavily inspired by: * [slides][slides_url] * [lookatme][lookatme_url] * [sli.dev][slide_dev_url] Support for code highlighting on many languages is thanks to [bat][bat_url], which contains a custom set of syntaxes that extend [syntect][syntect_url]'s default set of supported languages. Run `presenterm --acknowledgements` to get a full list of all the licenses for the binary files being pulled in. ## Contributors Thanks to everyone who's contributed to _presenterm_ in one way or another! This is a list of the users who have contributed code to make _presenterm_ better in some way: [slides_url]: https://github.com/maaslalani/slides/ [lookatme_url]: https://github.com/d0c-s4vage/lookatme [slide_dev_url]: https://sli.dev/ [bat_url]: https://github.com/sharkdp/bat [syntect_url]: https://github.com/trishume/syntect presenterm-0.15.1/docs/src/assets/demo.gif000064400000000000000000024353451046102023000165500ustar 00000000000000GIF89aK0$Hl$$$H$l$$$$$H$HHHlHHHHHl$lHlllllll$Hlؐ$Hlش$Hl$HlU$UHUlUUUUU$U$$UH$Ul$U$U$U$U$UHU$HUHHUlHUHUHUHUHUlU$lUHlUllUlUlUlUlUU$UHUlUUUؐUUU$UHUlUUUشUUU$UHUlUUUUUU$UHUlUUUUU$Hl$$$H$l$$$$$H$HHHlHHHHHl$lHlllllll$Hlؐ$Hlشت$تHتlتتتتت$Hl$Hl$$$H$l$$$$$H$HHHlHHHHHl$lHlllllll$Hlؐ$Hlش$Hl$Hl! NETSCAPE2.0!,K$Hp*LpÆJHqŊ/jqǎ ? Irɒ(OLr˖0_ʌIs͚8osϞ@ JtѢH*MT$@D TUFݚ@+V\fzkٯd{6ڷfe+\vWؾ{+nÄ^l8ct#UǕ3KysaϔAwlsϤ5}:צUf-lضs[޻}-vđ_n(!FXa}b8jxv n$:a+("/"3ʘb7c8Ҹc=B:$I@>)%TFYePb9\jy^v Jnd:e DUysR%tig|gh)衄"jh.h*i>*饔bjinijj~*ꩤjjjk*뭴jkkl*"kl.l*m>+o&xu˟~kn玛n趫+ok/Λo+pk0p7 ?,q/l1Oqw,r$ol2'r(,,s4|rAOͧsy̳;!@+tP3-uQS=Uc}uYsV^ hjOs]w]wހ-tnx#+x3.yS>c~ys▇螓託骷鳯^{D|o||/|?/}Oo}_}o}/~o~蟯~~/o߯H<7) עV2Պ%@ Bp`/r EAp(,a O& eBPe+ ܲt83l@a(">LhD&"qPlb(*RNhE.bq`b(2gIx';6wn]q7Gߵx߸G9摏cGA!iF"rd%YG:$&7IDv e%9)JO @$&VɕiR,45|e.kKY/IaDf1yL[6̌3 iZ3IMn^ӛ%8)r&\4 ' 9OyҰ>O{ h>:P=B P2tږHQ/Zt5cF+юjԣHCJҍ'iJKҖԥ,}LcJӛO@%uIr> )w:ԞQԦ&*QժNUFUU"_ݪX Vzլe]TFn+\*׺v+^׾~ ,`+M,bWv"СMC#PRvle7Y^hAВvMmiW++-lg;S֖5ŭms{ n[Eq/4)Z%X*Rhnv]5owݳy^7/z^Wd9ù_r7p7XELf0<K01a [?<&NӺXm1_,4wl:1,GM2s&+Jvrd*KS2|.[Zsqz_ _39n&/ʙwLg8π泠ANկbF3ю4'-JSҖ47NsӞ5RCNuQSZլ[ Y˺ָ!z@$s̿3,bFmd;n6-헍YxFtL@w6]o[F mߝy[Њ}(M Ox9w3Wc x2Y|].Qn|.ygs2'Jh@ЫAGѓ~3Kssۻw}oo]دX7Ǿ=p/ 꺋v;~</&5m|'C򗷼5 ʵMww|"Cn}2hȃ|}Xx=~؈w7~lj艙~8؉񧊛(8"eH苽X(HftxΘ،8Xxhxژ(H(嘎x8(u(؏9XTH 9 y  Y -gȌHɍ!9 Yy"i):3694I52ɓ@=gh؊I(MGOYJiLyZ\I[]9bi懋yklm9rcȒ*Ix+ٗzy|)B? yD铒y9it9Yyٚ> IqYYIti9 Yȹٜi{7iYIٙyI٘FIa`ٞc_RɞI 멟ɟ9f ٠ JZwɜЉѹڡɡ$:[牢ቝ*j+Zٙ2ʢ384G>@:BZDzFH=:NP:ZOjRT*" #^]:%*eJ`. -9ڦpzn:qjvʦrZxz5l:J*JڟꨖڨZd٢zJzvgڪif Zt'ʧ;}:JJ*ŪRӣK:Zzښ?MjZ䪪:暮躮kw:JzZ6FiԬ jˬˊ f:+zʱ[; K'+%Nr*0{12[4{jȪڳBD Fѹ۴ PR˴T{SV³`ۭbd;f[h{j:9n:+p;v[xY\ZH;G}˷;[ek[K۸ ˵˸븊KQ[<8)k,۹k۱뺩 K~.K{ۻ뻼kk{ƛ˫ۼ,;[ [{ i[k{曾軾۾˾m ;[0 L췉 ˽+  <\h+! #%&*)"0|67̿8:Wȳ |\D|FCE|kK,LS,VX] ;b\d|fhƚ>@qr v\xLY|~} Ă\OWŊŋ\Ȑȍ Œɔ|ɦ31ɳ$ɤ<ʦɧʠ|ª\ʨQy<Ǵ|t|˵<˶˸ܮȄ<\|̆lIu̘Ȗ\ŏ <OFGHJO-ՂX}Z\^M0d=f=eg]gA@C-B]p=jHQS ׂ׃؄}؆7=) mҎ!ؐ]ٔ}ُْٓٛ +l֤=ڦ-ڨ֩PBlt}׮ױ=۰]ۯ=\E Յ-шۉԀܺuV _=?b]ګ}ڪ֭ؽM,n}۲m-޴m}-Ezm-MTMmÝ^J'ߑҞ-]}nҜ '}^=&.%g=/> 6N.^sm5>@>B^]IJL^$~UX~ZNK`b.aNcgf抳 > npN~u{y1PQN^ VY.el17mN䪞ꬾ>N^~Sn>^夎>^i.ξ~Tn|x.}~n^}붾>E.N쾾>,홞/^ўԾ??__w/~([n _/584~N@o^~.FHL_"#/T$Vuī-//`?f_ ln?omt/q9~z|~̳U_Xo\UohO?_g\_ oo:?_?PMAD?O7n_o#?w/<ojL/?_O_@x‚ :L0D%ZxF3~\1$Ȇ"K|h2%ʉ#]|2fK5eڤySgNg tОYEjҠL:-2QIZzV]~]5,ئb˒}j6-ZI$quK0/ݻu ^=pF1dŎK|YeΑ7{\hѥ>mrj֫]O9t؟UמZ6jݶi ޹q>xƅG9tΗK~]uѷ{^x囓?ozzO=|ן^>zo"+@ Dp@ \d0B%pB +A )30DEqDKDCI\Sd1FeqFkEis1HrH#DG%\&d2J'rJ+I-.2L/sL3DA$ +lN8ij<ԳO>3=P@EDUQF54E%uRH'ŴL/մSN?4METPGETOUUV_55UeuVXgŵ\oյW^5]X`Eع܌͋+ڳ]کvZmn6\ow\sE+U\u}xɝv]^|W_8߀`w ^az~W)ƨY7#c?8dGdO69eWVe_v9fgfo9gwֹg9hh6:iViv:jj:kֺc7׌K̲$;mF{mۆlٞ;o{on'y飧yg{!^|Ç~|G_?W?~wb岏_~#@*l` 8AR,`5x f`&t` %.T!aBІZCqC"PG,D$.щJbD*>QWųMr]i'ݍьeDcHF5o<5юuģHG=s;<|Cd E~~Td$!9IG6򐒬$&/IMf{'EIRҒ$*?Q,%,rp͖]e.qK]e09La4f2Le6tf49[0 ab6M-v7)Np34g:˹Nr|;)Ox r` $ O~ӟ>:P=AP.ԡ}B!:QR{e(cQWn)GCQ'MHSjҕt,}K3*SԦ1LsZSo i¡01dPZT&5|*Q:U25F*UjUjUYVUeEYJֵ65l59xuvg^zW{k_;ؿV lb ;O/F}lE#;وRe5+rvlhA;Β!;ZTke[ڲ-Ms¶m-po[w5nYocԄ4]N׺u]n׻w^׼]&6Ůuo|;_}_7YKіEplZ7># Oư3a4]n+b%F1O,W, qmcŹ3+\֫YEFrWld&'yMV[d'/R2e*o^/wb>_6ons8Yu3|=y[cya †&hFoшvt!=EKҔ6]j;tEԣI}Uա~SRӚճ>Oϻ׿u=la6vle7{mjO|ƶ]m_[6 8xtѽnKx{w=Mumj:'np'^%:ufcyx;q<%Qr\.?9ל7yArGyz.;DGы~t'O_z$hzuo];ֿ.s-^S|k{vrǻw߻هgvx7w|!?yWgt7|A?zї=Igsc}a?{챿=so{i</??~^||?'Hyss;gͽw2?_?ǏWIsӧT= TL@;7=݃ݣ @  @ 仾A><LAt>lA|A ü !"$#4$.C@\@'d@(&'()-"012DC2T3\C3t49ľCCC>:=AAABCC[DӿcDHtHdIFDIJDDMOEO$3G<9B-UWtXlYdZEZEr7lC8E6_F_$^4`,Fd$|AtFDBkFjFllFkF‹ZBrTs4tDuTvdwtx̋ EYE|}~GGKaUXSZZ\7QDCEUa VFVc-ӒLeVNNThTOj}kTlmVj5N*25qUS7-s WsMW8]Wr}SwEWq-WvwUS9[W[|]WX'CVbU؄ecud؅}XGVg֌hXnXi؏ؐPmՔ]ՕUٖeٗu٘ٙ]񼦀%؁ٝWٞڟ^؆-ډ5ZUڤeZuB]Vڒڑ5َڪ٭VZ[R{%xUz5[z=۵U۶v׷E[-[M[e[ל[۠[ =:@}ڥMܦ=U\eǕ\ XZ\ګ\\ܰ\TӕEUeuzU5\%]ܕ/^%5\R%]^^ ݯ=ɱ۸ߺ^-_u[%U_x[ܵ_-7]^6`-`>`]0`^ ^ Uo]%6FVfa]aa.unV f`!a"^ܨ ^'$&Vbb^Mb-_/0f-3b2V2Sa99;fgPfQv膆臖h)vuNgv/|g|6y&>Fi!`hhi郶iifnjjn㢾f&nVp>萮ij~w^infkvk븦kkƗ辮hjFVlבVk&볶ɾiϞkFVNjnmvm~mmێjm܎mn~n&UllNnVnﺶ-&.lVfvo;loNooN=ɦlwp') ݮmp pmDwqq]c'p} pWp'r _p!/r Wr$q)*+_BUqs/2W,4q55w6s9p&O:7&?<=s:>G> pBGAqCWDgtGtGG7H? KsL7sM'Nt an6qQ'7uS/8 %r>XYVwrZF]r^^_L7M?vOOO_vGsTvUSjkg9uYv\nwWow=1ABoIWwHowv?twzEwfG~WvgvwOiOul7g|fun/wXvsxrxxw`a'y.vGW_g;~Wxo[uxAwzuwwyz|wogEyz{Z-xyWg{}'{'竏}쯯}ϽME|է}@&d  (N$ 3hA bp AЁ%l #AЄ-L_ Cqa!0F$#*I|"):1VŲiMpe܉d<84qn4cF5ʱtc8)Ѐ5hAЅ*uhB*шJjse)aUbFAѐt&UHQZR+uiK;ҙ”2)NoSVlm gxB0\aPJԤ"4tP*զ.ETZխf5XVzհիd=Y:V2ka5)WxUu'^j׽z+_*X6,b+.>u,E!+YNe3YnV,h?+ZΎֳh!9S6ݩjcRmm6ou[-nn}~4Ν&t+Rw֭.vrw.x+Wkռfb}zW/|k޷=iZشK+fSxygWpAx&&Q\bظ+vqØ21o抵l!\-c%#GvO9Ur e-SY^l/oyq/5lVs8v:0-aAzІ.43hB+ь~iHZ.SۘӛuiQ:ƣ>u;OkԭFWP;uys]׼ a >=yyn6-mMt{6wnu˻YMkYz߱nj~7 < _.qںd2/>sǸCo\%'9Mry'c򙇹zo<>s]Aы{Oԯs[zױvD᳞8~]F^pq{pyHő-xe~/<3~o</1Jwz3oyc~ߏ|3.c7sr߾Y~?͟w_~%yI?` ` `Bݸ>: 6F`b>}y`6 & U^  !!tU*!2&!.a6>^!E4vn`~! ` aΡaa a)٠!"6"#>b%$:b$R"V"$^&&Ο'*"''NYZ!b*b*"*"+,a!.!//"00b0hu ! 2":#B2>c3V#5^c4Zc6b4!!88#99#::#; +b<"===#>>c=^10 1$Ad@&@*A2$A!7 bD&DnEv#FVdFR$G2DzEjHvk((ZI"&d'$LKJdJfbMMdNNΤMdx"?cQQ#R.Q6R:eRF%;ݢCVB^eCb%BjUfWnWve!#HndYY64e[GeZΥ\Z|;#__%``&aa#T2S6T.&d:&c>dF&e^i!%ggr%hehvh~h&iۆ]eH&keklڥlk&nfnfO$PP'M'rO*p.q2pFgr>'ueuufgeev'fZd'xwgywڢ[xQԡi&||&}g}}'onho&(l hn%bF(NV(^f(Zb'爢'hd~}(~ʨ~Ψ( j".h(& ii6)pBtjsRis^tJ^gbv)b~nf)fpg)h)ީR()hjWg2i>266VF* n(vv*~*ڇΩ꫺**ƙj**jBD>**&Zj6"^j:>+LDi陎ij+zkrn~븚k뺦+V'ƪ֪*k+znf +l&,. +VB+FlB,2jZlEnꩆlȖ,ɞɦ,ʢl,l ,*l2l^,"*-2.f,JmFmk+׶+~mn׆֖-ښ-غ_֬,mڬ-i.nvv,>m6.^-:mN^f Ȯ~.莮.v,߮n޲.޾޶®r&.n...?jon&/RFo]֞-۾mZ-v/^r/z؞ooFnҮ֮/¯//o/hҥMBc2/?O7J0Fu.nv00'[xo p p 0߰p/_0Gcq1#[EoRWJ1o{qs1_ ǰ1p˰1xױp12; Wp!!+ +!/jp $G2%O%W2&_2q'1((({Ѱq*r*2+rq"7q-2#-#2/;-r//Fqq3332?s4C2W323x)os(7ss)w383<ѱ+2:::2;ir# 3./<.3?=3HA&g2AA4BB'p293D?D3EEGr:FGw4HsHs2>?sJ=I4KKt4[s2_s3SNtOtN56NOQ4Qפ6_D3uEcSG5TOuG4VV_V4W,.JJ5@tYôYY5Z/@3Bϵ\5]ߵ];S75U5`K_v`wFg5X#W/b7vW;3YOv[e4[g[W6fofMuRvQ#uiQiPvkhkva,_ va6nmn+U3cwbp7reug5t77etss_t[d\5www7xx5o6zzzv{Ga7d+|׷|wrؙugwt?wuS78v xwOhvl6m?8v6vög8o8km7{{88|~7~x~8Osg'߸Xlx9y9'9+y{G9K9OWows{7#8 y9o;wxOӹ߹y9׹yN8'#+:W:SzJy999{zgz::̓3;:zhkgzEz9:G{?9kw{:o!z G:{;׸C_{||?CG/`<sO}XC7>o~Chn#n*};}#<A+`+ C}8|s@8\v[Hj F8QbE-f`K=`H%QTi(jEZ93eM3 fϛ>PEU$[:)UU^՚V]~,I"I$Fn1.ݹoŻ.߻}70~ 'F0Í!?82Ɏ-gƼ2˝Av=rI|?/$P-<IlJ'RI-.K13Ͻzq܊8ߤN;3O9`OS>CDtD]Q>Q@RJ#tRA/TN QCݴTP MO)%HOSU֠rTVIU{6M\MX_Q=VY[u`gWfZPgmko wFmV]i6]nmww^pݵxw^}WwTDX$b%/r?'θb1XA~XG/dQ6dUN9^9fgnf?9GpX#@vo :%Uj饘f&椠*荒bR!e)Zg&kB)Psdmnof%Vtźk$$BSq|/_:s1\s9\IǻMOxM;Oeݼcǝvm}õiC]גQ)Hth?VQ§jMl"7Hs(c(Ǜo#fOT W5oonrI/tS͔?f1ĢH.)9$h,>l)@D7܉@J?LJaꡬ4$ 2a GD # DcH**̫ > "B `\͠ ?~t?PJ?4pd # i . Kh$ɍP t /YPoidq1c&(2%`\VTpcNUPYp%xl=J%GK-NVqfePPLZPB1T` Y^ ^k T\Q$Rϳ$ bM$y4T"aXaSQZ# Sg6k*]̺.εThDN.Ա-" jnM+K%5q%SrU$_%%ftTu*''wuzR'7''(AG(((qdgzRjeP &pb8bψM'Ule>DbR&bT)jPJ-"ďp*Gtb KmĒgI'r I J,9_"FڮcI.cp&gY+n Pf_Efr<',_b,M&לmh+p/Fj1"S))O99r::::S:' -/$á?1=72V =S8F,G^4i#@1+3>g$̑:Q45B /:T=3=TLͳLL/TMC0!I.BEvDϐF/ *q1>i>-!F$/M4FBjpan7C5RBr8b 9H$0msF!8Wk*jjGЩA:UR*"PUbO_*y/?Tv@i8F\P(>5NNu]5^Hq-%e_Yg2_U_u_cR`a[/ESS!rtaCo_q$`I$`(>=CT36:t_͞KJ# v1:6vSr4"-N4z >% Eeh%cf´Plޅ`7NN$. *` ˿*ObM=7!~V>Ml$ &p|x+Tȶnvoo{v`o &pw`l=mqϭqq7r!(ʲi)RP0j3t&n V1.)ɰڨ&X.= f2f*5F`$"W+)THIwKO$"0tMtFQax)BN( t& *NW0UDuK" 0&, ids9S6&5xB"L&.Lrxrx 'sԔLL86S&Cj0:&$6(D9#*CWu#T/ Ak11w/JHBN8 csAbtD N02IG6c;i:KA$C?<tĪXv0>c+c)덽glHXX/`7 @빶TX'X-9!9wM^C]IY^EKE򴗪X#RaZ)DK/I4|)D !"OU@BR![PS d5KBdw*>ԯh5?js/AD 0@چP霄H,'|7ힹ)8Cxu7[B{d$.ښJu$RFUJ&m#,)Bp*SM5:9z^uopuMzpK pU:puMel!^$bbqQ #N>k3r#Jjg9Ne+wiBro!d1#eEQa% ( N$=Ή\@+;AT1\R]VC۱@V[vd8ڵT-{/;pO9;E?Sw+;9KSO;:Y۴ae۵i'+jGB5-6FJHmn(Y}=_/iaY]ムbr5IFzSh}D2czKzaVk;@`9E)Q+R{ir7P =,&lY = |.*MIF o[5q, #3;W'{ g8;O|S{Ma:c[;1y5uq﭂)30¦t0t9(8Py0P h(4yB[K@sv.4X.ÔFdQݲ0C.Ut īبфGAJ ԲO'Y$+9Pi=@o؃/P W4uKˡwCtAd}KВ! ،};zK䁽AA157۳]>;ֱ@lao[tZxz_gZaolgd6h=T-F,#gcYT`|W~E»TRZչzeׅ8 RJ%cegTTrTW[ͥ5[ YODVD֑i~a>/_9m~=VF IM?OrrM~_sb1b=Jxnfu+X;NiS,r|k»QwP#xo"K /02ٍ)3Cµv4b}-? B?~ hW]-M|0"ĉ+Rh1#ƍ;r1$ȑ"K@)d=yA&N"PN)eMRyQf^bxV+aIa ycj^=)3dX-D+jbk8 䄣sGrjHs]h,RDhBx㘉rVWl6E&%$jꩣnqdCN ]=>WMيm+<ˤs |[Xa/0  M3hdqc1"lr(\21L5|63: B3A=9#ʹE;}Tg]&k`!-Qg_C=rs{ (ǝ w'MvvXM1ny[?1F^ x1'@l ~.x!'v\5]6B92Se@zVN2U~qч8a'=mƙ}ެ|B9Ib$At4_>6>zM˿~G?&#`ErD* 0H +Bp5j%XA >0ܠBЁ#`KAP,$a M~)!VZl 7!?+ֱ2O ! %+(PWE(iFiN-ֵDZ氃ш@ +R^VH1`F=5-HS٢  1Qu3@@"-b,JF` IMlABۛ"بYH7&E#(&D WA RGrSna-[KyWCdIҎ*a5gH'*3+5mS8Yoz3d':ߩeE,2=Ut|޳ܧ> P~ +(^vg.8()8eB젘S֎́ew >̴, l! ܅g$N4FM,#=MVZM lҞHj!]7Sjot!2g<.JGƢ i&-0?uuEBS0++K|(ɗ2T[ZH%eZr@+0:FXZճfu]]?[5o=IƌAWQm, ŸIm'ghT3h\mvC\^mzTX_=teAd|c'27rYö ]˺ L7κsNƱ8{ۙaƜ4 ZZ hT 2*{hI3YX(\NȴC'D{~8,vr{,ֶip`K=Tzӡnu^;}Xկnjd1!ˎ=l_} ǪnU l"w2b˾]X=沓=h) }\(y}Ix.o2*x/}B31Bk]h4B^wN!)E05/>&%;I|ɤ?ѠGg$̿}-&r;J |TPVTJs,Wrs??q1.R[ʅĕǵɥȀ%fzF_.vXrp}@ ~5H V(zF-`A (" 灙qQ6 v H bcnm[R0x vQ7>(XU!Uz6X1 XZ @ Q6a7C&G lPQ!ƄUc[0xфad1T!-4R-xq5cu UWO1}(8 %3 ^8Vp@AzFȋ HP\6jj˨jΘ(Y_Nt. aޕH60 S(xPx=H pP|phY8&vw&xc>R+8~pxM] ؎I(P M`haTIےF`yPc nj*839; CQy6xuZٗz9f闃iɘvN$I4YOtN O)əYI隣iٚ I0~e")K9)ٛʼnǹ9 )Iy̩ՙۙT)I剞穞Ȟٞ󹋮Xk[A+  *j\A9ʡ!-R#g=I~ .* 3*5679ʣ57)YYyCKDFHJڤL:N:uc17tUJ#EDEw,z<: > Nm~^᝙m!~#$'.%(n)n-4>m9;^<~=]I~DONQURnV-]>^_.aNcnegioqp>ruwtv޸:>.?n腎脮臾N=PNnY~Z^N.X>~>.ꯎ꺎^~^^B͞|.N>n .N>~CNn>^~N.:ABoE?@X@ƞ^nNZ/QS[_XoW\Ob_S;OYqr/tOsuw}_.5//O_6o?ߺ>MKOoG>DO/oo'ͯΟ?%8?ׯOǻi?g/loak?$xfXaa81v70C?B1CNdQveYye_cfqgyzg袃NyCS=4KTIӫGjz걱lF{mZ~Sl\pG\ŗl㗀2Jȇʓ\̋|su )t?WIOu]otcvmϽvq\{҇7>x_gyW>ٛ꡿^~{|[gÌ'޸}ߗ_cݯ?׿O# Ѐ D ޠ*iGS,XA RP7f0!MXBP#d W0/:T"%p~aCa D"чG$"('&PtbxE)FqXbE.PJxF=UJWF7scxG;{cG?d! yHC&K@H>R +KNҒ$'5IP~R$'KSҔaJ’a-eiX撖.qK_e1i`&رSEhQ|&5HFl1ٜ&7kjӚh6 NrӜ5e*m:[6moy[w-q;7gUks\BwѥtbnwCmed6Ey^*ֽ/{W{Tecz`&x^9JӒ6Xf0a ON;asq?,bX%Fq[|1>M:6nqc ?r|!'+^(o7Or|ebyYl.0#/^4Y`9l3l:Yy5rY{h&ыVthGGғt)}iKgzUr=%ӝE]jTZէ^]j(׭Zsi}0z̹}m^׺v-2yx^Mh:7~6ljg[מ mo Mb4ݭv~7mzwo|&x] g8cgcVGխ%^qS\߸=qM^}쓧|*7]r29i~d:6ϻM;K:Ӈt?]~>7i_]Yֽu]a'͞-Nv=l{~㲶yg]'o#gstCHң.yc<'o{><5UFzֿ^i^w_{=?|>?!z~g_۷|֍_|ɯx_gݟMOzw?? ,@Q{@@ @ @ @ @ J;> AA>,BkB1=00$C5LC6DC742$>8\7C;L:9lC{>dACAlAB BDDDC\DD*B AHAJDKDLDMLĴ?*(PB-lRDE+4QdTBW\EU$EYEBEE]E^E_ F`Fa,FblTFdDelCtFf|FiFj\FkƴMmDnDpFqFrFs r"XEwlGxdGyEzS? T@TGO)URD}D=TEmTF}T*uTܪ0R1T/TLKTKԯ8MSR5%T}ST5UUVUUW=Q -R\U]Q^\U"U_`V[5aEV#M֝@HEVjVkkVl̰RNoTOWpp=WsMW -LV}WYXWX:W|{S-O<%TT XX-X=؃5jֆV}X։uXNJUWrXt-WXXXL}~W=YMY]YmYYwWUgeVe-eYc٠ڜڢ%fMڝZ=ڦhXڈڪڊZZ֐Zۑ[-[5۰562YYuۗ۶׸۹eۘ[-MX[ \\-\-TSi}\ܭ\Ǎ\˭M[=[\\\]2Yӵ۽e]mݷU]ح[ו]/Y%֧]ZY]]%^^ ^]M2˭ɍ^ʭ^^ ]%]\ _5Xح]]_]]_=\M\__``Xn`ev`\E_ ` > . ` 6]-a>N]am3um^^fa.b>^`%Nb&&`'b%b bbb.sTVa_~ac3F2&15ޓ5c9c:c;c<;^b)c(d@'&?. b+EF~dEdF9~3d5nKLNc4fcOO~b 6S&bUNeV ^^WFX^YneXeeCBCf_^.f٢bIvdInfevff~fXMeNjemvkeofp=cs.gt>guNgv^goc`fy6fbgzg{gIdh^fghplfqVNvhl~honE)WZh\6ehiY&iZ6i[iAz~i|{i}icdhiiiGf^h.jhhN~^3!gwngjjj_x隦ik&kCgii궆\fjNjkkl^i>fiF6f^ǞƖʾ7.k>klllf鵞.mNm^ӖN꿆پܮZnn.n^nnnmfnS6mVfmo&o.o^"˦Ȇ쏶ooov#exnn6p?_pR#npNv5p6>oppgo6n?oqqq9Jqqq$p r 'r!/npgr~r'vp-r0r1r0'6l6ss7O 7r<r=Gr>?r?sy&_rBA7t*/?EOtDž]#ttJtKtLsOs9sPtQ/u=TtUsVTWGrZW)[YuF1Ws4ra/v/372?veOsfeQvS'Pvlvk?kuquW_us/wtwAwF^wv]vztMw}w~wNvnv?xOxmXTOwrsx wzwxxxwxx{:`G`vgvbGWdgwyۤv/yyys/z?'L]woy^ywz(wzz`x/yG{7lxW7{tu{oOy?|ŏgşƇǷy]6O{_||4{ѧ{ҟ{ӷA|{׿z{}}}}}M#|/~'~sO}/o~w~+ڟ}ۿ֗W¯|ʧyw|/g?/W@$II H$! :lqÊ-FH"G7zrǒ"MLI$K(Wl s˚2mI&O8w tϢBMJ(SH:m uӪRFJ*WXz vײb͆MK,[h׺m ҠM ^W0^ ŎCXr“-W>|YsĔ?cYЦG.z֍;&;lֵ]3ν۾u.[8mⶍΕC\zӭW?~]{Կc]ǟ/~ͻ'?|3Ͽv" X  * :!JX!Z!j!z"#X"'"+"/#3X#7ژ#;#?$C Y$G$t%!PQ0@S&$eT`eXj%_^bzI&cYfg&orI'sYgw'螂I("Zh*(颒:J)bZij)ZꦢzJ*Zj*몲J+fdL[l\ [,*l2 :J[-jmr z ɧ|k_+okolp&0+}KC0kܱ ,q#_1 ,rjC, Ƭ.Ӝ3;XD7s<]GیJܴF'R;M4O_m5Xo_W=Pu^MvYͶfur6w۝g7uwz^ۇӍߊݸ';N8_n9oW>̤⊺귮n멳>Nn?#O/o3dAvs݃_鳏뿯_%,c2L $ BT +A ^3@ r aK@n* Eװ {J!jH08ܡ{0@H#1H\':12@4=AZ"E,~qa"(F3jL#F4qql#(G;&OW헿?QIA2\$"yG*##II"wi! =I0)d!)_(JO4*?T,+i9KVR%/UXҖ%^beS&8LυtL49eVܦ6k>7Is3\'6Nu3t'89NxS,'=O~ӟh>FŃq M(CP:>tm(D+*QR(G'Q~t!(He=V2T%[jɗ+KcjSʴ8N{ӟTLjKb Qԥ*P}TjԫäVը~YVjVի1&QV)r+]j׺z+_׾ ,atP+T%bǎ4c) bf;Z6zPwjZ5--jW–}l]k6PVߖ Y+\bM.s\>w-r\bWٵtVtxZWT/yכuo|{^}7| "X^`jԳ%L [8à5\"=L+-nwڶ80k91yq[d"F6.&J[%;9P2+SY`\39h^79pL;!FM>ā泠 MCЉFh!@+K8Ӕ5miOsӝK!w̩3eVժ~aZʶs^Ž2l`"V6 e?~Mk;6ms[N7M7:ފ7mzw;%mjP#ԡ^x3w8#^qoq^!9G>.9Er<0ym98߹{<@ЋNH_zp RԣN[=PzիiNbx~j{پv}Wn}2w;w~/2w;򐟼%_yʋ~m;|C?͏|Ss?w?}'/??߿_ƕ .`2BJR`Z |]im`y `  ̝-^ _` _ Ÿ `  v͞^*a%!^>.B!Faa^abaR^MaVaa `a" jm!." aҠ#Fb>"#V$%R$6&fbB"(b))b**b+ֈʡ"ҡ--.b//bfe`!"1b11*c22#2:1B2Z %'B'b5j%r&v7zc8v8f#7#&nJ!r;c<ڣ=c=c;#?>;c? $b0"0>C6dDRDZ$D^D"Jc46G>cGGdHIdJ^R"Z9cKKdL$L$8K9ޤ8NR(+PeQ"Q*eR2R:eS,fdEbdUjUR%VNVZeVz%WZ0$HeIXeZZZ%J`NdOeM\ޥ]N%^^%]_:dBdA&&B*&A2cAF&b:d6deR&f.eBff~fg*A0dWWfjjjjfki&p$[[m[mofpK`e`grr"r:gsB`FqJguRPBSjgvrvzgwwgxa-dkglzg{§z'{'en'~ gog'g5Rt&u.(&>gRhVvaV&hzzhr(gvnjff((苪(獾g}|h) i *).2:i&isB(b)s^J(^Zhfv)~`xiyiiiIhi* Wާ6*"j*&N喚*V*Rjrjzꥊ.(Ψj*ʨjjҪj*ب k*+)J=jF*J+N+ZkbK*뷊k+뙎+cikk)+&kk ,llV+^kBJ,NR맲krz쨦kǒ,Ȗڤ*ꪭ쯶,l,+"m&m.*kmF,JRmVmZmb-zƊ׆+؎ɒ-~mȆ-ڎ٦-kmmU6m*2-:m-N.6*n2ƞ^nۖ-nr-馆l,к̾.Үln.mo"*oX>M:.B.NoRb 萁zovnoo/ǽ޺/ooNy>Io//oN^o[Wog/?'onV0_Z0os{0S.n0. n 0 0 $pp q0BKp3;qCe׊vWo0s{goqqqq!7q 2!W1"w13r"k#7"Or$S9 p '{r((w2)(r*)s*2p,r--q.Ӳ!!r!0 s1MQ#K2$q33+%/4Ks5[r6ks7s7{s8s1..2:s:s;3q01s=<=s[ 25_3G?Cs%4A43?tǔ0+oCp+K4DCG4E+kD_tGW+c;::;4J;4<7=sLsMMtNwD2t@/B'5P@tQf8sS3S;uTCTo9tKcUkJs5KoW7J| ~CK>SOW>k|s>{ڃw>R8ӧ޻>ZxӾ~q}_c~>}菾#'}~߽=k뇮;>~??7~h~ ??;7@,HP B 6dpaDJH"F/vqcH#=,I$J)OdreL3]ʬI&N9osgPC} -J(RIo&Hi$ P*VWr*؂bvJvYf~]mطr㎝knY{Wo_&|pyn\1ȊVKd͖sv2hɢ1wLziզY^uײcmviػi[woῇ'~xԹo^9QWMQ${xŗ'|zշg}|ק~~߿P <\P|!P ) 1P9AQs)$Z|Q ckqqoQG y $\R&tRI(|(,R.RK004\S6tSM8|8SO@@ D]TFuTQH}FũtNP9uTQ=5SK]UVS}XYuVY][kU^s`yvXa}5c]Vf}h3t;cZk:n;lWr5[r]7]tU]vu7\yy흎_pW| ^an8avxLc<-Ә"?8<dQ^yCvdMYek~sySo֙h>:袇6:iminz骡jvkzkzll^{궿vl͞[m~;֛p><7櫟zϞ{콿?wZzav`ُ}߷?~~H 4 @*Ё&^|S$aI8B'Ta YB/a i8C7ayCzq+]x7шI,D$2NT8(^ъYE,r^Ԣyg)L}|i4ǷF7#:ƑyG?w$Bσ"9HAGZ\$#IZl&)NF %(99M^D*3JQ|eU<햂˥v8\"[0{IL`L/)b>stf49Mk̦2Mors7YNiL8ijg;Uħ1>O2S(@PԠ -BЃ>T (DFюr!HA:RU)IWzҖ1UORtNsYA5PaS>ERTԩCjQTNVMvb~aXZVլiEZVխq\ZWړE(Eнկ`ZXb>ֱ/R.e5~h=ZӒVEiU+=*2TmVzUjݪns{[ֶp[wEno20uKkX:7Xkwl_V/;~jO;F]nCw<@V7F*nwnyߛ֫L81=TQE!?^61E >qWyuqq\նCNz Zl6 -Zhy񒃜?zЉ>k-Vt?Q2+z_A4PH0Dp|`o .!jb opn "@긎0=0p0 Q  0ȭސp氯`"j"`O j` 4!) H@_D@D Үv  jB0[_ߤ`o{1XbX؀B @Ю2@ bO ڡX! " uQsӱ}1)$+!wVj-*`+" >QҦ|bΦ#0'#GR$KA\0VRZ%_%c2{ pEl{A1BF@"CIINtNSO9tOsJ3#TS V$D# TsE"oQ$)N)T5TTTK{@;U T:WUp V<ɯQXXN YA [7?q u uHa .KZ?*^5UŵUUu\e]\A]]^5DVdd<@t i3c"PKp ɑ 0aspd " 9Ucc7c;?NNGNdIN*@ZE4X/ 6/_DoeyxVZ`u1#'odQdjSjhE*OIkkGk`bl#Bζ"6¶kmMmVx3]a\׵nU]cuo^(R/Wa// o\Wo7oo']tHqr{H3WG7H;r1s?7H)t/wtMWs=tAWuQ7tWtHVjkvojswjuvywwx{umnWnWyyyzy/U!q#qz{{|V|w||}]Acۗ}}~W~~~cHdWxw8xx m37zWzX#!Snӷ{×|?|A|ׄ;QXOWXbr]uYWtS_Woxcmv}8iXw8+"XXvw-X3Ø،ӸxZ BXG؃瘅똎S؅؏︃7Yّ#'w/Yx=39eqؔոSWY[Y96kYoq9{yyYybxYY9y99gEٜӹy eٕ9YٞUZ ږ Zs9z}W/37Z;?ڣCZ^-ٝչ٤YSQڥOKٟgkmsoui9)Zڠ':[ٛŹ:zڪW[z㚮N繧wڧZ۰:کڱ:+%ŢEڳA?[CG۴Kڮ[_a[];k{K y{u۷57۲۹3{{{ ٖ:{[ÛӺۼך{ݻӶi[ۿ;Q ^{'^+ك!~~ҳݣ꧞ݳݫ뷞^׆]cYӾoyow^Nᕾ~?_ ?yGK^#'+s^;9_?C>W?Y[?iw{_}ɾ˞^~?C__!e_U?]_۟_ms?y `"L`B4$q"ć%V I$C2$ɓ&S\%˗.cœ)&͛6sܩ'ϟ>*(ѣF"])ӧNB**իVbݪ+^Â+,ٳfӢ]̏20Aݻ @o_~_Â>8qŎ#Cc˒1S9s͞CgӢQ>:uծcÞk۲qӾ;wݾo‘?RVQEVf%[jafd&d b~fh)l9v'w҉g~gg Z袈2|>褒V饄R顖f)*&j* ~j*뫬:ꬶ+G!}z(+_&˒!)1BKf۬r+zv[.Ϧ[؞;+.o̯[0 # 1;Lfs,{$w\2 \<0-5wq>m4BJ7LCNGm5RWZw\^m6bw] vo)t]7xmy߭w|-߄^8nx?.G^9O>`Fkky^ꨳ뼾ӎ;ﻫn<'|//?;=JnO槏Os9[&$.Ё L`(AZ' nЃ`$s -P0L [C7!mP@̡{D"G@Q{N^H=R*6q^2ьS$טF4jэ]TcF:\c(e"i3B,4CdnHC62d!%yH.ґ#/Lr %E9IP$e%SUre+eZғ,uiKk#"0Is1c*|4IhV؜5MجmH48I"39יs|:Ї!,!,J!,J!,1 H*\ȰÇ#JHŋ3jȱǏہIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJիXjʵׯ`ÊKMfӪ]˶۷pʵ)rݻx˷߿ LÈ+^̸ǐ#KL˘3k̹ϠCMӨS^ͺװc˞M۸sͻ Nȓ+_μУKNسkνËOӫ_Ͼ˟OϿ(h& 6F(Vhfv ($h(,0(4h8<@)DiH&L6PF)TViXf\v`)dihlp)tix|矀*蠄j衈&袌6裐F*餔Vj饘f馜v駠*ꨤjꩨꪬ꫰*무j뭸뮼+k&6F+Vkfv+k覫+k,l' 7G,Wlgw ,$l(,0,4l8<@-DmH'L7PG-TWmXg\w`-dmhlp-tmx|p߀.n'7G.Wngw砇.褗n騧ꬷ.n/o?@SJZ=@p!,J!,UH*\ȰÇ#JHŋ3>DǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJիXjʵׯ`ÊKٳhӪ]˶۷pʝKݻx˷߿ LÈ+^̸ǐ#KL˘3k̹ϠCMӨS^ͺװc˞M۸sͻ Nȓ+_μУKNسkνËOӫ_Ͼ˟OϿ(h& 6F(Vhfv ($h(,0(4h8<@)DiH&L6PF)TViXf\v`)dihlp)tix|矀*蠄j衈&袌6裐F*餔Vj饘f馜v駠*ꨤjꩨꪬ꫰ƫ*무j뭸뮼+k&6F+Vkfv+k覫+k,l' 7G,Wlgw ,$l(,0,4?V!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,}AKH*\ȰÇ#JHŋ3jȱǏ C`ɓ KĨr˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJUdzU$W:j۵ٳhӪ]˶۷p%.bL2f$ Rw7b_@L+ JL˘3m97fNx0UPpcDh3&8"3E"d N%:k j ” SKnB3$>@A[>AF'/u?d מ{` hISq '[}QFhFVA0VF!bunuខ4h8ؖTt@l@v@RYY_x36Ey ^E \:l&C%@f1ZkqH IPT@|xU>HEnSwvXmযhO>o) h蛬꫰V egZ,9ۙܭ9䭑vex%FGJsggm/m+䖛T驮nzNkQ> l/BƖohNj''jgfQg¸b=gM>hsQw s|lOMk裖K[fMЅ;PJ ,@aZ<i !WmXw(Ɖ!'L hh5zR%yz/߀. ]pУԽĒԯqP/MAIDy|.褗r $x+ah\vhѮ[|9 E.`Þc~@Լ/&y+tA  C R @G*oQJB@ĴI7?~3CW?[` V)gBnz B͕<^hG)o%tPGr˜~>BLz J-33 `\xBgH'p!SgpD rIzҪ`UL@_"Xy9XZ5v x]kn8O s֊Vw{#Jp^Xy&67\ ݨUJ R!RVeĐk+hQTdy׽e5MfYӢ,n5K].A|tZy{S:Y;DC:` Zx2U}^aӛфmAT=mDJ/_jA.:~qQN8ih]ST{i54tP Ol~[ *1熖%D)7E L5 +Q4V *S+IyjQQ1Jh3E6UC!*=\@Cθq%eJWzpJj"Or 2=HЊOS JH@t%84yU&7}%6| FK$s,q,&SMjaQ USeЂp}QwoXn5v%ux֖<j,US n'Or8ih?8*q]pw&'Fps~'vhp[ [_raulEigU;cQZra6aS/bd%%^5&@ ' lD#MD&P)|"5g5Hgubq]gzc=Z77bzs8XXw7yc(U2@22 񇂗'](C^eQ8 1,wVB\=I@?%5MAV'^ |'x!c]g16 ^QRTP%XFyb(%V 7b!rIf!s_bnRU_98tb5iig%[<ggvMŽ|ÓĈ[β2fW+8*`7t"6Hy)59s񏪗vO|;xMDX Y_VAReȚ! ~U^uQ)"M%(1 Gn-|ihdl w@2svq(v!ȡ*hD\yYIw$NFiyA]E])x IV_r9$%%^(zWDM4NQrȒgJa/yhYb-7 qѕԉ1%U fOUr`!))F~{F4H g?CZgsdgQɖc!f%؍Gv1gb!3O5xY@y!:"] " at(Cr`*y3T(f >}Q9ITR@]C@S&g@щhA49I}ae-5ħ9w(7DTsX&+lBYmiBXQriTEjP2Є0 2>mg(*W{ FoiëgEUjrTEq zs5ZU F0(XVKI~".qu2)'@r5O;㜌AI=9ћI ȇ-$dSSX_.)O"{ҥ`Z&9d㓖>E9+5WGw{Z)[ }k-4R#ZwJy*IVg !TYJke7x,ƣ)/]@q'ZZ;h(ٖXA78恧01z֫;66V1]Tױq'0gA<࿕l;$\.$bdej%)ziLj$g@6f4jIii- ?uz(ؕ{?EKtbMDY(;ÕfE^޾ųl.&Lܾ?yNM\롞ױC6RQ-sFqßɑ;&HUi_ tн9.؟o#e{?q]0K_N?_NCl1@@ DPB >QD-^ĘQF``%MDRJ-]SL5męSN=}TPEETRM>UTU^ŚUV]~VXe͞EVZmݾW\uśW^}X`… FXbƍ?Ydʕ-_ƜYfΝ=ZhҥMFZj֭][lڵmƝ[n޽}\pōG\r͝?]tխ_Ǟ]vݽ^x͟G^zݿ_|ǟ_~0@$@D0AdA0B 'B /0C 7C?1DG$DOD1EWdE_1FgFo1GwG2H!$H#D2I%dI'2J)J+2K-K/3L1$L3D3M5dM7߄3N9礳N;3O=O?4PA%PCE4QEeQG4RI'RK/4SM7SO?5TQG%TSOE5UUWe:Z5VYgx+HǞ_g "X{l {|m'H96Hig Jm" ׄ6hM\g279\ކZwX[ X2Xdm 69WeWd6[{PX'Zk_ *V);8$nY$zŘb)>.2!C#h9 cRb* )݃HB;+t!jmIjx9)WN'71()I7fm%.&KiѵG}w遤@6ނW NkƗ)l r&jf{v !)%~ &~Iq[[gOj<"/#Сy}^yk'hn j Ƈ^/~$zZ~\'9oi^w5sk^jO·=pAq5oKr< } , ; "?} g\}oC:}KqŮt!_xs $ )<"A4b(FTlG|:n1t;H ~PJ`QXa}oT= TPQ[_ X\Lxyak; s\e@sI U$>zNmAKOF6 ֪UQN=)=uwg{D2:"{fP"zYVsSR hePbR ͹zCCy;t:At[)],IL+[٣zN}鷬؇'2]TIX9Qdk&o WE~ ;1A@;ѿױh4O3M o?_I-*i*RxtҼj&kC+b"?v=spꦭ)>!*5#\j aA݁gD`J ?3s.3%Bv1=d#9C*BJC8Al[u" L9mɠ-30J):%uzI7R(n]q1/$xCq7~ə24 8TijC\(e5iJ)_j9^;C٬4KTEH/f/jZ-tZ*Dee:+С"+\qI4qPd72)) qƊ(V,BfCFD3<\%@|HӻƇdCoJFW5u#0){a)uzH(Djaks엽 6'F|%(4{@1qھp\'P{,ʪD+;`Dr<%qْdAk?ЧZ˻d'Z$2,\$K{=q:L(|Č;٧Qćh̺_-oKB1Қ:oRa#$)JD,]PGkDDX$R{X+ƹjDjT-9q$e7[H) A`/3Mғ9?dL,ðjKERDjSDQ>8/鬘z8TD3@Ķ,\OT=_*-B4y/8L  b)𚬢ӣ;6=H^CB2YgAO,={45? XS k%C<8O?+GZ+jUrZJPTk)q{u@LmJ[SyC&G1P-aI>[4 yCGRHB0rRjGhV:E75"^_aѡ\!M g&UŠNc/@6FG )BDijR /LCA$  ٦ Aem]Wiه@'3q;ÔS MQȜ|'CS/JFHP\/ͱ6{ 8/:Yqς@d@@D@]3/lБo$ϊċ*#SZKK5JtU: bmu"Xt(+\ `2&Q^eseHȵ*6XU}[UI-i1t>KoPopF;^j Z Ds#T/ _\?ph^̒UMr1hT#qyOyC65OY/Kf Az:!ؤ|:۔H"EhVPtR_2H1mHeg|;b !S/xwHI7=G[]յX;j BjQUTIwMFFTxkWSC]@'ulQUI)IsdWtIKZpYv YSw]MVBIHe_D*Vv&]so#eygUq9$y[wIWMbIQGldRU#RaҒՑ&]Gpe(Wuޖ'jSqy? jqOz++JdHsY?)kR+[\ɖz#U$RK^fF5dE$EI?6-lA/)vn.ٸ H N7V^;mf@F"S:\Z3k{!QI,\o\+YD&9E'Ds\7#iٓYN-@.w!)Hn ]-q t?+ZFt2g \E3r !FJ7`#BUEǴ钱q/3PHj-Yx7Q] [{z眸BKSXlSwp11)%^Lg [dK_{7)ߺe &ruْyxTtn֫< sU[% p`$5vf%|`Y V /4Aj, RSR0XXxЅ$< \ {Dh( 'l"8) LRDB%,b$EфY"I攓'Rp#Ar!Bцł7B ]DNh%&"!'%c 'F6Ѝ .YLR%M>EC,Ax'%E͉ `JVOL wC]2LN'򒖍d"1-)ްfAhEQ$:G@P2'Irr0 {35XIb&'@zn\Ѓj[cLЈBT&]C~D+JPq8hҤ(E[Q[dI3:KzR5=J7_9*U'LzSt=jN\ԡ ^*Xf=+ZZF&DjbýJkKRz כLa2}`Rz}:w[,WeF Sdna_H,ikeڕmųils)mo+$5Ijb wJR8*MVn.x;0P=/c99H'i]Qڋ^΋{},Hw3TfϿ>0w3> 7_d!a /o0C,&>1S.~1c,Ӹ61s>1,!F>2%3N~2,)SV2-s^2,1f>3Ӭ5n~3,9ӹv3=~3-AІ>4E3ю~4#-ISҖ43MsӞ4C-QԦ>5SUծ~5c-YӺֶ5s]׾5-a>6e3~6-iS־6ms6-q>7ӭu~7-yӻf7OS#EA}^ BýuE^HQ8ƅܝ͏\ d%k8S}wSQ$Pj/&U߼$ҪL5q2} e*҉jLS]N3 L.i ;f}`ىHDjAgHM,, &~v]q? bu?kJInoRƑdv!!by19Ӷ8^00-lԫg5Xn;tNn󾮷{[&)芛.i]'֨D$R/1.UZ1|J*bI0h"W}U=?^+Tcm]ݥ\S_| TN,M+8[4 gyOJB,_$~ǂtqƒ Dv kH1aAFծ ݈Ԍ]8p(aPE\1^8Fy|Ol ^!FB5F r !a_(fpI鵠BhaK!^KvJT<͟]BEm!\` TJy "!+O@ƔJMK6qzs@ԈƉOKX_Kx9]FxAݠD)D90 )le{V9#MM MN'O!qдN쌄 HؠI"LN<p|" N^^ MRPL !":n$GOU0'ބ(Vqܢ0!ZH$JRh$0^ D1J!E$L$&<MeJMfSVT\G^%Gj^Pbc #lG(ƄX6H^ND@P)*D!NZ`'%㕥clDAbacHц $.=O X -BTDD DULe e9aƦlJmLj% `dǒpDnĉ&; 9} H "`NU,#<ޠPll"N}]\^^ͥ@΍TPDyRVH|L͹eq|\FD'RU 4[hGid$z(W0EZv hN4L' ܓ޴T\=8 ,^OߜRMPQ(~IJdwڅ@(T U,(({-.LNDuMOHYhlQXHD _3#iL̋i, bK) IATBAHqdƦ66ѓ -[Hܾ LPѦA@3 Bf9ۨzϱjPD^f]PDձQ LD]e]Y2BTb^+fn+v~+++++ƫ+֫+ !}Vbg^hh֭!i,TW6, _vV,rkeDȎlKt~ pޅdIK|qǎv\ʖ:axG`cdEhVHa@ifE% Ɍ, !*ʎ8k|V_MI?^x NټPʅ%i k,N"..Vpr&Y%}4_P4 Tm\&Dd9`ne.Ր~lިJH\$  /aULXsXx.}0 Nƈs)MZl:*=ƄOLW%SE6dJHKKH/jQ2D>jZIdGgU^mQϕnpeBPzs^P Jo-K(e\t4D&l֮CqRJKɈm"\X-M}2J+OqK8 lDU ܈J%o\KB~R1\B&= }h M-k .P%T&1#]xהP,:HWpn 'kLܴbT1AodBץ)k.4D,}K5GEӫLEE_4Gë)vH4II4JJlKDL?K_QLQ\x\5P#piNwOFR*St$kYFG ՙJPKTnf/qOhDq[cOS12];;e𫭤G+/ʙBc&X_50r/@Mnli^Drd5N(d5gtID޵F.{tY}`؂h wl5Bq ]!lNTs?kGsStk]FNTJK[%LQ7PwUeukwH57SߔzOy[y7Nh t'ΆG{O GvssSw7IM#Q6W+IBg,p$KUP%m:]LiDh8n$6zx ]VXzaKD3/VXj8M 9ǜi`.ޓxQ:Uo5N$,pTkֆ?9zty8S GgJ@0ΡⲌ ]@:m0ʤV0'mJE0sI ^PfYtKzn5YyUNR8GR$Z_Y 'F;EJW7+RH;QN5$E/K /I}A:S0sHumIr;HrwIJcqzm#H bI62^ps.m4HI͊=g(L`%O-Cid熪IJ$uBĵ2#;c_<ٔQ4L~M> Tf)H̟zb1HT\ sl}^c#Ir}+k|To8 7YPf#8>Hh Iψ ps᳞:~ߐSWI3NnCz;/h:( 8e 86я{(`#+ ?PLQYlaQiƉANGƀ)#{"e XRϡ$"s[s2J_hO~ $4j(/)$L w# )y0 $˒d)#zBT0.C5)$B RxBBU;l4%ڤ!2|G2ĬQVɧ&b[D_U=is)"līmkVmoi H t$-@kpi/J )f[WGBiehQ$PٱҬ]d;'XtXzt'ޜ;+ >"P03")Zi'q;"֬Br7`¬AڞAU[,J,EpC[ÖC)l; J\ /p YL@;?j3dr[Mh" A!S3Jh́.߼[8= א\hb+GVųuQ![L'DŽ;*}uE~,_`Oި&kNx({~zd=[2IVĎ; 'p1A nMO u\S3 /CPxT0KYj 3R :.R)C|d! 6_D1}ɋr! 6CP)#q4 /±F0RA@CJanR"%̧S* Պ*JAVJQZr5FטLL BylVt+aK$a A-tL^az37y!T!Ҿ$g=S") _ZS9E-MW|HB͇|x$ P*^Wl/RQREOj;^RItU"ɧ9(m( GJTΣW?׼ "ɢVCPŢ@録4IQR[ Ip!:ިY1&ɥܑai!FldWxӨ a;ח*5u*RɘfIAPB#3fy( K/Ґgq!.9k%: YdGؤa}DtL>E{XZю5-&  =A# zzZ{Smk)Cb9G:p[&k d [tHk(^17}Jۣx>r+\V}IJGRx'v'(oS`Ҿ*;{-T&+܆D!gjIa%YD0}IK(:mv\͸ukoOc=Vi r>)вnP )أ^yLBR5[2ɑSb Rl[`qR֣A]r%wtƔy!t򥌃 yX*l+ó%jԐuXɾ8Hؘe]"HKeJz:*=LgSE_\֔uP BCHTK@\VbPνy/RLn6J :K P*\"R6ѝn֥Jm"|_ NwWV"R¨ ,*wbHI$\RQ9t,:YIKA˃%$a39K9GlL $5f L';/dP2 AqpJ0 #^IOA͊vayADPIOSH!ԝw} LٺiW%אJ/'8 yN 34ᣵlǨ *3u Ͼ25m[@mPHC0m߲ d pxp+,)pE0 1q Q,c 1m&d%$ ()-11Ik.3f[v H'4qY]aF@ 3G{FQl1G^.}1q+q fkďHL1q11[1qɱ1qٱ1q1q2 r 2!r!!!!2"%r")"-"12#5r#9#=#A2$Er$I$M$Q2%Ur%Y%]%a2&er&i&m&q2'ur'y'}'2(r(((2)r)))2*r***2+r+++2,r,ɲ,,2-r-ٲ--2.r...2/r///30s0 0 031s111!32%s2)2-21335s393=3A34Es4I4M4Q35Us5Y5]5a36es6i6m6q37us7y7}738s88839s9993:s:::3;s;;;3s>>>3?s???4@t@ @ @4AtAAA!4B%tB)B-B14C5tC9C=CA4DEtDIDMDQ4EUtEYE]Ea4FetFiFmFq4GutGyG}G4HtHHH4ItIII4JtJJJ4KtKKK4LtLɴLL4MtMٴMM4NtNNN4OtOOO5PuP P P5QuQQQ!5R%uR)R-R15S5uS9S=SA5TEuTITMTQ5UUuUYU]Ua5VeuViVmVq5WuuWyW}W5XuXXX5YuYYY5ZuZZ5.i0pd[UO[[/[C\:p[@\\5\5\^*D ;H_[5^bV^[/Gauaa-"[rva-b+6+uc5dUdE\d-d^=cI]ו\ufkEj`O]Ղf]ff] `v`E9Pjiv[v+hMcvb/5jˍeidFeSlkcgEhl_fm_]Pfu[Vm^ǖE!i7"vb)797q)l:#d-"mbE7) 6$8q(WqvbH@oba+vqW.cw)baww1[sjmw{{|| |ɹܷ~Нͯ_;<бccZ Vuo?}=׏x{ٕg횶Y'҇gQ[K]vr_5G-3iW-=1WW\؏<ۖ^?V[w=|͞17{+{uؓhvν:߹ <8 .|(PaÈ lТF3XāK<2ʕ,[| 3̙4kڼ3Ν<{ TgCIPiID&M ˧RT!ԒT~1+S]RkSj"u#ڸ;њku-˲ 3:;׻m%*دѰ\! 8s\-=:լ[~ ;ٴYRcH{0JIU?͜Dž~y&nR*"s.8$q];`H`Pͧ^~I $298aXNaF`aslm W`*uXR9T!}$b8߂ !{ecoޑ4G#<ܓl$$IMrFЍ~ fbIffHʥ4RIj n/M$nƹExv'Bo9)צNPx $vEƩѤtwRh3y'rB(KJih kJkTavSyef5kB_ޱ&MVlhEdYm'9ukmyhQ.5Eo Gs|0L% 穟{J&'$w9%|%,Jz,ʶ]t'ˑupz-|JʀpBMtF(UQJ_>]3PGɨ9ZiauynتZ5:mYAr"-ʽя-MOBii/_f"Y]x :~toy盃(mtaGb# jNPb,ĠjpaaDzn:uF :{{NH_4ꡂ. Ms G\`/O#]n'.VB׳.a,_2`י>/ڌ]CkaZџYB]Eqg{:מrҮx|TNsc>y-tғOgNTU-'=*n紙;O7mJ$tIOҔ٢![+ .g(!(tͰ-- 2< #?zBO±Ԭ:MKT h`0=*sB2)5iL@y*m[ ymgkpr!(٣L?> rjuJ| VIHIwm^aXDq5[\OԪ|z٥Ʉo}l̞dҮu mo 7nµIo[s*wmskuЭujwc+h:Jrw-yϋvt{*v woJy%Z VmK\5+x nqrUd./ xk5 x$~+0wIZ$x,n_lIvnoLz xw yD.$+yLn e8T,ky\ 0yd.ό4yln 8ytSc}:+:*.H%zCVBDEU\ :}D5>#*>m^Y\)ZK-bKr%Y\։@P$NE GդJ=u$b SZ]J;lҞ"Ѭ׉5$ZxYъ٪ʭ *Jj犮骮ʮ *Jjʯ +Kˎ$yu9@  y[RId!d ` 9`в/5A 1kk 0۲3k?kdG` [ 9 p%!&SQ FK5kSdp@ Ck49<9k 0!I[IIa+F3R`" ) F ;{K4Ѳz+X[rKA븜Y;I`BK>\!GöI0,k KF#`;  =¸{B3ROk;0ͻ[D#k 7AZ+;B#p%!k[/k3 Pa1/RK . 벶 .k59&2 0kһB Fk  d 5Q-\.l P .̴{db@Iܲk[Hİ x;0 \g da|`)akI0bŤkLŴ¾[|\Ǟwp5J[Ʋ!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,}r;H@\ȰÇBHŋ3jPƏ 1v Iɓ$G"˗ GœI͛8sğJ""Q%:$!ڳSDnMUB&BS&*k׳,UBTNhʝK݉@lKf料| $E ~ڹ߯w#l%oS$l2 Ύ!+%grsRͻ &h9Jʞ-I#øx+!Պ-'~Wt'n׽ɧ9wRi&~XxX zflxKlQn gdHP;bфv%@Y;Y5aD\+ ш4^~eɑGfMijZ~߹Ygsi&Q&)yCJe|2zFjD)\r)x蜉駠NيsD/4&^-$E_W~%FZjA@HQ&Cn[S%իE [,HIyA;h`d$@+0 ԬNPbn +`_rWQ晲W0D~5S$=j%oRԷc<$:׎׽J,^׮&C:T^TmlE{֊4\ɧ2rr<C8͡fĵRr\5\qfD[rdĨwEmފa 9м m`ȍ|xבoq 5q@ݽ4m!Vssn&uJ8qقiܳKq#4mEos)Zqd5.prttr|k4bQ AR{pK$=-~"`pW6wzp>&v7G1K  bݵh-XԧeT[H1B_AP+| H2J)qTkթ"C뗺A>% xh2 F:!ssL;C&jI<< q&DA$pF-n ࢼ7./iɨ%D!_98*4 deL0qczF-rp P#4p!A:yFe{Dpr=ΨU8N CPgĐ&I@%sWH#s\*)eV$ OGhMyR}Dw3PUd`4[ҠUQJ 3` 蓈@qp dW *TZB4׸xcj7."ÝDI$1.qP |šWVMF)E<.dTy*Kk,0c xɤ^ѫBj:d,HE#'$E3?a7\phnU !!‡n4M o*u)Sj/Hμv)Nxu!O.j92 Sa32# .`9Vdj떈DԡQsUmCNy)\@esȋ0 Ab;(UzmMRT !AkQraZBLdMj 2Db@6)}<ʴbOmP.I~BT=Ja?^gCNYr-63"Ң@~J׾ aP!QPA"Fja$uY10i$' oFs1$H6)"Y*3[dMACLQL/ײi-:﹟T-FVx1)}Wu I[z83вHJ`3Wl¤4nQg1.bVAζB:Hŭ=:u]Nm M%E" jΈ xW~4弛Ki$P|b)}m1iBH.I #]ZAp `%F]k;R_y`N§E`bֈDmD _|9릅p a,Hq΍Keh3*ێ[$bvv$ȼ=?;^v<^WG C %dֈ&wuI%%:ԝ/O (0AhxKg[AD` b}]`AaI"[veox A"[ $xO7vC<: migWW~c ,u`vecn[[ٵl!{})&>x$j2|ƈtpA{hVW aϷ WӇt2q?٩y <{UkV&q&D`J/Y)}gTVr?ay+ CI,w<%/x*5$a34R-ăF,KM״*by=j$ NQ18-f dҤD260XV8hnDA=_ӑ $1g6na1`ߵ Eg:2gVh-]j QWz F*].[ ]kG:Cg%Ufwh=6 (u"CxwpEp{MB,pהC928i"~G384G6 ]sP2hF4["n_WCfoဇ;C4(~uh¶oly3۷+R+2b;`Pv+¦GE|M 4AA"349 C-=]H}JMKmLNI S-M]X}ZM[m\^Ԑ%>Xdi]gmhmqruo}n=yM~׀ׂm=xm{M؁؈ ׌؇ ىٔ=ٖؕٗךٜم-ٞڙ ڋ]ڤ٩=ڧڭڍmڱڲ-ڵگ}ۮ=۹M۾mۿ=Zlr݈݃̽=]}ڝܽ=]}=]}cM>>^~ >^~ >"^$~&(*,Wd݈4~67(>@B?>=DGn5LNK.M>V^XWY]^Tbc[Nhn_kieojt.u>ag~~炾>}gN2n.0>^~閞阾>^.;ߪ߫>^~붞븾(Ȥ^~ƞȾ>^>^wmmn|.~^.^O ?^n~?_?"#_$&(*,.0?ߏ89:<>@?B?N JLOR?VNZ?[__O]d^fcDE/t?v_xzu42?_={?_G?^m?h_`b?O/Ɵȿ?_֟ؿm?_Ļo_DB ,8P!6|0Æ+2\h1#F'j#GM<ReI-WRt%H5a$y&M=y tЋ @TRM>UTU^ʰV]~Xeɞ5Zmپu\u޵^}` 6bō?vldʕ-_lyFE=wYthңMF}Zuj֫]Zvlڳm}[wn޻}[xpÍG~\9l ?]ȐW~]{v۽w]|x͗G^}zcN_|T}wO?P <@/A|A P@)B 'PB-041EOpEQt1FglƉ/GwĪ=G!$rH#DH%drI'G)2͖29-rK/K1$sL3DL5dsM7 J9I;3O<ԳO>3P@ԭD4Q:|FG3,QGR-mJ)I#O/TPIQ3-T]E5VV_UֆU4W]*P_{Wa%vXcEve٦|36Zj6[lն[n6\p+jYsM6]eew]wۅ]y-s>XgT~ݴV U `VaUaX1`yNz%ydKFdSfyeBJh oW\syg{g&zh3fw$O%V喧j:kwiX 3<9).)ָ龻opG7k3 S.nN\kϷ{~ƕ_*}kuzv*8c dڠ%`}Ǐ?X *ҮBorZhg?O!M 2Mq<'AFЂ`1A v\ |,|'JdS_x'p2P/ \*,qep aO̎+ ȡ'JX+] Cʭ+R&=2vg4c(Q|ˡB*t!;H 9kHy ]8q!z4dؾIAqKC4HH&Vgⵒte,a9KYr4Isc.EQ~+ax9UQ+>zEdӢ&@M O)iv @&C(g9&,"FZa {aj;Ozb{D%*nnS@@ gh؉EML8[j?v \:Q$` h@.9^䔷P% IAЃ+`KRʔ0L_ZSTTFgP4 PԬ0\[Q-wM, H.ΆNsUf1-Hyouk\*,uG6J9ThZSy[@_)vH;V6Bb߷Pr"@H@`m9ZZ5mjQ0!nò>1 5U3g2gCPl5pskt;]VI|m/~oyF/`V ^;OB x/R^ORa }3pa7iujӘ9 `'n0| fQTN[z"OUiI][5ca7u6zMQ"6 @6,f;%rHEs>MHYNwE ~X;fՖg6sL<\8|mWpw ;atجI=fW֕mXshE7ыƮ"A8(7VD`3tTfržn}Bw$Lv%`׽Oy-_;6vlw&ҒVaNvURj4CNo 9iXc:³YidqY$v:ޏw=5hDZImMA= UE,Ƚ>QZs";sf'7yQr/ߨ-)o0,D`po@ ~<9W$ 1Dwt/}P~9tm7 MrO"pu[;pA۽߄>lfGf;܋]owqZw:x?ejī%ys͇5_xo 1כּ=tKzu;M/{>3}ǟ||7Es9C&|>E/\M}[O?}gw+~?fbX,. -A-؂1h@ܨs @6?Q -0&!ʓ0҂'لZ:ZĈ--п|9Y)$V--`V2$ V`3?01D'B;C14sQi"pmA61?!\QVp*$D4DiC|k -Mrxh/ /IU-؊X<3-}, $`VX$ROBV0EDXE  b$F∾D`Y8Z7ʢ:q)ZNR a.(:- u#ql1BV.\6xQ`Q`++B|L Q.182Cy$ ǃ 8ul* Ȣ+Ď<ďTF)>24)肆 p30/08L=8 2L4!sZɃ؄Vhw@&d 1ز@t@„Ђ@J+Qx(ʫ|J)B*BPJ&|܂A-pJ|J,-@G44ÿL?5LCL0$d$$=I"Ğ{H(ID!p2 $!hL $$).bI.#[hVPųE hԀ -B,@ZVBQZ8QZ<ŁM -YBC-B UeB-8KPD4&=F''U˓⳴8  (S +<;C-CX҂XJ682)hGԀZCZ2:V#TS><9lv9v~yד7;}Ө:뫻:;^{뢒;ȡ9 <_~⯯_'㿿$g H |# J`& *LB$H2!D rDR!DѤ-IpW)mP'#KZy4%*OTh#,QbUHsd[#0(`s>Z^, +opBl.&BB%pA9ȗ$*4V¦Bd,Q2JLr`@ZHI8=ٰ d*X1 @ bc-ȧe Vh cX,JRtZhCfjq2"MszӚt:eO ) i} yJ'*O …`S,VTUM4) ˍ7bXA"leLZUu )M"@ZԵX-hub"Z(԰j4TD1+-f4vEldZcm4*lNqKcl%P!3m0IM K(X/K^J *̹\"[pWB%kA-H5eY_jdeP}jἣ(hx-Z^"s= x O-LԳI(K L|@b3 kQϨx–PQ`+\QVg*,)=QB̀G 0Nu-xkY"0 FFK[ 10I/ǀBdX,jAPX0'@IZ1W lY]EM1k Aİ!{ /UKp*FQ;Lṛp!hi\mjvJ( 1)-&1PVBB@]28,}]R_" [NMZPRA@•*ěhYtmSBw/Zg$$x]0,cOlBB6+PYM *M!HFMY6&~3eCJ:t&.sC٠ HAR9j٬bM%b.E>1V4֯n~Th<¤3Z͌'Nw^mSO-2<7p7ܩD)$$+t' E5,Vϛkê;PP};񝵵Xл``˾}^X(0qTvL'@ XYDQ!B ^pB ٬)H| ^&ifEe}~%9(Ox3e^,v5_(սe f2ۼb]tMW R`mqyZgddZpT@ T᱂9>9W B`HQe Bv$E ^mDɛNVf!nQ1'KTY\XzUadȅIi֙M,+IU?1"%]ْ vb~'&ƒ^BmU Yj }Fh YteWDl1ED@1 =XTy`9 ~AX~=-Z1,!W%ҙQ=b=ޣ=#}(RQEm@'E|YQP)XO,AtQ,Ll==L\ bPҹYgd"%PMD]&9؞ҵ҅!-])"(Fe(J%UZ!{pSq5_lWq *#7eA\4ѝ5I[JtDMZat4B FIAW5T\9$K˲ I5 ""eB&zaR@X(fZ^Ʋ&e#nr&ofmNVP I$U%'TTAYu+*(S}P9ܪ'TRDH„H fNEc]VbBN|AՂ9':_ZbBIO~_%\_E%%iBH]E9hUnTv( IqQ)kpA[Z,PP|Qvua]JWm,&٢hD@aA)vI@)*yh]ܡ ^mA-@~f*U"*pNF*f?b*Fg AP_o6cH a;%NgNof"+&+.&WvpGPE!L+T(\dD@@U«nrЈDeɜHհ)Tg%R8z- )( ,JkF,NV,Ec6ϼtFzE*Յ ,#d¬jEfheBpĆ,wNqifϸʪ*".mF6rNfnv-ׂϽZ*(}JBR۞-ܾܦҡ-x-~mUԬR,.,J q▏01mj*fnj:6-\ ɶϵf \.ϵ.έ@lH^\F a9aA i $zR铮)#-R &* E>/Xc`REArC+ ehC iRBeK$p{ŷMv=%^٦BgE\pɴL/(.˩2Gpe*&)0vzD3:ևJR q3m@A;Q{Laa^>o@XjJ^0T&v[- S oYOTB]b^љ9gEK$iE\( ! ! K lkQ-Ċމ.yeBвX)BxA0b$@1'ETIԓ剓F #}sω&}Fehshm{)BX[>%@73 D?-DmA33= a@i<{@k)3dIR{r#sDtt;kI>G3!Ds[Boc?4V7~iN#BX> t8ꓢq@#4ӴrtD9HNie*T&OsAs5$)6N^NX-'UqY}gvsV$'T\2rbU PB. vd&xE0X$L$]}oNA>%eu|*ԧ$d]dᶀ&MpNk_U} 3p'hYnei?waY#bx,M7~%}DÎ DŽOBNd&|Fa}ploUf}YB&uGw E7UDj0ON't1DUwʭ7eAx2%H}A&/M$u*ykw %'yaׅ&+G 6Sdg,6C,cc?3ֶgيs5:g1^Pqms:P2^H Z#Cg^=3t^c@">CA&p&`5ƭBkaAD=w(;:E>-7Bj.YBڴ&!6FC‚{g^~;BfkʣG.ݰ5n &UdvF :&!32橰m#%|UTgB _ /Jyd e6G(Ff!T E\TT>Y2'LUPSvLwkZl)+UԙCcYNl#a&lе)DC͙YY>Nl|&Di V5Y/DSdnB_9eTfZ5y(g~RjHrpN%F~6uA%5r]rN$Lq2*7u`~✈W!j@mɊkG'>~`rMP?E@N bPI?g%e4&@DI$1jѢ3j$!>D#FUkFYIxlh1IPZd.&YG-9fJ$1R[Fe**WfպkW_;lYeT{ZmᾕJxD\)Gp˗&f:- Č$}%q[$`28Þ .){ZI\j2fU-H3 +VcpӕrC1e}[td?{ YiMWIK@@&%J ªQ}߶kBb>h@Y;So ZJJ  JS?V֨kQI-$  9zAv[Zг-V8:z< Er h#kY8J2qD(/>(6x@bc bpC$6e4ͣpQJy"iѠ>V )%“*@zH NHTNbb:E{M~(-n%‰6#!ŧ6$PI]\ `2 I y"% @x Et nBAa Qp;ʄvdRgÉCVC0* XT->Zdzxڇ-HAR <+()[LS!)·G1-Vѝ&ҥ95ʊjC'a@`-x E-dQЂ'\-h@$mP3 'MH" U&&%GA8"8$%.MXnS Nq+9-җw+*^xAYIV=-3tA` *r!;R./X˜ %L3\B)щE@n)R֭v.:A:U ! a6WӒeY`T.u$qTw1A L0rdB0҈l+=윤KZbr:!m6][[a4-}p *,.;h؈3[Kb`HKWyuBV4UF-vb8Mi:;B hqam`Y+j _SO SXE#;gs*H׹Нtw.PNu/qR# 2I5R `()h9wI,96X\#PC z#?A 0?D%J–5yS R :%5#MB~?ܖqQ' /&b¶01GCFD7V e_ w)eRA%CbLI!Ub XuAǠd4eKQb(eD&-Uΐ"1 @7i*f (hq^u@ekܙ$dmkΚֵ.ns[w3&"=-OY:k\/*1;HvۄI.smSʓ9-g{sg1`MS>VػgQ)JZA :lEw6굓t/ \ζڥGj <7n*&]zӖ-eX,bZm:w-7NdaH;lϭmOI->P٩ i"0y-׵Vu m3"=\lv3; n~aM-?)F 0a@/<4ninZ҂)q ob`!J$Ed )d~GJN>B$Hn/Q ݉)4/y|0Ij]id'1uT<ĴB0ꎆ<;u 830Yy =dHH5ELh'lO|(fD.•B,"qi8E$94$/@ʐ L0h0׮EnyXbB+"b&<*ZZ׸{[ \҉ _,BjX6& 4@_ 9Be"s&Fo %hXA݆iec < 8!3"40c ̋ H 0+"  (3&~! Lcث#! ZbV@aƀBFA EzD$27€,R2LnC2Dd(RL *R~Gƀ(|)΄lQ  DE$c$b6,JT*&cCj8t/5p$=40Jo1"~C1l EFDC3b0k0gH"-2ry&<,' f4ELkBjh70Zȶ7/\6pu @N(.d6'<֠(Dx 5P-i. s'Sp% >1"&|I%\? M" I 䉠@?በ ]P*6!3c|mN< Jf}cVhdum6¦ Jj`T3G u I]EFnX`G"1Bj0d­q)ΆKo $v_^D$/BG@ H \@X4DHkgtΪJ3U?{GsVogWG?,~: (򄘠 0ʠ+~do C:$W! Yi lJ-0\GO$ V@@Kd]QЄ#xj> QL7j3RWDc}V7~k%}-~ ǃwj} x77kUx8E.dMzM֞7KwUsacxeiAx7mq88VO}Km8]V3.牛Xpax w؊؋wo/˸xp۸D$px- V؏88xiy F1!-91y5٩x;^FyfHy^.s͌FY ZSQy.myqu9b Ȁxy9yٽs7R e0Y?@(y@_>i9G99y幞&d9wx99YVXX阡xZm#1!3z?z7z5ACMZcTI: 9=ZW q,wy}:z{'Z 9w'Ϸy؂:zك׫:zŚKx+z:z庮ڠ:"Y"  \rIiZ-B_ڋ8.7 y(Ǡ5prpuy&;Wm 1 ϖV_ׯN@v[_xlW)(/8NƷSώEb/V0[Y*: >AB N 8YXFa*̦d &nY\sEEzgnSjm@+GK#B qT%swIBvd"H졒azz' 4Z2B `2VB#×aD[' e왵s7ǀyьB\n,HV~#`KhČz͜3ɽ\9huί1jͅy/QiB: :-b"s 0_ս{*ĠK!]m'eF0> G& bp$X@Zrfmm>׵Ha TG={_){((;v"}B &:ɣClA.$$x6;]T`0 k0GV |k*ۊ(S cO߯1R`{b?(,=N59Î[Z5&`;!{ozڗʟ @5ݞ,R/@\ L@']%!?tb"f*8=;*;Ku*E b%D]#980NKU}fm;=TD<"ҐL (׉%Y!p$@ m ޾1Q'^F~ ՝Hw@M[pч!Wڱ>e\ѓD N@Ob ʠ n. W2tY~_XK/I$<(v|0"D ز'%v$L+ % J$)DH Ha&ˌV3ZQ^*ɊMHDʡZ^J@ҩ0(V)WeCjK%#FX*KHH)jĘF!Hd3(㲤Q"tgn R$݈}[A-֪KuT.CMi3fRFd%˲ay%&(^kE2gcy9AaNHa^axv"H&!xbLAA\(1J#.PT|U(!DR䐞蘄8zH#mc )'~Ie-QxH[H!Qaa)"lRHE:m\ojR㵹pJ-m uR(jQj(& 褌:btВ**#F@pn1vک4 ("n"(~zNɚxjimũȶlAQZ&J+v|(E-RK-]: zh*`\N*nŧ̺EvulZB_Ξ/*. 2ǜ2ތs:sΏ QFEA Pa'gL@G)ZUARDUD+~tu'U$Wskks GMI!0-NpH72Ad!fEr}.ty%1LQUAXA^Ku1pq Q'xKHDOt&cX[g"uwA=E#Y9ݵ/E)s_ǤdI+>W7He=G!߬N`N= %%y^Vyo}#Nֽ륐{[! [CƐ2! oB }lD, l B4)AC]>N I)*XCH#EVKJlcy?$ԫyy( O[@(ԋ"i5 \m(y2Yr$XQ.;o;]VL I83k M'Mʎc@٭Bø>Ʌ0VALU6ZZX IDH] )::5l<%Z²d9ˉs3L xʓA; ĴNP ?Re2B(ؐj̓L>s5N,3E= fGH)kH &`lR-lA 3M A'>CNz7©`"ReYq6FIRSNr!(j=bPӞe)a^WM0:]-\z $`g`Ea MV$9qBA=tJL7UoBp؋--iOkԢV@@k_ ۛs'RE%(K-9aJѷpF*mMGif"uS(8HZH˦$7U^"[E!VECRP:R@uE l&펇 )=G0/KE,Rw<g~ZN'{M8e(1K`1{[+bUSO*0y{ɡJm4(%s%#Hm@Q?^>YFӴ*"WpN`mg``w)TMwII3zItG5(n4^j: zrīIg/D#k4h@F+n;)GC@a&%!Y蓴 ]W`$ʨb:wۂӭxK}B{"Yã[=ӷ~@TV9:sMezZ淽x>$3 8#.bIȢ SP܌ǕLN,QHhLޣZxU"V|˲!Mx:Q `sp BxÄIq(!z9QS0{^X*2Z*S,xq| ' d+~EjUk?R(3@|.L 1B* ZщT=p~uREd'A[hCd-=}z6(氡eexӾۦ5NLTPxYh%Y,֒|3Ȫ:M$zY vkU@l]n7{Hhއ ZFZGhZ xxD""7"eL!*rPEO\77-[@ d!!-}+`btI`JW"dK_rs^fH07d򃌄/\7"wB,Е+--UNe2FgWI1Nb@`)fG3vbp"buڴ)tWsWdTv8,F""Tv2'tU(&_al=Vt=ƍ_qWkKdm=C=Toѓ؏fXvaelh8G8cktg Ysmߦ踐$dw(޶@l>BZlWJB!oo:ȓ>C@CQ(d#R@SI,T@T% + ] DEI2qB\3##WG"d%l4I'y_B-2rrH09+k^5sXGae(&3L'6rkNr1)c cWpa/gqiM$d@ w @t]8[QJH)0/.xyb)]%2`bW+9'@ty`yř+';__9 gim܁W_G(~◈F MiK[_9ٔiEO%\ >r ʠif4NeT f`PP:1Q NNG @7gVD^dvx`J(&UH}B=i*AH`|$=rVC`MQA`WJ^SJ*,\5RB1 x `P_<ȦBXG<6A Q5Y4LKGV#c$`N@cd6aTNlR#ߴCc+;5y;gvUF39e5p;6gJöCw[t&h}+ѶEsY 4eSxZ+Ei[uIPp@Kճ*% b[*x`ڼѻ[Gu)L$J .t@ r^8 k;xūmTJVfA:ڡQ4SfzHB:EgKR@6 %Nx\y",#C.u҈7ld'8L)RK8 62A9G,H IiM3˻P#` ^@gRź gR`X= ^RP@ }ƨGƷkZGESVqous&Xic1jwcvcT3Őe,ɕroHě3J|dR%Ӛģ䴮܊[ʤ= íL˫l˵lN:Ӿ" ,̿ ",4>26U rag=ikm }C] E:zc3ep% Ho`opةs-ѓ-ٕіϑ}ٛٔ٘ ڣСsq]@$*R4N\yf S•DBy]]lmnǭܒ&ҬvPaJ! `,McRЖA 7RKTGMmќGL  {"NTp%#rSٖ-$UJ $[i۞`#ۖUTBZ DP)+-/Np Τ}opf0>"!"#cW_Y6ePBN>򠆂 Uylmk s~q~uNjnoFI3ͬ*B$W _ ǂOBZ !U@Ln螀g+B%"FR##@$Q2}^``0N.n뵎뷮р- 6 P6Y6A0os| zWPQ"UylQ~Qטm ]mFfr&mggpMm?ԼTEBQfE #0\"%5FQZ#S@$/L-FHSg/12+mU _c#O[UJ(M`V6 J^_.{7#Uny{ndcjewik?qѩ],qwY^y#"W pd$\ Np f020ϟyɾ <6p^p^ODT*nfա݊:S4hWTWm/OMM,]\EG 6=d\dԕMG6fp $8`A&daC%F8bE5fcGE$9dIdK1eΤYM9u(O?9hQFhLtZ$%fo#PbNj/~L0>Rf/:32ڱ `3.32rydE6;V䵷sۻIlnnΛo;m&|p [d8q#ؤe.-N &U K'-O/e)bro"Rw (qMZE 35vy蟗>z꧷z쯷>r]m;(/%C|·*MD3(el|O?%_T4}?|J$ X@P d@>PLwA ){=#a IxB+TEAƐ'K8LTWCpATh"BD#:SR$-YIL^R==N [LGT2Te+YJW2c/d-#E& Ɔ]W$&u9Lc D&3 c>3t&5LkN3l-mMR$8YNtSdIN2d=yO{g?OH < u& UhCPF2A-h۔&69MnTFIђ MiKQR3I#Ƌt=D{SCuzTSK(ST>UQTZyv[ QZԯckYɚrU1JmV4sJV52mk^Wի_Ӹ&&feYXFb-֫ff=Y~V%m zYԮlkYZIkj6+`{ַ.pKX5lp0mu{]f .j:ZV%x[^Wjw]o}[ں7].t_77p |`8.n`6 կEk_ _XGR~w#NoI|b+VqN z3qi|cR-/X #9Dd#88ݱNm|eg[2:Ucs|f3͛l/e8w9ssc7r{~2?CɁs hC:ʉ7Yt-]AzkVs9iOԣu^ixbҫVuYj=4 [ֻ^tkkDVLeYˆu&۠a&uKmml_[,ZB[&T<;nbzrxoR#vMAv)~qg2FHy;*MNpt= K` ]`u'O׾ӏ{_MOf^]B΅vk.`BB:~'3?{cx   V = @ l+S  ゝ#*H˒C9A*h)X PwI %&(%w+ ӊ ) [pV`ǻBB-B.@ ‘ *%t )$(lyCݙO98íS2ԝuA-ؼ2C@E@F\D(=0 Ji (pн ?=; 9ȒQş3_ E )˾>_E`,_)$9;4P`B**83cmP jT?nCi{#`N8Gl)GlD}G~Tu{518FO@pAQ3SNHXAػHW$Y49HH.\-lI|ILGI?<,x4ys;ؖ`N?Ct?lcKNx~GJD,c 9,!Ƚ?)`l|A>V%ȀEA) :cFFLL Fb NP._`6XsBH-\ ܤM_ `1_ݕ#uY%.^*Haq,2GD cInr xu-@v@}bb8ߤZd8ٛKџ(Z %[?!T * O%N“[ FD|]=]>2[* 9Ze=5r1>Ds)Pe?^^2(d).Wܺm]M-I]Q\d;ZK (eaffenK@^8L3S;dm#y+?Te?AChgShp6? PgPjեJTԋb APfX])$+BMP=ɭH]?ci&gpah\0OL~M!ScCYdgvfnkah&E0al3ÄSh<Ϻ pa;\ټ- 68Wމʝ 4_#ճ0X\yA,Cm ݻ-'Fm*`#e?ٞCNI)~n*@연ei,( 1JIg9.xonNu1ivNpFƏ-&l ,EEEz!Y { h߹HYe*Qn@>WK.QOIeiEc[𖷖ff_봎qf<25Z݋ڨ=-?r %^qr^V kWksN1YÃ1?./<s/5ns9sF:,W+r>qAq'7?B_tEwssII/H-sMsNtOJtR %p5feDUqXXqW?K S/u^utnsO/N?vc*ii#isl{6RdGhha{-u{o v/o?Vkj(.  I_`KK 8yǖџT$Iс NO}}oq?M6敐ydF^iO(w4{zŸ7 w*XN܂HY$1eǗN.ƙ zz/v|{riEI0S%ALiPdÊItɌ)H"ɑ&K<2%˕.[|)3&͙6k⼩3'ϝ>{)4(ѡF"=4)ӥNB `ժVbͪu+׮^ +vlXM6ZlߺmVڳq[w-ݼ T.(3JEog@VaI` ~FPK.T>Mw:6_om[lܻ];8_|泟&.=9t̫v֝wtş'}|xˣ>ͯ??`T)ad)]HT&!B =A H4aIVc#(x)-185xOף?UU$mF"yI2UI(MAĔIDaF&h ^_ aؕ})%@{6'( Z((*(:)JZ)Z)j)AjU$!< q!AthQZQ%9NHZ,*,:-5UbX'_{߲`VaaY@q[Kd%[5F\XTL1ZZҎ-R }a|f݂̭<{r$|r+q(L/,s6YjCK۬- >DA' Ӹ:Pa!Vۅ)օ84H\PҞE[ES[7w۝7{7Ѓ~"铂![qYStk埜aocީ_>YYyc۰_[sZ{۞;;< _<D3#8"ąEOd-0ETH +uiUL=APQj 媽jT૒A ? КgEg1SY3$Nݒ $ xCm?"#hG*rFl$IJB"-IL^ғ$)9P܋!Jlr%-giZ+{/1q0Ic3\2gQy&ri]b&7Mn GJQ:SsӜ;)zL>u4'A}̓v3]BЇ0L)(!Sp&GQI Q)I;jҒ*Mi8ї44rOʡ;VB :PJԣ$0]VϧR_ZO `XsUx$ ` Vf5fEZ ն~X[բT*He$1{}QD@>B,c2mJUAsy/ KE8h ,gdςfvfFk'r6:1IL68Zb_'4h$be*RBҍ.uw'b* @)Ti% {hy]Q/ }v=b/d_uz߃Hj@lE^+Ҁ)hA Ia-I%pY%>6]x쾴u=[yέnsg\5@e0uJcU;M f1jU11KH;a:1^ Bؕ)l 6HPYAQuC"@:fb[kc|{5v+Z]5*`r*nt.EsIX(@`%% KnRc =, 16W:/%A "'{/Iqs3{i;la?H߅l0)dAEYM3WL{ޅA ЪЃ>7[mV8e EI"TdM@(V<3nň'C(!%g@G._א b.0`1W&4i(L!V4p I@YΝN!H텰%ph!@ l uZکŠ `ᠪ %EҘA%y~aIxHJ'J4AT)޵YJ''C[)}Oʮ4 H±Y)!-Ax -KbMb!"!_Ijˁ0=m)xxA`%Mނc \L4`I@'"\ɘc DjpUJEZ Ur5#6jc7r7u1K)bL0ޯ]vd0<.5B gՂ DAP©[֥%\d꘾j~iq)(H_ـA^ ƲJlad&%(+,@rFDŎj*E±+JXڿMhG2QmnjҮ#:h 䬜Z:K2$ Jnݛ][PX,Ȋlt#V˺l,j)FդeH"mު,-:6]֬+LZf^-؊U4 ,>p•mɾmm"!tj  nBm4-2 & &>-FZn*-nz.`"ᒮn׊+-ݺn:ɮ.nܬVnj.r6:f"/F/vP*rzo-in횯oo~o7 /f2V^Bo0"0ZooKp/Qos{ps0b0X Sp Eop 0 3 /p p Ze   #+AiC1KqS[1Imsq{q k1p11p1:o1H< r r!#gK#;r$32Eq&&&{r's(2)WrH*Cr+ťr",'r--r.3Jr5/20 Rr/&%2'3s(?)Ks4S3OG/6s7ss.e8r993/r87{;s<_Q13X2[s7>Cs>#s?43>tr=?BsC3!4?RX4^teCT4F{F4GotHIPIJtK4^tJtGLL4L۴I4MǴNMPNtP4Q uQ#Q+uR3S;uTSR[5T_TgOW5V{V5WoLsDРuU5U[u\\5]\u]]u__u``5a`#va'a+vc3cvd/d;6eCdcvegekvgsg[vhoh{6ihviivkkvllu@!,LzH*\ȰÇ#JHŋ3jȱǂ>Iɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJիXjʵׯ`ÊKٳhӪ]˶۷pʝKݻx˷߿ LÈ+^̸ǐ#KL˘3k̹ϠCMӨS^ͺװc˞M۸sͻ Nȓ+_μУKNسkνËOӫ_Ͼ˟OϿ(h& 6F(Vhfv ($h(,0(4h8<@)DiH&MPF)TViXf\v`)dihlp)tix|矀*蠄j衈&袌6裐F*餔Vj饘f馜v駠*ꨤjꩨꪬ꫰*무j뭸I0+k&6F+Vkfv+䖛榫z)+k,l' 7G,WlfPwWc@$lx,0,4D82;|>-\ #W"t/ J;]RWmXg\w`-dmhlp-tmx|߀.n'7G.Wngw砇.褗n騧ꬷ.nV'/o'7G/Wogw/o觯dy/+:I HL:'H Z º!,K{H*\ȰÇ#JHŋ3jȱcCIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJիXjʵׯ`ÊKٳhӪ]˶۷pʝKݻx˷߿ LÈ+^̸ǐ#KL˘3k̹ϠCMӨS^ͺװc˞M۸sͻ Nȓ+_μУKNسkνËOӫ_Ͼ˟OϿ(h& 6F(Vhfv ($h(,0(4h8<@)DiH&L6PF)TViXf\v`)dihlp)tix|矀*蠄j衈&袌6裐F*餔VjB\馜v駠*ꨤjꩨꪬ꫰*무j뭸뮼+k&6F+&yAfv+k覫+ڨ@@,l' 7G,Wlgw]~,I@(򱵬0g2<Ϸދ@ AH'f,PIGj\w`4bmhlp-tmx|߀.n'7G.Wngw砇.褗n騧ꬷ.nv/kM'LtG/Wogw/o觯/o HL:'H Z̠7z  !,Sf#H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJիXjʵׯ`ÊKٳhӪ]˶۷pʝKݻx˷߿ LÈ+^̸ǐ#KL˘3kyΠCM!,)H*\ȰÇ&Hŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛%ɳϟ@ JѣH*]ʴӧPJJիXjʵׯ`ÊKٳhӪ]˶۷pʵa]u !,!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,{AH*\ȰÇ#JHŋ3jȱǏ C`ɓ KĨr˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJ# \Zud֭Zw~k׳hӪ]˶۷p@]v^]voX+^̸5[Bf ˔e0"WKO^ͺװc26ms{;# S(2A̻{ͼucسk.+wǚ]\<Ƀ[ n{OD@/N0ӁH{eW&H `EE(VhaP}au]iƐeЅ ](($8(FXGqgЁe!BBʘEH1yPF)唫gadQwiyB q &yV@c%Ri@ni#%j}jgi9hzږ -Z'F*餔:Una R] y'@'10gum@hs.t)ks_i&`h9ښؚ1Q+FmM'nǠB>k EKkIۿ-`JfZЩAI0#_[Df 0E*@!k(vt^@)Z` peYPeeIЅnވnUCWtAF O\wX`S1RaJd '"z֬8h+с9|SB(^ d{ylq E.zm{|xn}κ@Q;oM>t 3sMLË|C#|ljh@ \tكA+=A#MmiᏏ>B3^)_0KC%!VZ= QfR샏D+ g@gR2tafpCЅ(AL]pfȅ h!IO. @;wUK!R\!: B \ʕG %2%A馁aGY+pIvy]vAw(0ą첈d{Q ^xH9*ɩ2  2qu}W:њq"WnhMZ@1dykK<5xI2HzRfT0(9@Rr \cKTP5M#ĢubVE9O[=I I,ky W5'4?GIxJJ iMrݒHzsU-5=6c$ N.gA TYT "%TYi+XUB7 @d?TԔ_ٲj>Vwv+,9[GLbm"'>^tJ+6l-VN!VL88N|SU߁6 T)*҅1xEm[hxd7 1,bmeu`D"qMVֶ%i&E$Npc֥!1 cx*MhXPvg=CZ*RDk< dOG'{)4 &)cYu!8h2a ]F'j> \%­+-o $l~mEj*`8}B7Pn?&H{s!tXIlzIXȕ ]=k) rۆsZ K"_.JS@LpqL-1R<!$JhXN8 {@&&n}26 Y, uo}3CǍD)6-WgqUW}{`7MvFA\xW g8HBf0AikIGw"-`n=нlO_nSFX?>0Qv O:| @U#|ЗUQdHs+s@=2IvXSpgF4V|:r{\ (ֻƻP|_822xkm]m Ml Co;iJgO XtuwHbU Vn eoa2S3U3-Uxx'7+t3fyH"TDւD4A'm/:D33lsepz7+L0Em$V%n mݢ4LXbdp8=o1SiK5D?Q'# SWu:[j5{Av]v˜WarwS 8-T&i04Ug-w^d3Xx#40D (F44WaL^"YdTD`:D2b_K&@ǧS2g$I2ّgqg6g })I*WђZ05&|q󈨥qqrBxiC"*FvD+47q|8[pH#iZ|( XDŽG!I+$" O_)+fyh;\T_n/_XEDt:=3ď@^8UY6ߖ #-KC4$Uv#/PBA4#9?D&0qALe#c83"vn"1:hc% `]@цau\Db/*97/X*X~'$NBB.X)f" K?z_x#s>?K'sT1sMe~ebӆbz 5HqtaD$ox8asB|{m6U j!J$*$qG/b.JƦ*)2s'x-8%:!: *fPw؍ăm"GigU8]a^HT})O(9ؒ+IZ yvrjrc|n$BC>/2,pS+!p%GY~ڧ|jQ %=1`"KYiSN[g`ڪ )dH|hy4]QZm'}RQӘWUVpP(758=a.C$H!ZJesq(Oә;diOEnK#jqM慷z/aP /2* a)NxQAT|@[%$A$Y%XJrv~Xw3k)>VubiZR1:gĉeJggvSHb:\)5ßcX-/yC3 EfwуXq%3Cf3'p [p$l6E ?veIWbZ|Tw$O\ʦX5|]&HUo#&#Ew*RZ[ƐkƞQf%e_e%y^Npt*J塎?37 틾(ɹӘ44{-#UaX!\NyDXgNı\AiӼJ@j WoZÁcS2 &&g]>ҘtN^䉎РoXDD~ÖK;Ğ;PİmI^1jϛ8 =_ "?$_&(*,.02?4_68:<>@B?D_FHJLNPR?T_VXZ\^`b?d_fhjlnpr?t_vxz|~?_?_?_?_?_ȟʿ?_؟ڿ?Ic_ 3)v(Al aDHPB >QD%&+txРE%!~Hʞ&&"g$SfDqLb+=;OGbVH%sÄK$H+c>"v?9vjvBN\kKCośWޙU 2_… F0)I ̿_Va h 9Upj "4Եk'ly۶"0[]Y|\o*Tīlc[jWu.O_G֦R_yg,M*⸫^8dAB /KiEb \Ҭʰ*rk G w-fɡ⑦ [)=d,p2: " l.`r2L"#qH(L$$h2@4+;Pi3zR!8k,2QEMQ?NE'M̻K*DvSE W3.Q4Q~UBG1l")DI[ #T"a3K}HQ)jg8vZh͆* "Rx"Tի<'χ\0Vy)ݍ[6$dw^kJb;H%4-ݴ0xQJb [4Sa5~x<LH$9@"3 d7"owQtr]{*Ǘ v玌D[?暏b#s GFu,L`0R'eژ9 لn<&DӞn[މꥴ܂պ& L8b6lBM! cJ ] ˞}\= g<lDc*(n μ&}mdzT$ʦJ0]wtFw_lם~}"P-H!D.M TF9RrB% `(s@e!e!|&-IPn~12t4xNBX#Mʱ^C|ɍ OivQ}I CiE|U6 Q&☉>CKBv@pTaCʴB`F$O$ z=h![lb +|EFFA l2`3-8#j•87U|fS 4sl-lD%9)@ C1hUe6RJ)ˏkE4:i!e J̅2limzrXnf0IC i2qC$ 9VCa Ć$"Ĩ4DZR=5R(LeQa uK`9蛪ٛT<}F Q|~T>-:&,_Nu )D) Tn"Aʲ|wF!ibNũ, (% c=~8& mYSb#-a&rV3ؕ,m$-%ZX s. 6bلrбIRVPg`Ɏ8l`+xƂ&*[ek44smmKX^jKV¸ q#P<2Zh!8T9#D0:+$Usrv@0 C2fCSdR@ĪU,pcBqph( lYcW(.Ee 6HPQl#uQr8?h%T˥@]i%k!wjQmk+<ۨ8'g&ې'yw:b hr1ZJBc<T);*Qu6n-N)-E^=<$lϣW1JOvaV EiFQg(`)2lW.^crRI md> |-Ǥeo"il)@OK#OP8e*vt'¹htdA@k>%HXHM?Z62\b¬ pa< (B$.hW]xqjKh.Bn&a3O[ }띘T YbvAyN靈NlHQ!| L2-#HYzsOݧcr"s$R?NNjծ~v{AFhgH]dFKH(|mU$GS2\,L{At74ed̨'LS-՗?757r `NL) YEzQƐEzB[Q(&R%2z?Q)! F,ŕ@8g|vzttPۗ񨺏h}`KbZ4|#CH5 jHeF ሦXǓjGⱃ]HTYȞ9@*+r``먊Ч|J.`ʈ:ΰ0_$_8#V8)*A.Tīh#a\+LE 0!1#42y!ٛVNRN5yD9R:0>EcOS $)1P4V.c7A7MA E%3|#,2)2%@1=\ 2bS8Lѫx "o^6 \A@JALE+sفF8`k)UAKYo*S썥 'mjʧoC}@j<1ʼnh-,RuYI0QU>UY%k hMx%`ӷЖ"_yaԳh$p 6`6Մ9eT P%ZS6QU!}AJ6zZ ٍpj uzI lkqo US)b%٨@Y2n1$YV "E3WWإ;#i;F8kcU{z mZ}J`{ZYӱTh ̭P#A o*D3A،>D-s+!thJaTHBRIt ݂-XɭoYI̮M\ѝ\L@@I?l!Qb #=XcAG[ҭ\NrCh+|pzI;thO%%b܏J3^如RĈ@)LL]1? X#߄ 1! %ꋊumB(zQ(b-`%L٤ aB=T{pE $\J ][Y^ AC1DC1^Ǵ `Y3u' 0.6fl5ue'm./*Ak؛Ĝ7R67AѮ_-U)3CSQ BՀɎ6ʎ N&Im3- %Q#d>R1 mlk0o7m^:c?{ neZhW629 hfpL:Iv&4Ll#!ZKqZHAFFjRqEd59MH`Usb6<+ @_߼*O爻}sdrPgϭ+5%Ri@݄ Jjw1D"?0bdoى dj Z _'%nzf=Y,)v.ГWԙ@ԡ/i[h #¦0A[^"LH)-@-yYj$ro鹑t9C֒RT@p"-IYmWA{ <" 0w?/*䁸wpPVP9 Atb~+S !jaJey]a\&ɇÕb⯵'9D5x(}e@#I?O յF%fEK,=c ~^ EY ~vV XJK ddߡDϸ꜒ _'$h럭vK@.D qƔ%F / 8 ˜`k5VMک(OsHdM#kӎbVM@̘d:HbNWJi0gZ:֭VP >8/Չj0ǁlO)IT,ؤXșqydL T%Sqj[*nɺq0핢ĢbiK V)0T-r Fbk\!kjH|V-EU{J=RpvU,j7}33杯mKV|]'θ߈'NߓƀڒPz9衋>:JV[?J'+0I\wIHĻ(RR̕@>_S*(D>%{'@JzcOj 0 |8+ؐiBf@odAb9T P#soBET ό.da8 )rQ_{lXHLQvkBdAY|%%Tà"혯̄ۤ;KId$-t|%,c)KUɑTB(ݒ,4/#XIܥX9$)d/_V#3kͱXItd7Ύ%<6sMnNs)5cEl8}Γ\?NyzsTgAKx2}vщR(Fi (QEё4F%5F}ft$,Г6)NsSm>)PJD]P$ѧGURy7*.V*V j^C?yɠ`)ZV.t)KֹҵZ%׽\KοU IZK3\(IRe3ٓB,f7;Ҕ^MgK{ղ}-lc+Ҷ-ns򶷾-p+=.r2}.t+Rֽ.vr.x+񒷼=/zӫ}/|+ҷ/~/,>03~0#, S03 s0C,&>1S.~1c,Ӹ61s>1,!F>2%3N~2,)SV2-s^2,1f>3Ӭ5n28rsg:O}泞@|64-h@Yх^4IK҃4/Ls:қt!-GѦG}jLժFuK kW˚կuSXZ׽o`ֿ66-Ą3-hS{֮6ls{6-p{.7ϭt{n7-x{7m6^!(gI48| /xNp+\)GCg?Sn{\&WyIr\0i<=9΁]67σN|I/zӑ. @&uos}^:ؿ.}f/;Ϯ}߶zRiM+I9C~;.~/<3~o</S~}gG?_ݯ?o(5#^*aM6 >F NVQݝ<>>@Ց&8}ޕ ^ Π ޠ  ]a pTȏtHHD)!*P\tcx  }P}A^!!a @TU982I0@nNŭ ݟ"Ab5bQ$:b$$>&b&f%vb%rb(")"('"Q6I:\EZRHm[R/ݢDuQ/&-[a%c¡36#4>4F#5N5r+ `S[6. "vL^ʵ"f RBx ͑%%b*~%+zeXv%YbY"XbXY"[%\Xƥ[]$j.R &P)b`,&+HQ0 Eza2I&aN^Nf&fnfv&g^& 82T).-8N7;O5lQ#kPC:?Cfpp'qq&'r.rjDfSHHStF!KȐ@!u"xxQugg'{{'{Nd4TPPU6U$e܏b !n~Ne}rҿi"p#v߃\Jh]\N]eZf(Z(nrhj腎hh+V^>Dc`ʧL'5Rw6lRa^r֙y[L'g&)^h77@H)l`kiV̖;lnrNޏ0)W&pEs2))Ω֩hbşhK)Z0 XTnreQʴM*)nv*~j)NHSmp!uR @\jE(j+"&+.-(_⨒&c0jdAf,jjEj e i+Fޔk<&ahV=0+;Q#i ovޚ.>F,NV,,Iƞ.FȮJ켮ʶ,˾,ɨׂ kެBkl6+->lѺ:؏,¬F-Nz6Z,nv-~؆-b}Ymծڶ-۾3ml"m-mmm߾ʭ-­6.>.)im^f.nv.~n-W䖮.Ɪ~̎{.nn."-Ӻn=g.6/rlFF^h(`T 薯瞯/or* S7N}'gX->/G侯}:RIO-\DĮB^0b0fw0.0%o&pa/m;䯡10pp˦0jcJ=V); -rC!/mA'ęɎ,m0l K5*[u'q)2H(s2oVw 5PY:fiZ{)AQT7ۂ+@3-8ˎ8W0-92Y?;՜&$۠K%@-2grgg "rI$iKK?!GHX"kQjHprryt-1 .w;oM(oAd ]5p?`.Cyy԰Ww7WwSV,Ab޸U.P5#y3;03IUԅ+Si'ꢆI2GlwG6w(|28syZ3y=o`ur-e#߾m7k{5CCLU,*o})}}#l@"B:8@ă-fԘ$GA~H%QTJ-aL5qԙN=PEURM8jUWfպkW_;, UjG Iƅ;Wn]wŻWo_\xqǍ'G\yioG>zu&@zwn=/woχLJ?Ϗ߼/@ L?Tp@t) - ߃C?$C GD O YE] qQGfhcNH"ZihNS;, (:mkn(H16;mlPߎ[Gf-O73/\ Oo)N Ҹ}-ҷ.w"7W.:_!uaؕiy T+~4y"7Yq/TgӗwoP|+}$"[臢OeMk`>9|Ծ9mcQu!#EL5 r-Ԝ ABl-0팂)n)8D6l&@d_Đ *SbC)΍U j0$"&/o"3Mc[Ȩ7 E4i rK'):Ώ^< 3PD6- |o׻uzinB{6qUh.#誘tG~zINj(0a&QKF]1_]=;ힷpq5]}jn~`|+u?K3?S{[gbv_mwaL*ia:*rϜ*Ke<~}'1O~[`A?gc_ӟpXpN#/Lm,/01P*=05,e$ LoUY>pip*d+6&PvP}0&k5PpO )   p 0 0:F 0 p e p00n Q q q )1 %1 /1+1EqMO1Qq])eqiqmq˥aP1Q51p?1=qQ1 1ݰ 1]G G0QqqyԱ1 e 71!r!2"!r"Q"+/ 5##=2$q#IQSr%U%Y2K2&O$i$m&q(Q&yq2("((-))R)U'&2'*+$]2,_R,r,Ͳ, #r-&+r+-Ց-r)!/R//20/2/521H.11Q1%s,--/331s3P-'32E1Is4o'A35'S05a5e06 )g7m5kU7*M4s83n39299593:sq);>>E>3Rs:?3@t@;:@?>tA54Bm$*!(B-4CHCC7C;CBD͊DD+DGTEKEOES4DaCeFWF[G_TGcF}4GtGGHHH4IFIJTJEJ4KgTKyKKyTBtLɴLL4MtM4YH'G ޔR ByNtOzϜB*Rf* )Y;OYVL{J OEO).m50 S RwdSm$ST *U7Ȩ 5#Sg;/Vז%WV@VyUr&FIݦ>PZUۮ"NYchYUL*5YJҵ ;TuQ]K^]P_xU_]JNWu`*_V_6axIb+.vB^)a`?6c96d-VbdQG`dC\VcUfϵf6 R p*lȸvv+L#NȅSKcѸfSWgyX\) j U~V vJΖlEukVTv>utΦJMiEjTivnk[Gf7OXXXC齃tDOhl-4VKo 5IpHtш*LBAVVSssm>rGh]tS7vwHvYvHxWx ׶XCrvOzExWsAzOwVqhqWмu #N&)ts77esm͹CZ/I}A'~wƗ޷kU |KG 8+nxk7wYy1;NI?8DO5ӭXwŢz*P; |cFΩa-fjÄnjRLRQʊ bNlk96)ϋAln-bُb߉=Q5c8j]a4OfՌ;ꊟxYx1V]8X޸uɉG]5yxcM"4oOOR*BU'k̓HUj]9gj9}y}֎Vťzjhն6֘ٔ ~WhVV¢֕֎ٖ}vpU5p厜*sIq9i9u(v 8bٶX6N:/֊(jfgX-yimrt uW.zs:xAZX)zq:~-zyG7HQt9][7Z-Ymǵ[㗪ML0[zv|UZ#ړzo Zݚzz%-Kɫg8oP}7QgsUI;#|8o'_PU.X9e[RcXBf됓LW{Q 9aa ;G.Q?YcD6Aٵ;;5SUκK;_gݖ~C,xQ5!/UYpZgw/l{L!mn*j[pͶdí_ɹkpۖpY5{;KT!BʝɁzkwM¨o67gYڟiȦ}c7ˏ'<{CD|w:z`UZ|@ͧB͝<9#yϥԧ'ёig ۪Qӹp97\M@h(A]ȳ{ʅ]*B];w+l-[;5 oI׿Ǘ]h55kڃc<[{UUͨ_=yb5^88xi% }][>}xܸ QOo[L;`٩;?^[I_;?917=]sE|i񖣋i6~/~z˖G|;\ĻQ>Í\oMsx zz]y:9]U7|Pz??+4ь7'z*w ow+x߅Mᄐ%+ sd'_LlgK6cˣUZ4Ĩ/gNi4+mwJߺ_|3̛;=ԫ[=j3>43+%>ۻ?{^?~ H`` .`(5agdU^ana H rf'`b2Hc6ވcpc{ U_BIdF@$DE$DLJM>Y唚w$&v &v_IffBHN(mfp)ldzg~ h!(jh.h!)hNJi^J&lͦi)Oj k%)6z+[y lKl{9*.+*ʆsX|4sIhIlnm~ .\%qh(抢G&[&$ hsQD p͒z0"0~M)8Zioqg莜nH!ZdIP_4L383l6 rFt!lN7mg8xRęJX[ GaStvL{n wrMr%(M@`kf3IQDhXoQ:"Bx%:h;'Th@-WKh>w馟2 'ܩaYs3~Pgy8Z>@ /J6Y E*N~g<[v5O~WJkɻ~ٴˀ,Eg $  \$@H 0HV(pb ( ^WD; OڈiZ %5hS˫!:յE IBZ(w ApbYNk\25eZ0jq\"U.m]YLY D~h`&p~nm@H^y"^}'gHZX">F'# H`z]%CuIO*US"$7L.!Y'!(@MR IJX6y\H2F7eᓩD%.)ja%> .?I*slftDvhdNyʢ XĶj!ae,!c&DڌA7ǟ,s\2:s?iKP0JfjL̡vʈ~|(>Y@AB[lHD%s]^ԈϘt$4o-o~(*Q!wN+_ \6JU!kTnf\W:Kv'rąTLgC(RģD'cZnfC}u-g+)Q ]O˜.O=lIObXDQ\Y׷5w ^.6d[f6 fGҮ6lO[6 n.7t[f7 v7|[V7! \ 'c#|gK\nc|;\!'=n|%g[\1]n|5{ ]A'}n;܄CK~tLOc]V}~c`N]l]q;Nw=|߻^w]}'x^g?^򆯼-g~o|'y_~ǼKOӫ>]zϿ^@E߽{ ?+?_K?ԟk?߾?qyE_o~?߿(x Ȁ  (؀xȁ#(%؁$x&)Ȃ+(*3(/~ RtH9ȃG׃J;CD(EGAȄF؄IKST(UWw8h{{acehzwbhliHpG7v(yqw؆ux؇zo8X|~hH] Q!Qg9q 艡H!8؉Ȋ؊ (HhHxXXƘ8xи،Xhxј̨H8؍刎&A-؎15(H )I 췎iɑ!#ّ$ &)%' (H86Yx:ȓ8 ?)>IؓFEGICJ S).y 凕旕[]ɕ_a c)eIgiikmɖgsuِvyIwɗz{} )Iɘ)ɒI)9陝 )I;MTɔ锬);9IQٚɛIiٓ9 戍ɍ9HҹiXɝٝ ڙY蹞8y9I)ɜJjYz ʠJzizꡣ #*%ڡ$z&)ʢ\q19i7J8j9ʣ;ũ@JBڣECjAz/ZoQ S*UJWjY[]ʥ_aNf*gjjʦih krmZsjG}~*J-:Z*q{ʧH*JGZJjʤz꩚کj:yɟI ڟj*ꫵJjɊʬZˊwjtyxʭ׺ڭ*jHᨉzʨ *:jJJZ*Kk{ { K`b˱ +!K#kgqʲ+*- 5;1K7[)Z CE+FIKG{<۳ +Q[;WK KX+[VS۵\[uǪʚlڶҊrt:qo|[{w kza9˸8븋:+ ;Kk㡴H˴+Kk[fѺfda뵹9ve۩v{`kǻbK+;wBml}X{H['. +ዲI I 諾K `ck cfx+RRS` [@  ` !G lK֗k5,&?s@2 X=' @` @' l Q@?H| @Zp`@Lˉdl"!b, b,},ǣkȋȪtlv}>־>i/1 `?6??&+ I`8!<kQi/Bn͏zc~4ˬ~́SA|-ALVƿ;\A0÷ g\ PƮIPϼ^f*l2٧}~ۤ] Wm#W QƬ?~}ø}>}d Bl ! @ V]G `@FũLpa n7ߖ A ¤rB<>ŗ L'p -Ӽ|p]'?BEnmi|snǛƶ@>@F\3~&A>`. 0 ^?"? n \!>np>Ф ˨Ȑ\ޒx+x ߆@]}˔l !Ub . ÿp^~aNq̀䶞x 0JLV'nu|'|o^ɐ>7Uv>1 ~LpUdžŨYGQ  O llu9ҎܷN{ ^Eȭu;⴬O "Z  E׎†./_~R`f-x̰ ΍_z].؍Sǀ0K \ģ&ǔdL~ aѺ?;(nɶ/e~g1_8AIWΧ"d_\݋|Xn_@ d WYؐCزjE2`=)@8rl(ֿ-  cVB2")~ѥ-lIMI#iES A.a*$$ҍZU(2HR%x%0=sLp+VOHhq/MPBԴZnEFKɋaV1BXe˙1oܙgϡA]iөQVݚkױa6}mܹuo'^7R\ܹ[%BO`_ t)pʷ\L$&'%߶I}@H $bo-9PPBꀒBoB 0C$C 6, cpFLF;7܄O?SDLQU5KV\VSUYaV\iuV^o^s Wa5XdUvYsYhvZj'l-݄e?ނ60N =lI=l;Ӯyo 7܂K*0/:"#a&Xz%l --.EU`c0Z^)܈mHԶ0Uf ٜ"6 r1 ]h{VzcPv1h b~iRW&TkJxtRjLrް(8~zɰx0$2t/~%P;(%*2sy}IKGtWw]uSikvw]I~x7!C-w؂Vk| &7ODmQJMH! N z2HVOv="HĥhK['mK^\^>Rlb+SsQx̫rNBD= #bPԗAR`O-@gC"\(!H(ꡧ*%eRcGVA,PKdY\J@rZq!m^ln&QA~TEAQE9褝i1l|pr Jd$G20ctd%!iIIbd&;IOl$KS~2\%)W2+yKSxe/}e^!mp`| Yrsfs1}6[RƓ|:b.iomwfR4HBn7G"dNI)]+$"w*NJsR0ϛl :r` +D FF8M&хHa݌Ƭ!{"#9'?eB^t%P&,i19iC0J)/X8GT1o@m![)7QNU-u-]eKs{l*VNT0M!P}sw_ (COtjG&5AM+8JzSA-&مel2dפ5|BgRܛaW.Mq TrlJxEpFt]nt[ݪN7}w-o|Yn']vՎvթ|[=<@\} .ȇ a4J(Av9}"Ql"/>#hs Qv^K[ nIOK,N]'oW|myxǷR"x[[r0[!O2SH۫Hugڶ?K#?U%|>!)7ۢ+Q4G2?p!qz4.> $^3*jts*L9XV鲦bR>~#("& ڐ>Ҙ} 򫶛{r*`h 58X:(ŊOs 8  AAAAAA AB"% 1 X/xQQBH8ۈ-AڢKvH [y8 !p R2QL!7q 0JDi48{I%,y%yB8 H)I82$ С Bar,Qń@D/$[lD+$vbE9Q%)1KE'Y!_$#8DIC&J % J:F:F-RDTBjO-2-|2~G }~H,H̒"\H"lJ8,njk3æ`a06z :CӦwa)LdB`DP>xHL Y'`HPxHI Lqq3?PQȔd iX֒JK46QKLLl)+ `&;hɊj EiV꩞A e I$*ʧL@mSE1YKB욟{7ج7|הMڜMMlH X(Dmy`/ oD{h,|-3덆*>Ch~+;8>j ƛDj%ڜg*"LC<D#:!N:b ⌸DSp T@UAԊ@HCEGeD,TYBIUFōJN ,8S3 3ᧁCD=Ȓ+VĥT*S],U8xһ*V Dz*TzU̺V0݀ *8*C)f]Vۊ+Qm(pzQVy,xxWͪiW~WWWU=؁5X}Sm؉3q58 ۫V$X㳍X}؈؋,"I!#}DٷsYMcٗYb+Q:aZLٙ=Y=)XZTQڣYڤYsZ<ȬMHZZڰZ-[UM[][ ߔ=ژM޼ۺ[ۼ[]c \\\=\}]\Ǎض\˽܅SK9($"sS:\ҝS;5]ѥŔ<-E8}]=;*]=] ^ݭ=^\}^u^Ah7 ? BA^^_-_ 5_]_m_}___[mӤ9ۯ5&cY/n`}`-A``` `a_.aFȅ]amava~a6aaaa b".b]U%^m$f'^(^b)Fb+6^*b'b%b,.b0b.&/c4.c5b6nW-yㄝ〕c|UX<=c=c;c@@cB>d>D.>dFVd?~dIFHdGdC^dLnL+7dP^e.e&S6eVeT^T~eWVeVeX[ΕPe^_f``>fcNb^fbnfdfgvfe^fk4>c5lm*fqq6cr>gsNpFuVgnnvgp~rkg|g~ghh.h>hNh^hngh#eYh\\hhhi&i~2Nihnivih~iiiij.yv>jzf.jxnz^jg6jvjꬦjVk M.J6KFNNnk~뵎kNkHkdMkkklvlk^nl~쑎쒖lǦlȞl̮̾ll-[lk.m>mFNm^m6ֆЮjjmݎ߶mm>n6nn9mhnnnnnnnng϶l>ono~oΎo.oَmՖmooo7so&jNp _ pp qopoc6kkvnqNqqq '!Onvr&r)r*o++rVr.'?pGp1s2s3r43W4'.spp s9ss s>=;/t=#8OoFoGotHtItJtKtL7hEʭr,t-uOuRuSSuTrNom5/sXu6uYXuZu^MW@7tB?ta?v:'a7dg>WvdvgtvkS##/vvpvqvrq7rqoWnopOw!ukwLR_uU|w|w}wx{?!u]uox^wF/#ّ 9 jhob/e7hviGy?yO_o*&'=yy_cttz/z?zOz_zװyΊ:6Ђ1 z1Q(zO{-X&x{{φ]ZXV`|g1ƧV|G¯ׂVjx|Чз}'}Z{_J11ǟ|_UP|?wޯ}z8m?~iyo~~O~w~yV]J `_QV6gQX/WgNwGw LР *tA)6č;Z0ǒ$OjD 2%˕.GrL2oi3'ϝ>Ed̠Dk MzTRLuPU6 *ҭYZ4ײdHv-۶n+w.ݺvͫm5aEVuFLXqÉ731cÈY @j$ |Ijլ_ lصem[7ݾ{-ŃOn\9ΛC?.գSn];޻.ÓOo^=?/nz?hc( ҁ :" HNZDQHEI&2WԂZyiPK-QFB;#GcTFk?yvf#Rȸ+kjJi1Ԓ*ӵ{[˵{#mJo~菿ɉ5F03@6[$Dl-EHЖ)MD$[DxMP%["8  A`" 6 $J ^^f+4'<QOF\"&BRL"X(^qV".b"'1K )G=H2b29 e)Nyur/XB`p j2U- #܎(T~ x(9)rT"'"PvCѕPi#JnxI0ZAeTɢ5 b[XƘ̆u,3 ibټ6o*Ӛ8YNpN\g:NrӜDg3yR#?( er4JisMBGb8PB3o %j4cl*46h`RLM2aj}()Ip F"ib4: U&QA Ԓx0Yr,aX"ֳfM+[Vo\V╮yk\Ww` ؽ6eEX/b(SZc*{dH$r+T+*U E-PLB AHBEzA (UM֢S`t%za^TJ)FB>//o~_7p c ^^TYHCT=4ae6ER cBhJ6lэԢ iI iG"eCB\N.0Ug2ŘE`WO11a,㖵.bL39aF\69I 3B(7AjԤFJҔT,bCbڴĪP0$%F-jYb jUsywRXD-m)ʢϝ4Nj,\^ Woӛ.|[McF6/=z/5+`gO=B7gX R h-NLatpÀXI 3͠ !MV^v!f!JJa~a!aFʏ fAYB)ZE9@DED}iTtƉ,}T< [`Z`"! h*\ɵBXLGqUb1`a--Ң,ab-b/-0c.1c2"2.2*3b3J3Nc45df]eyVgEdt7]hE\`/|h єAҨhM!mNZZwZHjZ ^h*HM`mmWu" ꭞH^IIdJIJJdK6$8@*HNjXdXtN$EH= HõXZmAlPÁb)@Z`THjmT-ߊT4e1)f +%IahEy: `` fa2 aafb2b:&b>bzQMR&Lٟ-p ]4-MZLҩH, C6|t`s&]/ f`,2$9i&\B@Ę*avvR!wbvgwwgyyzgzz'{'{bKe'[Č[~'޻] $JZ$ \R!UژYGԁ"A ӄhj1:vψ "eIw ]h ii"]}2$NIFeNeeg@וEni[t\i_oqjhbd$i *ij6G Ȍ^ў f]*DjcBfJ檢*Ffʪ*Ϊzji\fu aF |` *'|.+'ggB+jNknkb볂r$빊F@B%(D!j b*3RcF"#4l"Vc*6,>,FN,fljlU_l7768` `VȚl&*,nZHOB6$-PF g*jrzֆm׎׊-TmJ/Ro.oz/Dn+JnNB///o6*rl^l: p'00KCpGKpS05ok0oo0 0p plp dp ְ  c  1ʯ+q3qqG/1Kqqkns{qqqio ˱ ϱq~q'0 p! !r"!3r#ǯS[2W2&_2kr's'O&'g$:0N*[0+_+2,2--r.+.,r-)31B's333q4#34l1[3."#r6s6{s#w386s]9(K:W:3)rt=MtOϴOKѥ2/u0#-+uS20?0'uT//SSOuV3Us5WFsXStYYkY5Z[/I4K\۵\u]5]u_;N vMuaO#P`4ab?bg'`S6?1e+tfcfkvgsg{vhWMeZujjvkkXl^ߵ^v_nmvos/m @6cud#q'd7vsrcC7sp[hUg7ToTouwW5wxk7ywyyw7{SuɪZZ_}~}fvl6k8+x3LL,LLASO%LgW8kxo88LT`x+@$wv6︎8b>$@oxOx+'3/yit'tks/wsyt9uw9rHtxxgxoyxϹ3g,hh zz# GpBxGzHS:Okxkzwz^Ĕ x/87n0OzxO+S$Axk{sgGG#9?y8ET)tA{{s;9 @Pos>w{~omދ ?{꯾~{+x)8e(LLT(@}El=+NS(@@#G+@?˿?~i_<*K4x`))-L!D'N0cF3V1UFHB6JyejAS+Ou!1H4"G9.S| 'O,LSVN T<c5[Yiٮu[qε[]y_ 6\aʼn/vcɑ)O\᝛9wthѣI6-: ,ipJ.iZ շu;T۶$FjR%@j֬]<282A;ud*?+Piթ߃Nq)A+O ?3(H@@rp%|p) !ܰB1PEpI<A\DQtQe|qiaܱFqQq ,I"#t$|RJ(!/JO021b J&"!&Y8!ݬ\s " >ssJXI b%SHN$(0J @hT$h)JJS@ˆ T3HJ*HO1Ó RK+: *\lv+gZh}i6j[n \pqU7r]]v南^x}y7z_~` V8^a%b-ň5ޘ9K8ukG6YNZ97P<и* JA;> 9¿p*g8!\dDQJARpS~=vmf{n;{p'q pg|q!O|Ⱦ5or)EsC7}#Gu'oc}uKte}vi{'x J~(͉!CzJ M*Nt*RSH,'.hn*T0YxHp<]))*1L+i P\A~"ApZ< %BTWxagX9tcHC8{D# H &Idbx**N"(E-ZX#E2WTcxƲ@qcƞ5j؛FuzM sG)Ĭ3x> #iF$H$:A{(јN']aHA@!1(m0P~PO;4%4%_XҔKasL0Le"ϔf.5P:ZU 'VL nV6%3+ 3|$yJLB Y'xiʟَ6mw%Pֲ X;WZЎw5hWKZվumli;[ۦ-g[[V n+\7%q > Bw:,{{]io~ V+)~)&h팩B5J U5T1H퇗Qpf`7p)>>峗Г?^s5]S@a3@ @t@ TA@A\SB'69/9195C}CCtCGC-DIDKt9Etq(E_z-rFk-GϒFoTGm4.wG}TFyT.sH{HtH4IHI`J4cK?4KSK׌KTL4LT3TM4MNTN!tNߴNNNNOPDuEPU4CSTQ 5QCP!Q#QYTR P3US5>!/woc=g59;7>I⥴7WュSpm>^7v~#'%>v~n]꣞%^[뽾cދ^yo~^~~>^^tiO~??f^'_+?- m6;?~6fQ^_WMdo_s')?_bEIE_>SAE^ͿCM| L @*\ȰÇ#JHŋ3jȱǏ + RȒ(O`eJ+[|93&͛6sY'N:{ 94(ѣF]Z)RJJ}:5*իVBZ+VZ:6,ٳfӂ][-Zj6 Iݻx˷o,p0a V qANjCnxʘ%g>9ϜA{MzǝQV]ȭaZ۶sӎ{ڽqwqߩ'pʁCo|إg7#KӫKKˏO'`h`` .`>(aNha^ana ~(b g( TA.hЋ46ֈ79D FG6IFTJ VVWvYd fgit v%|矀[rj蠈¥h2衏6 NjFir饟v >詨jޅj>םFwRW+ 찾Zlz";6kmbK^[s+IS;+o.J;0k 0 oCpS<1; g\kܱs\l+r0,3 Ӭr.3Q@-tDyw&F3P7RWMNg=Vs`wbMCڪvpqjw׍zMyη~n?⌣FέR.WwZ{^:ۚ:ޒ鰿.ny8O'7GWoEf{gއv_o~/o_'LX~X9׵NufWA vP`28Brp$O|@IneMDz\ƒݥDjK2eE5zэ6hHA((UWz..%gRt8EcNcS/MzSGiQTu)jB۩ΪT;jլzu_*X*6I hAjеpe\:W5t$ZM`J؏6Ea=2H+-Z6̬55zhCKњj V%ke۲ֶnsku]Kܼ׸Mnq\"W% XNֱ,ekf7.vztk:uP&վM{޷/xp,T:zq;V8ΰ5 {qMU\x06g8wͱxk<6}cyFehdɨL)[X &{h7 g.3׬6Vbxcg>~tALUDVqFы2!=H#:ɕa7( uuǔXoNq{_:.5h׾qpI>w$xqϼ{{o.x>?'Kd<'/S<7]}Coћ^/==qa_~hw>*7|xO~ݷ^QzkCׯ~շ?}cߗ~~/O=zջo?/k_)y7xW|˧| (|H|xy؁ " ww-0Xq%v85x6z6xx(FHXIH|xJ"iW~WTxg~}Z؅V_H~`bX\&Yl/Ȃmnolju:؃<|{ztXGL8 xNKȈMA<8؉=xrtXsxruhȊ4&ׇ8Xx8؈XԋGg7[H^(՘8hֈ߈ۈthpx鈎؎ShXH(Xpx8(IXho8Yy nv!9Y i#m؏Ȓ/ْ0H) yɐ 8i>?:9X&਍I9؍MJRHQiLYhֆ)_I'9"ieIbiV\i3ٖ21.9n9Ikk@Bٓzِ;~I<9Qwi٘LYhfYy>IhuIp)r9ɗ{I}))ɚy9gS YyO9ZIiA_ٙYY^vٝIɝMVٚi蹞yٞdLٟa4fyz :c9 Ixՠʞ*J$*Y*bY*z+)ʢ0246zt'` Z>?ڣ@*;^ʤڤ1y&J%X:Z#WR^d:fZhzEn:aC*EZƦmJ Oڧ{zxZ ڥj^1u:8-کڍZrjtz:gW|:J)Zڨ\_zʨګʡzYj̺ڬѓ|jغښ=ԺW䊫 *JzV: ZzwRխʭ ˰ N U:暱˱UQծ" #;zj)(%0;2[4.{R۳<; uFGoIEJTQ{%˲ K+_+a[hKj8ʵ$> @;stgnVHJ~˴۷tSk +[ZO7k;[ Mr۹wvu[M|{++L;˸Ķ;f ě;{k ƻ乢 + ϛ۽JK웻 J;[{oHۿ{H{߻ *l[|lCk̻\"% #F;2u-F 9 7Fܾ< ˬ˴<˶ʵ˷,˺˼˹ ̻<Ō, <Ռ, <, <ͩ=]} =]} "=$]&}(*,.02=4]6}8:<>@B=D]F}HJLNPR=T]V}XZ\^`b=d]f}hjlnpr=t]v}xz|~׀؂=؄]؆}؈؊،؎ْؐ=ٔ]ٖ}ٜ٘ٚٞ٠ڢ=ڤ]ڦ}ڨڪڬڮڰ۲=۴]۶}۸ۺۼ۾=]}ȝʽ=]}؝ڽ=]}=]}>^~ >^~ ">$^&~(*,.02>4^6~8:<>@B>D^F~HJLNPR>T^V~XZ\^`b>d^f~hjlnpr>t^v~xz|~>^~舞芾>^~阞难>^~ꨞꪾ>^~븞뺾>^~ȞʾAQdP @ ׾IPH A ޼R ͋I ^ @ `0d@~ [ R@  !  O ^=`͋1 ->1 [ R@ ݝ0OL @U@  R0h!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,{AH*\ȰÇ#JHŋ3jȱǏ C I$O\0%˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJaWV5UV]@ٳhӪ]˶mQ MPDu[/At[L*7Mb.2 JL˘-9jg :1qLi5?'j$C0mbvJ2zvi N Iƙ” SO`}ϩo`. ]P~7x~ wzA)aig5F(T)&N\ /B $ zHA ע@/.@f2 )0u-zB9!أXVif 8[/$qY</f#p`Nl}\h&tD^i *Q@`i B0)P 1{5)|\`*fXQIL ZZd=JQbjBp뮼:\ ᡙxBM$mA qQL BPjLg'(V2zUJ.mf$U.RgFHOknJG,@e,d(1%(,Т9^CvLnA-@qW R6+qCXhd4CflSW\wuDtћ5x7q@[ߩ<[`Kж (C66d&ŻE-Vz(ڳNA!Y]+ٳR&\%@]8mS; K`UXą橩x=v(sh<$cr#يbԇ EӼ{mցivdd%#H \ ,3"d@G]l,BRtaǎ3!I$ŹL Jai "PvRs`S<_L1쮘LfaD`aXT"6,p&@HǃŐ|fԕM]nKr#G͔{l R۠JbX r4I` ƖOH Fa8Hsѥ1:7DHĘCR9Mg>+|󝭍&t`U<8t? \&gPP 1IkBtaeԌǸolw\oNoe35HMk}d FH_j :</z$-3@}85Y7v=.3HH rDV@u8; 5nEk} ^ZE>W݃rV*`6݋!nm=+&jpw@2luH/j!=8p. ]358xe 'jbbkjf&kd~bPAFP͎}E%JatR/$&+ƿoYmϾU1t Ҳ׍j=DDpsQr < OV&7<uzpc) at(&TRzP-POt2Vt?wE|(&T xvcd7~Wqof@Cƒ!8~ѵp9&rW~;o!~À%efwX}lmFA f 0(;5{d,rW"n6&8Gl\/itv\k#$s!B#kSid$10G!j#nyQbX0s@6xC$wW'!2r-V88ւݤZ rWzֈ'UncTdq\r=d9ޑVx=}Ҙ=m m`X%"؈t.49'@Ljsw#Y1]=k*Uwe<3#{%qRTD?spX1e~h+Np7IG-(q)y.Bt-cAo'OR*6(.xR48IC5Atb>t#aR)X>bA A 0eIxwwLXC'Lj7}EV. `"vGQw,%{×*sfRiGw\SF%u7J&@lF6%@e4j2XU$"z|/sj$,w8vGB(Q/,B&$|D&=GC/k0 CX}zǖ%%)8g1d$H™';3To"$s1Op^PbW(`@Wbgt{2SÕa |@c.ba*h5}d'w2psf38v^R%FdQ5e u3~L  \.1U(N'mQ]9ȇ1|Ch4m}&k#&kX&/R9qrӢkBC"%ib:#&sjWFGj #ftItX"iB)$ls}$Aq3hٹ*5:i:fu㖀9i"6: ѥdXaS䨃6uRePjFYe3Me~eVӐaM0ILpS֤Lb+:4r2$214YEdP5s_fAk'0ds1th9*<153%bo1{[eIRQV O)pᲴ@:)@]ЩkNGbpx #Ē13W?IH9ڢIBY#!{i. qs$˿ 8n*cvc|$k<725+_h\kk&-"J[9KK8ogKu9|A.&ˢW9$] Bz|>2BӇD#[Օؙ ς^x$ )M@xݕ1 1`ik0l@^(^IN[ٮ kTpn}i(=c!5 {ިLYy̙BD1d-3bI  q`iu3ҕ-#n!3Lrf@nfq!h#xDem&B%Ҧ#c(pH!lvxWR̉TцfϔPN 9Py?lp%jj37(~oY #4lE8R$3GlVR_vQ3E|ǒre>?}&Jz°6=c9~V;,v=taf xB3e q@ ;P@]2 MD%SLNzIP2b-M11 .#EYPf H=9! dzrYgͲ(A J8Q54p *-򌙇j2l3Gge å?RpR_)4sj#u6Je.s't抽mXpōG\r͝?]tխ_Ǟ]vݽ`&ʟ?$4n | _Yp)7GO "2<ϠBzDs$02^JID 3ILRb,H + y`RJD$oxa0)G2 Sˍp< J%4|̈(ҿ8QʾE:iܬAB4RI'RK/4SM7SOUji7zK 5JTL+U2<) F}ը++. :%F3$)32O@ƈJ'B鶷r36"nH@Wp=͌. iS6-Ei6SE`b*ne 6dOF9eWfe_n<):M3% ȫO<ː وTJ:=az>% :f 2€3Z(TbfCI@.,@UC&x(nFLBG[3Sj̰ WÔ<\0._p#O2HVOR?4O=@S@8~8Mef%srFt'Bfgfgy矇>z5TA*TCZ誡"n2gVSClخ,+#ܚ^Iqʈ6|AKTKA8LsT Pe*KY,\gG  |^ E"Xl {Y&P6 ` ,UK TJQ|sU?t!B1X K w/(lBgzgDcոF6э ""9 (q2#+*f#qF)iГpF2%#\ UG?f uFBđ(L.t %()SJSR2h.  ͆ "ճ.*+rKVr,C KVĕ\%0(PPLsB0/i$lCH֪)4%2W,C3P0 5W):–nըcѢhF5QN@Bz T H'pIp g-2j; JW*R.2j:!u'5][T^vT#*'f)j U$QEjITh%TN}MպUԃ6+WնƕvP;U'ijȀQְElb9:ӡ-P"<N;PUIض'`i;gqک嬖lJ<;Ў.IT3*qjָ↓Ifb;]V׺NU]"ݺ)t*qn]jR<j7͊.v1S^Fʠ0Fp`蝖 PfN%6g UpA, fHN a(! e ʉk<7яt|E>&8oaThZ3lPStUjV-qBd*ܛHa5]jjWFv) P|Dȥ=yNWsrY:%vE8)Fw-Ñݷ4ء9 jP'd\Loݛ^w>p:*mgx%>qWx5qwyE>r'GyUr/ye>s7yus?zЅ>tGGzҕt7Ozԥ>uWWzֵuw_{>vgG{վvo{>ww{w|?xG|x7|%?yW|5yw}E?zҗG}Uzַ}e?{}u{~?|G~|7χ~?}W~}w?~Gտ~?$4DTdt $Aē3{p{$-{{R(-H ld$ABYr8I[ H[B{+Bغ%jbꨳ/Бh{hNTB&܎YBHCA kCAN(y9,K) RA-$0I-8ˆH_ 'Qvhx H$p) DI0 (7lWAXB)R2E*S^$h_h)RJUt^d 0f|F94EĤrF_m,h lK2{JDJL|y2D)H DRAB{0ƶ{sT4$aT`%4TԏAG"wt'SiŮ|GDSZTVpryW`%ZD5G[lI2hжQV Zl|Ÿ(G7m )sShVHT_ĶR܂TLի,D5~ԈU6TE T`UPR<ZY _AדK$&D`Ȉ`l1A>G%$xGLLi(lE:tҀaMՑrSuG>^%GbY"bO)g]FheAM-d()f­' zڔ-ή0ɭ<5EDfWq^E&5.SEchg}<&d.h{}hAl,VtaZHHTVlĢ~ܤܚ/DHb*ɤv@| 3fA ОȤ",IK3$ v ,j&eY!> $pu$(X)D7**Щ6$ NjB~h4N]Xam) IhL^eFtD2Vb N`*l,l,T]`4,瓈e , z a|NKTc/bl FT¬pn]TSMR[{l0 SX}. 1ݴVGB>POmK4M SCBfdA,FIN UHAP,|lobF@KEur(TreSK ׏BL>PjLosAԬ CFȸPals*6ϞuFXX}YY07kLb>LHDƞe Iwr6st_)؂x݁%[p玦M~ftRbYM`<|]U%<Ǟ(w^`.ȿC*_[üAƎ]adtαL,Ye]EI&+YLuXư,^nHh-bH=M^ggrwGFu$-VMu`gTnuٶ_YEHWNugRܦTmpO=aY7 $vv Z]_x 6QQOȏh!DTnjp@ˀ&\d&{hGqeDs2" f6~TJWEK kdS.{Cq g rtcX˹h(/?p--'ՏNa*w_ZxSE8ꄈMԉ/TD''&KӣVTA}ɧW$f T ;W&!||2?yo}Wk HkME{% qD[ ,)0A&QiRaJ0Qp ˜.wɳdM*s]ihŸJ2m)ԨRRj*֬ZrB$Hk@( [R(&![֞=2%E A ԒeHZ)76&IN(IhT)mB0n֯@񫶳8'vyP1- &[%%|@@?Y7C3t3?܄Eo9pƓl%@\%P+"Wc&ID?ueIxk^IEImdGJ=l`"sXlH"\Kdg_Q(Pg!pXA(!Wfٙoh2Qc\Tq8qGf%g]h&׉&ʶ'}' :(.{&P|B-IE!ǗqaY*JQMAiREh[ IS4צ<K@-4@@jɺTRu d䓈M%酓GKӳҤZ?d$֯/Īl;.Z,|D)@Pj+uhϧ5*DCvض'U9(w@$I;PDjLE&bj[LWvERN DA HQej&?-;2DSS]˼δ2ol(y7}*f$WZ-Fi]CGѣ!^}$^t~12aBG'z)_4?#^%5b8="fgqm$xZ{2::s DW򠅬<*fsAzhJ8^;CK@I*md*`_) !6={DյYivD+IqMy\èdJ'1 fXF;. 5|2:"ԴEцt 4ZnT2=+N&ἔĩ8n)!N)<()RVDGjWX0E22֝BX~9Y],5'it ShPdӢ\TԾ M6-ˇ!t@CL \C.t\v #OB +˿*~e (2PG-($4) L;th˅p(;aIɒ:gZ|٢I>HR|rʸG\,a)W)%[ E=$[ KfIXkTF6"HC*ґc LF)!?:xvtȃ`pH ω@~ lb׹SmPc2b@zFӐNEVؓ!ۄL3yq@Ie'%L;i{ @>B+AbKX]$2s1лF?Na*L%g6#K xL0$ r$T#}jiVqV"æ'PIBȚF͏b`\R@!%QW!RI[x $/7ÑS25Oӟ.$=S_=B'm֪얯¯[c;V̆,ŨvL3  T. dHq1,MmƧ%Yz<-ڱ$ hwV10);)U٢>4ꪖ˓ jp٢vlfSp"7mGfA*m Q }PIXu9nCqI~*('k,"$^Bc63*R\+T'WT1!y Ŕc V#kWm)[ 94H4Ȓ%N7=$3r e!a2qtwQ jx׆zEcTrR6'QMF[^=s_Q~0-y?!IҲˊ#`k cs/JXw)b>$@ 'E\m#GJ-3#RcD !5A$/4|]xR2yؓn~Ipőa-{FCFUډ#fV{'r[s#VC| X@O^ πl pT-L9 M[u7嘶0ЉI;# 1+0b>dY.Y:Sl 4&*f`ػN yO2}-)pVUXd KIGS4# _UqO_\)H L_6*)awJkuUW>N8+ u_Z-GuFU܌tMDq0]!P|R0 ҀFюKI` @HJRB`|) 1IT @}ߓD0 _Y.`B_(G@fV`mʹJN{ҴNJU!ߨ4x4RCaQ[ PL!""gDiD JE$fE'%qM,( "1rE&&0֢Sc">#4F4J$*> tl_@SU4M֟\)Z4Pƀ6_xLqr#c8>#?4amTBn( 1’#"m]l8AFp}U@ <.$JJ$KM;LT\F$N  CJXE.u %L&$O$TFTNTF1xHUFeBQ.%]W>&%ZIq[~)FXZ%__e!``"`a`caH5,aN&eVe^&fffn&gvg~&hh&ii&jj&kk&lƦl&m֦m&nn&oo&pp'qq'r&r.'s6s>'tFtN'uVu^'vfvn'wvw~'xx'yy'zz'{{'|Ƨ|'}֧}'~~''((&.(6>(FN(V^(fn(v~(((((ƨ(֨(樎(())&.)6>)FN)V^)fn)v~)))))Ʃ)֩)橞))**&.*6>*FN*V^*fn*v~*****ƪ*֪*檮**  .2"vdV.EFNFV+I`$n+!m9+^n딦!OdYאA$`rp+&kkTI+FBlfl*,_rl)}k="Ig(kFv$A+1Pk+j,E%`Fn6`zʦы ѮkIt˒^Ѫ++땨,` A~ED̮I-έ_FᴤYln*אbR=R, " Ҟ+F\NvDF+ .+ߚGԫl$֠ʦƬɑ.¨RJ.q.kj-k׾ʋLѾalLອʒ/.B n(,FFট®zkr>&-0N6Rl0(.U-aG/fo .ޚ_0˞E~l*B-Foj Kn&W10n v ~!ݒNocT qRmD--kln.U%/_1#Oh.ڶa/ Sr,%lH˞\qo `#~{$&oCn,/tnR.Po0213׀RFrq'.ߪIoiJ_jkF8303:k)[/O3W]3P)UC^-qu4'Zh(Bs؃q%-FLM?OLA)$Tp+eF'dvX$v.Uq o+JTu"J(ϭQ9u9kCqq7W[7uu )Xp 7N+6'L,7KRWEhA}@w6I}wH~7~CK~(&{xqVtS-%SwRT8z7Lk.`|QSkv{[3xP#Tv3GkLAPA|OAx+E-w;ȴ)×BO@l+p—B9pDYCrKxrIAE&&D&̹GBIԲKKH(*ȹ8Nr'z8&0)fo63RLAuLT{yuQ)4˴+09t9|t9y9l:{5BcD9:$L)y,jNBz:{ D sB DD+y( $(h+Gldy#v$KXhA$BO$h$WF[93l+܂wTp\)$Ovi6mK50 O'w$+;XT4''NoPBX 6MkS84z|j@$=ғtQKu'tXH;T+wNl+ )3E@=8HxF۷@h DkJSwU1EܤWlA)XtSSHAmv'z zޗ;cqGܛ_xKzqWs$0bj$~\}z}wY$ܻ8k@: wRkA@J0`AI"%`C!L LB{Za$C) [b0AɎ trISTNQr`IJMN81쨤T)NPKIq)MN:!-J^@ʞgѦUm[oƕ;n]wջo_ WJ)s#nrUiA)a d(M NM|JP(H &diQ"Deܘ(%i QJ;Nk/TM[.٩2-;*(\n9DSp)WjRC.殟,lCdR S;__ P ,LPl \) x $ BxM$"04͠1eCbUV`W{mXQ!-[e-H(h"[l!)-H!RآZA+!R"*'˰2 sI4h Nr J"!J е 9݉HZ1JP$2>SQMUUYmWaUHV[i=(] p vWͺ2cZMHw "g}l *mJ MέMlHdQ"#$Β"]ZL[ڡ%6)e+s "O0 c)ʁRH<k`+ϡ$>V<>7]|UYnaYJ  (j "7T$HlÜ`vGf(`%j)gBQ tTtk-IiJV!l6])֓O@' e[8Q%J5~$dmo;?WkI^DԕL#BTѵ²Yy߁^{Hr&^Ӥ!de7Z5Ru)o;j7olW GkǗ-[s1}3M 3Ct.RW9uEd?%'< N1'rx>!)L)Gd+8DqB  ͒lzp1AI!#)bDwb 8F0'BuV>ԺbY)LA$P!TP$w(Q?A"ňv|SA0)_Na)3HE.t#ҫdtC8;Q "e1OsVoi! їCe]i"!?S+cGrMA`>L;>=mk{K$A?΄|)WB49љΘ,r v4>d&M3 V̰E$zMG q!`&4H XS&V`rܴpMKܥ„)&0,)) 3H(1i4M'hGJ 5c !!I!OרEj1KAdWӷ,SYњV4E&nUN!Vt(D@zh9RHbXf@.%i2yHRqE l,VLmDZJ?Ka}ӉR| YLqӂ + '0#BvaeAmƊV\SPp Jemu]풓kxel&X` >#ˈ" O#M$F!zBbbvXT6k|D%[)PĹ٩sxqbÉYŜ(TNwb3qQJhC ip%5򑑜d%ʣ*]4PwaA$0җeՙ&KTH{hla$ DWJL,M΄@nmm/6l cѝnu[fZD|QSvϛo}p7p/ w!qO1q%Z`xBՉ5Qr3{ AW2Zh ʁtwgbE +jP?7amo]zZ_|5d -h!a142O_UDF EKډKd!bwoK`5VgLӳA`^VEF1ӏ"[OzwލJkAhh8}(Ԃ>VA~[ N| ? X0z!lmXo @ #d/BX4@X@H bx` hĀ " XA Np4J .8~nupyB1 %d "`7<BƏ ҏ@R/ K"` Jؠ$گ@ Be7tz0pM<\Pt",0 ja HxS *[E`- jP"P r@~M M1 hA E#@0qy1`+b LY'p`WkQ|1q-X[@1-pvBH7pODPo +pai@C Q h:1!iA , bk 9#=2oJeP-F j +aQ pQm>'}'IпZ "j !Y `=. r++_&$!p#$A19 Ա$R)k& / sR+/e쐕 QJ𤼏ऴ"1LL`h(w"R"/A34ES@ƠHSqdQhr)D$uNq Or H#s'l48/`iJP 1#1-$9-ת%⿦&f *;9cd-9s=-$>% $=`>$֌?%= @ @4AtAAA!4B%tB)B-B14C54~EB:h>TDBhCKtWDDSEADGTE[tE_EEEmTFo4FuFqGwD}GFHtHiHHIIJgtJstIJ4HKtKIJKTJ˔KLMLTMtMߔM% `C)DeOOO4PuPP P 5QuQQQ5R!uR%R)R-5S1uS5S9S=5TAuTETITM5UO)nFWUOVe5ViuVmVqVu5WyuW}WW5XuXXX5YuYYY5ZuZZZS#XUT[Q5\u\ŵ\\5]u]յ]]5^u^۵[%nN5LNMUN5N_`L `_vaa6b!vbbb5`%c5b/c36a=6dvN58e%e5favfefifm6gqvgugo6e!U#2Jhi6iviii6jvjjjVYh]vgɖgͶll6mvmݶmRQkCIVLKNsd;cEoKoAVpovppwc7pp!q%q)qq-7r+bnNOvt߶tItM7uQwuUumsVjL!6kmwkqvu7wyww}ww7xiv]}uuyy7zwzyrtV{57s'|7wq w|1|7}w}ɷ}}7|}7~~bO{] 7W x gvuvMJw)-81x59sxxUP98XYx]a8uvnh]ᗇׇw~嗈XxNID͵F9b_8xSYj$@=8x帎8mW޵ x 9Xn#mo8X8ؒ)Y1Y9y'EvUtٕae96.u }9yYؖMyǖg9yyPgX͆y/9C=ٜMyY@7\y:zxX` `蘍:!z%ZxymeE!=:AE:_/CMYLd f՝噦e9ozumڧspQm:zI+:੥Zy ):zź:Y/#ֺ:zך z:z:]':[4yz#+:);;5۳C[I;(zO;QSYSzmq+ڷz.ߺ;{/RSۭ;O B"{r ۰;;[6{jCT0{%[۲A| o\%]۱!$}>ow2}-']_+=[ig\Am]+3|ց>gO h ~ŭ~Ić=۵zţ망Yq~aś^>6ݹ_ʣ#ߚ}ޭe _ 9=ARۼ+ݱ!k_9~!Թߓ^~ޯ9]&??C]ڳد=٭Qv@H ‚`p ĉ )|H1ƍ;z`$H,ʔ,Wl ̘4gڬΜm:5ժ[~:6ٲkӾm;7ݺ{;8‹?n<9-;=``v۱k;E*>z{go=y OFSH- )h%)ـ.1:HaZXjazb! H"&182H#6>FX". Y#:#?dL* R:IJ}eZnW})YEdEd{lff^']iIsu\*hjh.h>hNh^ie]"@ @HMiiP٪)Ш 6X2UW +lkl.l>lN+mSmkn\t}mߦVNt.D&n{ /EΛ/Qʯ L '(> qfǟ}goSq!{\% IM 1,s49ۼ3/Q~{ p,*p\zrtG; ҏ4$I5JI!ncK[ ӗ44Mk7|t%I} ԟ 5DQ9S9MSKJqzӨBuR*Ujլbu0q0,KE-QjִujmkmKu:1 y.LR**4-MMZa!?H0,aے F[,ezNvͬc9YҬiäZ\3(c,ǭ mlK[6ݭˆ)7,^K SO*Lի@WY~2Vu$dwXwǝ]r[B.W^|.ao~ŕvm Ino syð)1^c=/w w;uxѱWbyЌ/tmh-g!"r9I2)y[=ԗ>_H+LMcZ]T#q.^m_ s.߯j|o^zm^Z{>o[ T'urm]f,yݑr+>CnEm{+6mnd\B*)q+tR*6pk76r֥7"(f%y!x+?apT^u{`i7qC8vj>(|ȷ^i)94Qg0s~u T(`([wbVw9VvQ}:qhv7&.:icc!Ww-&cywzDozGekGf4$^2%yHzhz؉ȉf:hKJ|{Gc犼g}HF}E(A|ՇxX/{i|Hs&~wkZX~tb[| lx&ڦ"T׎ !Հ4+h'*a-R$ȂU%VTɐِq^&׃_6|qr{zaB(|s҄bQ.Oh בl̘kr69uks_XlօܨڨV8KbsHSvc}xw!wr'c_I'Wcm(Y +WcbqvFf$v#=TB.b>#Ywz)fCy#cywy@5x9Yy #LDŽƃ{i}G({XiÇV'gh}'Z7~;i~H`<:kqBswsҸɔEnx\((*6n6n 878) h5, -){GQRd&٠c2hIP(%#-5.G!Oޢe%g.Av8>i s4:2ڣ1s+ E]'~)"Gˬ.]649fnVM(* [L FR nWV2_.a>GmIK ۫o.vpNe%>n@{}:^?N$hFڼ&揞LiSY[.UeA|H@m+.>^s.~1_`|W+˶1#qJ~YH$X~|.~n'侄n.i`Q/\`߼!P` ^Q @ lfNԴ^0]Q7pU}rY#: 0pP : I7! `iۯL6??O@7]J*qGr]^Qr6^\P JD0 d][[`p  _(~JwnvHyڕm)RyrJUɕ~}_uwNX  >"о" !&&IPO "!Q@ ^` S 3057,ȿ.gKj7$'m㖒q80bcPqrgáR0ZnQS$HB)S&u*WjҔR 8b$R$I #HRc$"?nqb!-e(M9V\b$cОH^ qӤlٴ%-"ЉM+,TQӮUۖ[qΕ[]yۗ_  \aĉ/fcȑ%O\e̘lgϟ7Mћ1î 6]4g҈i6|;5bߝU&zu׭rJqӔ$ܓx/aIS\ߞ~C%nըQ"pBV6B@h:$dqEE -L" GBGdE k2.)lqe F`FnѺli{l,l[${٢:\$ZYȠMG24L4T3M6tM8ߔ3N:紳N<3O4O@tPB 52BQuQFQI#etRKk4NA]FRR'5 GU% L kRIefMu6]#]mPJᤔ".+cd")8؆vI&l&ѢQ(Co5[Qw1*BBs57pǖMܒ`ERk""'6Q(-l R "3ȁj-VeWve_9fgfyg{6K&h襑nZȘ.z#0ZW:N{ٖ5ZkYCjǞ6],+= ( b bô bP%("QoȑobB < WAsQNQ-)ě>HХuCz"JL*K0no/-~jR)/~cl(J")bl v*ú_o񝗿|ϟ׿~_ x&y T^?>Ё  @ "P7x_M#$a MX[ S+L!XBEIƅB BXWT8ԪjUE}J9T]CWpUG\ ÞL 0W sbY N'.E. YZ"A-i,182q\Dx3KD򨖢%.?9E- {k\W0`j9Š"Agd'9IOe)rBTRYrvҠ9+W&4a w甭3isNl8`Sf.Q5TmA1OAh)wt J%`C)RtN(8RCHX]USB`P)p( @G.O?I [OO iAJ #ܤ#F ^;Rv"R')CjQzT&KUjS&?RSjUC*JY媯(U[RaaAU^T[UTʩoEk\jf#Ǹ) ' [Xdd2G ĸ0b.D&CU4o1^WgE-K^\$f+Ea5 lad`~bNE) )%WT S&Uns\FPU{]kNw]Qjn;av^t n[[R 5ozf6}ݯbBI4_NX IɕD=.4̍v!]BA(]1ZŃbu!|\@mağF$a0zM) 2d{"}2l <;l x$0-c`3h6sf5w9_^႙]LjL !ܫkG 헎 YN3{5E9@F,1 ZŌI㰓̪),Z,*2-;7lq<-eĕzTXݻgwy{H1{]>M]~昩wM/kmskx_ F׼tst%q)'g+? (s$؂v@X #,I83S$! ,t)iw  hs)32A? L B!A$#T"LB"A'\B+D3-B.,4I4A3?K+OPq4H۴5|![¼;k!N$NȢI a"Ƀ>Cf PS->YF QljME( YK ;Gr)8ECr6؄޺ G-+$Pq ` ֚>a FbFc,FBe\Fr%{{zFkYS3iZ&aԠ}k&ߐo|p\ [WWw$wrA9|;UQL 9@S2['LD:Ax=-!#,3-X;IIIɮ4fIJ"JCJ4+Kk+lJ*ʪdC)ꦆV i#Ixs,9xaKM;MCN/"s&:ZMMMsMj\lT/B1(1{}&䟐((@1QN9, 3j'YK))<N1\'3 4BOOOO PMMP"BKCԌxͷM NLI Pѧ Ѥ`# i}QQTP~sŷWB RЦ,Ҕ0܉"@0:\R*R+R,R-R.RI! e[;3-S4F1 S}>HlӞ8Rxŗ:SS>S? SZSB(=%`ZV`mS%) ܰ]ٍ]\۽ݛ٥ ͏u:^*B*RH[-0t͎][]UZ_t]]fO ]Ӆ]"=@\`VЄ\=UR(R)8N(! %`qGSX .SP FX&sV%c#HkMN1؄#euUQAV`Z` Nd5&-{6P"sJ-f@]V(i_L}ccÕ89N$c?Q\.b0*rn9')@bqYdi<.𐄉31.X'C.!SvhPK_ -ZZcZP؄ҥԕ:d:eVV_b_e6_f$%A_l`m?foM-_9Ii-.'pG.iN%zrr/") - X`2 `%91)5A-][ fՙ>}тe/Z(Vb-H-(% jqjjKoNj7m"I^CKU>L% ENX#$qrAE`l_ѵߤ\^ݺeVX $`Z)҃6搐i6$mll]jluc=^Bwto:x)p.X'q܌G~VH,%P1B4;`5`Μ['>(F2 ] iR"])n`6N^;<.&^oEo^oloj5)+KvLMNL~Lބ &̘ҝU($k] ^6h58c歅 LjN6)b!ZllSqOp.r؂8]}+gUm)MHRHm`)a^ 9c;K][A$ {n2?c4j tAtBG*qDOtBf%MOI.pE_.V<jNk0Z Ih<  gQxEkě1 (%fЀZf_ hqdqe7FVtgvnVr:jsMeؾ'$aڮrӟ@/QV1InnZfsiނUu;nigcxj=Phx@{ahNh^rG&u9ox=q[-[N&I(XEHaT- h9f &HU-)5IZJH2e.Ytr̘0iތ9SM=wZOBsRL:TC"Zu֮Y+أc-۔lڶfJ`ݺvͫw/߾~,x0†#Nx1ƎCgʔ/[ΌyΜ?{Y)WD+P¥+s]##EI\ٲT9SM))RnY&S@C2BZZ,G UkURЪE :5I̐{ jEiɦ6,ϊL|imAvA=E`z * :!JX!Z!j!zd#X'+آQ3X#7ژ# mI84$\ 9s ȨZȥR[\?4M4eLSl@(ccl2J lRh1FoIa>KmRQĩŜN9FutB R:bzr:6񢪫ڪz򺫯;"{2\BR[ZhiK۪mbuзneXV.KS璫nʫ/ \pl0 pDbs15-r#\2'2+2/\7㜳^1s?4C ]4G4K+4O;5S{Wc[2a=e}iȵo'6Wr,tٝw{p׾#É8/8?pKyS>cwN袇^:ꑯ~9Nvێ;߻>˳ͼCS?&=={~@>unLR?g.S B`az 7",h›.Sa W, ipcxxBdKuB<SG#1+D%.{"h:(Nъ"X.V}?cdžw<3xi<XYY8;1xܣ1+ؗ>2\d sF*|t br$'7N %)GV2jJ5R<^,hWBFw{G_ &1]"Vsa sg0ΔCMH06<M|s/gE-2NT' tsl9 OwӞ=I~..L&AY6" ]#CH5>&K1+jLb(Gc6яHdHJȒo!>ԥ]_FS=ҡwLyGҔE4e)Jԣ5H]RԧZ (8Z5X}C!x~*_ZAPХ`EUYѷn4p\JWR5%4ּ̿xW aX´쪂O~f;Of -g?kZ҂v'jO59BiЄvhgZ uNU svđv),cǙ޴;i9~yÛSS[^NMo)c=jo}/[hyn0Mc;^)K:f괫-V. vC/i6m{y[w7;h96 /ZXBrirF'lf7˹3;Ng!5^r|e5EV0wdpޚCj+qHhIn~;^қtc{|fY,cy[3SN.qbyʋ?||?őoCW4Ozپ\YӸ|"У_O?}hꨎuڣ>vc?#>c@#;@£@#?&$BB6$CA֣DBRCFEjd3"AŁ}H^HHvdŗ߈ݴd]JLML_MdNMNNdPdOP¤Q$QR*eSeQB%S>T&T:eUb%OZVfPFUjeW"eXWReYYZr%Z~e[X\e\e]e^]O_f` `faa"fb*b2fc:cBfdJdRfeZebffjfrfgzgfhhfiifjjfkkfllfmmfnnfoogp pgqq"gr*r2gs:sBgtJtRguZubgvjvrgwzwgxxgyygzzg{{g||g}}g~~gh h"h*2h:BhJRhZbhjrhz臂h舒h艢h芲hhhhhi i"i*2i:BiJRiZbijriz闂i阒i院i隲iiiiij j"j*2j:BjJRjZbjjrjz꧂jꨒjꩢjꪲjjjjjk k"k*2k:BkJRkZbkjrkz뷂k븒k빢k뺲kkkkkl l"l*2l:BlJRlZbljrlzǂlȊȒlɚɢlʪʲl˺llllm m"m*2m:BmJRmZbmjrmzׂm؊ؒmٚ٢mڪڲmۺmmmmn n"n*2n:BnJRnZbnjrnznnnnnnnno o"o*2o:BoJRoZbojrozoooooooop p#p+3p;CpKSp[cpksp{pp p p p p ppq q#q+3q;CqKSq[cqksq{qqqqqqqqr r!!#r"+"3r#;#Cr$K$Sr%[%cr&k&sr'{'r((r))r**r++r,,r--r..r//s0 0s11#s2+23s3;3Cs4K4Ss5[5cs6k6ss7{7s88s99s::s;;s<>s??t@ @tAA#&i؂=؂44mΐ]^EoIL4C{th"H4)lIgP+&= KC+$&4N4l&ADtMPf+tl]pR&lD'A 5EK5k?lzӓWo=/>矿~ށ` ":(Rh`bhav~#VX"H&02H;#! @[lRF&J6NF RVIVfZv^ bIfjn rIvz~ 蠂J衆ƙ1hA 餐V襒Z)ri ꨟ驢*j 묯ꭲښ+k 찿,2lF VZ-rmۆ ߖ{Ѻ+k,l' 7G,Wlgw ,$l(,0,4l8<@-DmH'L7PG-TWmXg\w`-dmhlp-tmx|߀.n'7G.Wngw砇.褗n騧ꬷ.n/o'7G/Wogw/o觯/o HL:'H Z̠7z GH(L W0 #}9$R'áߊ":lW5ĹQh4&͉dMpA '"8I P<4$# HaIU $q:$9y42 0TX$I m[h# R C$D 'K-ъI/<,Z+A m":\t#s6.9ϱ` \FG&C3_[=tٱU"[/i)0R r@Y4la( 4XT`2_,3=~A͒%`a lmܔY2mςl=2X -2IzLYzq `ǬiT ILh^itD)S%`Z 0iE%IK拘G@GNb,uЬI N ,H 0k BEZLJm6z1j+$v Ԭe?/RMgEuX+9UR=ʨbjuG,Fk) bfVͬ 5)uH"iYզu6!ŭkEk/-(2Jjn[mq%rjhbSa)ּeѻQ&K)m^kխ}˛A4卯z\f/tgKJМy*GILjXRI$&w.$)6b^~M,)=Rx/K&a*.DDK×T^rA(bKJ@e|RX2_oF*,6H[e#k9qVlt.-:lWXad/I @eK9]d t n.H[Ή 蘺!rL]y\a#ӕ^=46M[1^Wڼ.:r]5&[鹞ϝ~% f GR.r&VrΖ fyūvPlTH,9bx>P2Dm9$%4 .+ILPB 3)ץD=}Bw~ĢKSFd+/5ey>'Ͱ͑CЄ+;`WG;w"Z IKގt&奶;Z̛?(WVI^*jqn"eWuhʾuy,|ӫnF{ AL)j~/;&RRغy.㳼!mEEROgNtPzR7yqK7E|iWIWuENPz*uFηvzd'6|hwxRKU?@wUhBEҴTITTKRG!dV98T='j$poTsLRQ@4yBuIсh hTI̱DF$E[Fdn[P TT؆Mplj'R0O`f[$5Qh5P_hIzGGXj!u-.`ytF`qTt(@SJ#tGPAeIv=TuYKEKtFI02hDmL6EbbOXHXd:W뒀<%Is}lOoV,S{J t6TAvdp8tNĀh})8_p(Ƈ8XIvrK8(y d|r{@sT)) Mz8XnF_r yX8x(axXĴh?u-WcWOPhHGN2P$pAZaN `\U V) WFgVv}$וNdZg^gvuj K\D~5]?>:Eؔiy>Juv^&fi]BvibRB֢`cE¹% =])U͡Q|.vw+EKMh8StJ]_}X\x\DZN[gYcD/tu ེH_yk]Dͪ))θ㩰e FQE^jnI J~rLwʓ8)d!@@eo@ %L pS-neϖdhQJEFd$0A Dip 6 dƁJ2j lYa^kR5 ¦:͞EVZmݾW\ubVȐʸe}ɴ r`H86Ž>!Blj[BIymwS$[LrtȁO{,ek%w2Pzsͨ`>]oI[v7n[)͏408M[j=P$e V~A8T衪 럟2'-*;0ͣm; -po )#?8 > J`<1P J*HB ]K Hk&4FI1- +:XÐG~Dе $!Z)  I% CX,DϦ7 4%Ve?B 2G.*3)W\񹓀h{xV:; ݎd[#VSOء HW3iuS wgͶb^=We}߇?ܖGoV~*?̔g.ߡ׊^Y3)7Ai=RԛJrN0j#7;gpR^(g W%٠R?R$!!p %WjB - q*}?5,[1WK j} -*{@,NA;Tu'8Fm", Nr h/P\K$c &O$c7/"eTHٗeq'oLKHC葈IС%ّ7%HӜDg:ev \_;5)x['Xyӟ>Il:١S. }BPg<+JP%lFϳ4 'IUю%i1iHl3/@ IK*ѕ,J?Ә7% R&գ9%iJsZS3DQQYTHiTNլgEkZ곳Qgkk\:W3̪k^Jgk`˒km5ElbXƾ*|_lYc%;YV.ZJ-YoI 8N)c'QhUZֶTKrWֶ}P)ַfGvQ E kAo;]uSU]v׻JFh>X~q+$o|<>Q-2|n Lnj]6|7N!1a wqEt=hBЇFthF7яt%=iJWҗt5iNwӟuE=jRԧFuUjVկue=kZַuuk^׿v=lbFvlf7φv=mjWvmnww=nrFwսnvw=ozwo~x>pGxp7x%>qWx5qwyE>r'GyUr/ye>s7yus?zЅ>tGG:8Lץ'N6DQY.?~@u %zaj)z {'\e.ww?9.QN͈1NZ"Bw>CVG#?^gKkIvqRH{=4 i a?|_Igɻ}˯5go(u䱕ǴEqi|{/|5g$ tW{4vJ4`UѿTGZ? h% 0K@lX0]j@@T3(?ۄË(Q<B;#ŻԴ3p=[|@ x>)/?b' :-4 #i[SSA.dI@@滋䋾Xk=O3ôAX?h o 4%Gl43瓀xl ٠<)0:@H$EFS#HB^:my LH-MQ@tVtIEl X'PIBMXL"pIpۥJق1#ŝh5t B Gl $QXl ̊ʍJFI v) L PQTJH"ȼȠ>JaLLG|0WhWp&Ѵaڂ Lܱz0 @I$Ђ(`K$CMͨADNM)@NQ`˨QXN',` W˵xL( =wdI` ʛ@ƈ RPMW`jO[X=%iHiLj Q ENq,H-`@ ,-UPvPqeϙN̖ K:))#4$!ݝŒl )9i/RxPl -QM$EaHXԄ)V-H3EU(U(SMPq$T WMD{P T;8 4PU6Ј~Y΁  ( ̪ՔЄqp$XKmԁ@d@ݹĻ(MǺEOݼ/[0E\#Lmx DXQ7ef} X@{-L`M@YJdSXLDuQ-pd*WmJ-pO:Ѯt@ wUq!P ЁO\ײUІ}gL ԣ  QP$ $(T XʢU2p%Y~Tuk[O]2.;!ڴ(I-ʘHXڲ7Rq`Q`E\|MM\U\I([MZ5? 2eH.ٹ8RaWVΝe (W)Z} (z]- W ZMЂH@]^L`^U^M|!Ze5 5iY} 5],-. [K 1ՉJϑ=rk<ҝO00MhVN%Wtee`E} ۰i׬G CnDs"L Օ虧P=`ܟ%-^8ޱ*bA5ʽius-]G() ?;BeԘ[]U!d@=) (ksZS$ hkfKR*%Mi蛥hBJ{ (lЎҠZƏZe vmOm,!l-EE Z$yNlldVݖ.qNLQV<`0nvV utKT@ﴂFQ̍ 'pWp+wtLǘr OI˶@$ޫ,* %ԮgBZ`[6ްVBKt #( Rr(X∈ԘZV,Kn r/v;p%(R=䨫ҧrRm1o $ QЀlQqYq4roe)))RRp𪉺(<* '2)*j:Rb*Kg`A9 т)x$YW.%-P )W(Ns[w QkX-B)v@v j;[̢Vh)rЂpw oO-vXNt'@Fw-(. Bf p۞asUIv)R(W0WxiR0wSІ7wwx. x//'s(qt7asPNx'J̈'t*ZZZބ7og(y6:z-wgz`V,`ZV[У@L/9<І-(w33/?I G' H3"rҘ"i 3wRI (A 9X:Vz2X-Zg1l-Th1=N1q6{Rh6 `_9+tS|NHcj0s/F/.Prŏn a%%d 2l l"pZlTU*W&aCkGVuDC 6٤FGjC+IVQJ!S@E)+ڲ* Sr+ذbǒ-k,ڴjײm-ܸrҭk.޼z 2[HIrHf`R朢X*5JR81Ll*I *œRLPʕdsRV9(ldBX,+夔 RkH keh1.9W]j0ӯo>Z `[``Ybg+bAdP JPlL2'PH-2U3"n[BRJt TGsdcN4P1!gҐ@Q u$I#H=IЈ ]z%a9&eyf[WqhFmg[dbgk(jJTqQZ)d*IC t\;1T^-K5rb\2Rq6P+P=GB[Xy+++j& Pa9Ҭ5AR-BqѬ b9[.r8JG5(LZ)VK+@E\NR,wB'+McՒE ;0K<1nLDJ$J6$imc4Rl`6R2[]yȐr9]N Ei)'Ce&#k1]{5a=\+SV6tB%.C䚛'.CIЎ[cB6WRިr ܓQNdk9{9c%nQ( @ݡZ6YNޛ"0;Vjjd͉:Z^b'%+tj4Y Eơ>髿ZjӸ[k+Yf$- %@ Wt#~"`c򭻡{C\ǔwK_kR{$`Hkȁ*ׯ:j asb }6!sE IژN(fК(D4BA˸SHtV,5e37k@64H ;+j)0m$ b #Aլi,[`{xMrC$JfW9HoXugmAB U N̍DL@p-tOFE eqɑ-nT Z n|e Ui-?uXy(fLkR'. Lv3(q"BS'C N!*Ejc;8Q,GV%ϊ) "P'* Bw* ;ԥ2TyA$*I0ղ'YCV=s*ZӪֵ}Jw W*6[kw1kh~S,XL"X `51|Zhig*%3ɔB8xl-sٰCĖZLaHyoӬ5s0RQScH`٬=9}%I f%O1PFE3kS&L@D&VGMsb@'xƐ4Sj1)+fW-YӚ?F~wk]>FˈKawҩbjc3ΞHgS֮Um\(WRĶӭt'VĴ-y:Ap.]!,C!HA*\P!$V #JH 9a,!,J!,!,J!,!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,\  O`f!,J!,[  H0@!,J!,H*\ȰÇ#JHŋ3jȱǏ Iɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJիXjʵׯ`ÊKٳhӪ]˶۷pʝKݻx˷߿ LÈ+^̸ǐ#KL˘3k̹ϠCMӨS^ͺװc˞M۸sͻ Nȓ+_μУKNسkνËOӫ_Ͼ˟OϿ(h& 6F(Vhfv ($h(,0(4h8<@)DiH&L6PF)TViXf\v`)dihlp)tix|矀*蠄j衈&袌6裐F*餔Vj饘f馜v駠*ꨤjꩨꪬ꫰*무j뭸뮼+k&6F+Vkfv+k覫+k,l' 7G,Wlgw ,$l(,0,4l8<@-DmH'L7PG-TWmXg\w`-dmhlp-tmx|߀.n'7G.Wngw砇.褗n騧ꬷ.n/o'7G/Wogw/o觯!,J!,J!,J!,J!,J!,J!,J!,J!,J!,1Y H0@*4!,}AH*\ȰÇ#JHŋ3j C`ɓ(S\ɲɏ.cʜI͛8sɳϟ@ JѣH*]ʴӧP,Iu@IJ &֯`öJVٳhӪ]VjeJ1Q̔^ ~WbmAVx(Rҕ[FƉ3k̹gW+& 3; -c>xXk?#<\;㋙Z=bj/,@УK8s$&Pk7A}&OdL~˷ްo?9:L`{ XW H` gMgfTYq@ _#QwrfF#cFx#e` >r!/$$\P $W> TLqƙ|9MŊl*7)t2saBg\P[``AAd'q(iDiMF#fZc[!>*+j^|EvmzJauk.i1+!gzl]+U V-&-lrqP-QAtV/zں,&QҢv  7-EM`ڻn"kKh Zg*#4vG!SGGE07}tFRzf0x2uǻFSG88J g d֝˪j:}d /0k0")cRt.R2Yq/ċW]۹$ N9e,no6qNyRa:M00&$sB0nZfMm `+{>Gɗ[]t|HJG%Oէ3ءu<`^j(I,W4A^`/5{ԟPuE)]eRƻ4Qֿ61m k5/kk7*(?\ ϶©9."س wM_ C\3;U SAL<vX5PO &:+@픶'tSbMȳŚ٤ 큋 >33~hC.*0O~P  űM;1Hhf7Ʌ䦒6Лf(8mH[|HN'ʃxFHHL!KcBD95$rHhFȁy\ؖj(ۡ1I4r {VĈ,9Mjrf8 Iri*4tAxYЙ.z3]٣Ym~SH  Dz*Azԧ\OxćO|\MM8+2cOr6s `)-r)J+#IB$]U\d7vLwDŽ~}![j 'fup$,MAxEFF?pR3(oW|X-@r}Tt3uԲpmA@C1E "}6WxnS'Pq "2N2}Z4..ՂDWxӀoSMZnx!l}hvfA\ \/!$ |8|34TfwCFC$t2 )0E(dmRp k _7N\X?⍁h`ZRtEb&IRfWhwP(ISf68O}rE}SFE 6tpM0UjA 9!( P C$UQNRxuF(mԔ}'.ig)qv15Yh$9kdWS<=)lt%)<')\5aGkTx{Asl.IL9ei.npANO 2'T{(dg y|4qÈ"cK[xHy#N ԉDҘ/Vc]Zs%7wGtMVBqW p7sIw7\e8զiz>Z7fJwC7k:*RYk"ObwFX;NszAZqĄK Bc\[ 6e@G|T"Q{rl31YGa xČ%]i"a .QqQ\sCQs>yv%7 `i>(eJ"T/p4W0AyH2LመJNGF?fW),Wwvq{'(~]gFR2 Yv)p9=wAqf7h^I .XX FCfyhCڒ4mqnQ4#I?Wv-8Ysd@Q_E^NgAm48@Y%kLegWz5qj~3@ JpE49TAzǤ蚮VoЍIa/rhQ4D55@W1{__Uy1:qHM9$#tu rDB=- .4I@CxI%L 0w{YdR)Dgw3kn^-(~`11kw4*`D5=/~CD1DOWqɇdLZ١d54!ta(R´MۑD/Bj *QDC噇qëd&ZX2h0뎊1.PЏ#n!F.#aZu'Ԑ5hUW T vDh;vmP%3TXrVeU2jj>i@'Q}+{34QBj6gmJdSSe~2}D5J񨐽J'qޚK3P p;JjH=w,"{iQiK%ZOIԶ-Q"@1lT:Gq'^,uݡ0$8z"yqj9ȡ{Jw4ŐG8Ij"f]ӎOwZ=Cvs'zvFrdА<לw%!WL5b#)AkGf:.Ou[hX4#{2kxԩ'LBz;zkAܘpȑDE,ɲ6f<ˆy|4CH4q3|[zg!C3Cf'i&]]mK~LGG띋X{ 1R1)4!Le¤F-찛Ft%b''ܡC\b)biiK Z~/oXʕ71*P3ŐS"Kf VY~Q]a"81t4W9C;"%KL9rQt[9A LH4V(X,"Z<:_O2$Q$<j|w9Q< ry;.H^g^0jgghL<+(2t'b|||7~<֌ >Iݬ;+M OP=AƢ;)2)khW=4^Φo=) ҁfB,GI>%' =2E&J.Įq}lC6$CzDs^j?؜J :&Y]>|DrΪ*$"r|=4A(Q:;u[ֳhv/J!RVz :::vBHAe cBmrR<#MU21#eQ2E%EMEӼʷ۟68Sɍ8(/H{ͭ~9Yj&PٍNA1儙'Δ 0Ȁ,hD2dFf$X@f@ G (8B5cL 6\gz)OBo.ԧԨ5|q ‹Ez]Q&zxSgL,(AD&hKB ̐-a„) F|X_?^mLP s'~ZhҥM>ҫQC)#@)OxM`B_ R.Q`Z ] (:H~!n|eD3pG^zݿ_|7&^(H \o@"N 7<Є!|a/\*@,üj"zA@(Rt jE <pXL |h츿1 J*H)#2ðX< I^m':"7N.@>&Ĉ/1 O23DE ұ ^ 2T42)M;dPN(c)@7L=1 WjQ(CQTq A \1#O|AA0c[e]w߅7^y祷^;#" zi֍|.ȭ.'B#)>XDnܬÈ7U5J,,`⨋lbYJ<3ZY2V^g~nl4.3y9l*+JP`'.#i&Ɖg[9f;^P]e^¼2iM{`z 9L6Rb$2>CDҮׯˍfu_=vg_ R4 5Hh2v&7"ʸ(p+@Zޡ_yJ%!t5P(~1J Mψ$-'erȔoHAcLrH"Ii*z׵=Q!ڗ$DDQE @lŐ%JCq@H}R @ ڝF TB8ya6O3 ?-FpGhF5Qv4yQL0"CA :$l%MSR K7q- w'DR*=kBN M TbLm'gv'+)duSa꒙$$2 [_0Θ *+5D0! YFR 'TfƠIjTf">oTI|Cm^rGD( Rj:9& ȆCy \8! hkrR r ] s*_")lu$lt7<ڔFDc|-|,g)m6xUIXuQ!ۀ䧠]B*Whxڅ_PPTQBys,5][- UrOiIl `z}C"_uAN ιF>Z\2q06]"Q,>uW]I0 sє !C^+h9Zޑz}"LKS0X:9%W@ų 9YPADδWv((^F+^qA?H6mVm'f>缎v3(]><[Q8>ɶ]hkQx8' KDSš;~ jz?~7?ąc94ΞNMcX6ihfi)?>;!@h)%I8n+f!+*7hd21+Ш~-|@NMV #п˒ Ė2$<p"r!X#ۋ?9:Pa3 c@9A93|CC:Ik?,r"5,I,~ј?+)rA$B4{?оAsz/h*$DS3r/7x<+paO#ŝ`,[@YDX*@>T23=s1) HSDA;P2S{9C!WԳH(3мNLg$*۔-k7ady/7@C kx4Js 9#PD0@r>S=:J2L(樘 5=j4B0CDɔTɩCcpI)%9142xI\8r=-991- ړ+. J_s  SJJ KʗT ؔIY#44@<5J@8b7נ)ҭ `, dXʚҠ˜J7)K!.M 2;)Iש.bMh?-"7%djF96tMA hͲIR2Nij)!J/(]#KOU;l`0OЙ^" @ ҭ Pj$w^φKP8)@P€DئZ*QQeQÈ*"Q"Ѣє8%/IѦHQ+MY%ʼn!ԏQncPl!! .)-S246v2(+;7r?Oiʐ{= A FP+ BdTp,p"2QT 4@FBD=K"UtK+kAMM< GmT:TUҘԯ ]E\U5~邜ꂓdcEd}#>)\UɉҠVX`dSK>ٺۙRF?(8:F,}~="AׂBl8YXظBt6`*d9p!uKyU*XXxI5ٓEhգoMYlX0<ɨ٘(H%\/C*==?@A&B6CFDVEfFvGHIJKLMNOPQ&R6SFTVUfVvWXYZ[\]^_`a&b6cFdVeffvghijklmnopq&r6sFtVufvvwxyz{|}~&6FVfv臆舖芶&6FVfv闆阖陦隶&j_ !:Jukv 2{ А_j{VPB RVhBVꮆ0ҐV(jv8*j({h$ )vkѐVh j~{HΏfawقǞŦYDVpݢZM8Gۨ%m'[h˖l^ڂn{T{`X 2[0l܂^fVnh_xk8њ(Mpko /&kHn2hj[l7jhm掏$s2hoVnۆ lnٖnwV(iL_ H /$6p&Ĉ$FиB ^n k궍-oj)7"'$Gr8opj@뷖4?Gv.^qw;Ov WӠmЈҸ qIV.M]Lh8jF@v[oxo: P9Ԣ%oH7Q_ p[0vUqns[puA]RpnRK H>)_v)1l Lqo7\uY!4l7ujUJ ^oo700qoqv V noovWMAO?fR`QxXP_w v[U,poq39jws)PtߴLPjȈwP5mO2MIv~3q&w'ז WPߔ9A* hwӅ%Dxv{uoVj?dnr'oRh7_kނjϧ3҈ ʶηR,h Qsl*qX'vËu -N1oVkHpsނXfLOl[p.v?Y Þk[)wpYjI „  -{I]YgK !D[׊kIpB [EDI*H d`M E:")J2@ңH!4hȜ @[Dp#YI~ٓB=2 $ +Q`-B] Q9ԐRx&uq܆fGLX!a2lAqώ edRc νPβJiU+d Pn:ڷs;Ǔ/o<׳ovMآ*) Ir)A UbQ;4ÉnI4 BNUQD`\-BZ5Z+cZpRൟ~[hPLq4 GQF RbL%D%I!P%Dn]gc H %QGHQ_Q)[.J}\ Ufيl@ 9q2d(P05` d_Cy؎=Sfy% yKi@~x"BYdzE$^bF= *,4'Pn}) IP*Y!)޸>ҏc;pjܢeBJ)pJ*kUPe.nēIx& ;0K<1[|1y@;e֐DծU$lq9h n͋Z AGt1%$fFQ< I89  bc C(&LH,'-dHa\"/.R4.zCq ~i#V< e1Z[ d.Ͼ`M E$6?D4N)lK$tSRHbAH"ُl?KH0}C2 mRbC#(V4 4/3HLI ENS\xmM$I4ZBaUN*&儨'D4ӠW„+@Y `<سUIce%a[(7  kFO;KBND|"=(B)NJ\cDrr@Ȯ8M2쎓J6Ԉ(+2(%dYR5$6E8V̘sd\<UeygiWl>EzMUfoD@#h LQI:~$'hnLHR*cDEvI]_b2.DMXibZ 2_#_H R03ʧN ~T kI\Fr$\ IˠwB+񒷼E("aDŘ? ^1YhۨjhRDRC:Uht! VCN=㙄xDĎl*qT(p' Yg[Tͺ4--'1cib:iED*m.A *\HRѢ'LDyGHAMLykf<]~U<m$RI?-O*2D@ջω2*>[ פ( J#0ԟF Wxv m1ӿ%#x~'on,S[&Y3)sro0`Blݰovdjʘ]V[pxe{ϸ^tJȦӎ l啸,E(5WҊyGkŷ50F,U5M+9#Y!kv9K(QӤXYMRrTp__YRNI.iLd &Ξu@%M)D"K U ɿ !XAU$-yQMD]uP E̜vQ`V+LxTv՞ТcPNRL Kb4G]5ho-]ܿq_dVxGMuOd%U] YTvam\i HŹ!nTK픸YpY^m]$8S]pH*Ra\Z$6e\Ж aJXa ]q o,ZB@H\Ha;#<#5!ن|˳%Ȇ\@Niǀ]؛=ٞhMP[MuUʺԋmC,э\AqsӉ4B&dFfAWdX%4WŴӜ]yJHQqL"uؓy ŊK 1E&L8P@=iVM/&P\BB4WtJ4"JxhLoE̕% E $J]Nnq95 E_MqY&Y@^jx\\eb9M$*.Mn9FXUոld݆KĺViV_H4I((zdrD\dQ)HI}.рEl'tSWKL-D!b`pS$ eOgDj6H̺9DIƓHDр[`EK[F p{_Ba{c 5t~7w 5h]}.y_LDQHR ]|Q3ďxxxiA7rH-$`_PBO{OPNL k߼<'>y~x[?>埐|=g[>w{]>ƣ~V{ӳ>[ ks~s~T۾G>h=Ì{V#?G7)3>C?W*jon~wur?)?#o?@ 8`A&TaC!F8bE1fԸq@A~ cI'QTeK/a$DJ 6qiSfO?:hQG&U1gӝ7,:jUW5Ǭ_;lYgv:m[oƵS:ջo_uz+`Ç'>Vר!GO+*T=ahIjP9AnR ,aG>[}@ 9mq%&(l΅(QJޥs6]S+;.dGui[jJ5%Qtw)X@+<4IhBY) %!y H@0;=F{gʬI葌f[uŚT5zz A. J)g}HJ)4J uD1(q }&|dƒnʤpFHye{LTMT w"B%=LRYRT W%,Db yfEcE ,\{5mq ٫\ B).A[Ą*Sp2d#!4U75 h$06* ϚegW+ihstKu,F eALdl a6bv$ b jE&<]a Xֱ=a,3c̃b'~ J$"{Y}:v&p p)u/p%^g',AAp(& ŘpoLN%S=(@"c59/y.ݔ% 8C_鿭П1l X<ߘ Ta xkZLG AZGN~g,,Ɗ>_ GxmYD:m jJ 6j%FMN͒eY(ˌnZ<̀\bhg8-4,sbq kM.^pohasD^Jΐ=o 0,z$@BrFM`@9晄:鴩Kfbv b/!"`ڔ|ث ڄ 媼J!V0nGHdE^!qqgfT[g%rPGZW#t#PPOPWQcs.$Wᢥ҆m M*>m2XCR[!"$ RªU-4ǂ("*[HԒ\kC\-F_!n_C[̳ T`[vaO8@ "<~b1#c9cՖ<aCdMX5N_ue]deRţ%"\g0ʕG(2u2ge%.h{e/6]OU]шj2i"fV@5h/5mOcTb*̡ H<:m45OsƥbXV56k%#A@0pvp 4`M"\WjMb J&A)`&!a֤)!6"VDJW(Vwzqde@Riz(ص4#` \!G5S ]F bT1kƃ)ndV&%aV{+|kWo}y#dL[Br)W*Lr'." +XuC ZiUuY"uhiwCsdQUFnf J<[0m4x9mK \f h^2)=B  qZږJ0mo&Ɩ@K:nS pǨ%wX԰dS{7u& l2 g $e_'Ë%B-W{!Œx4x=84#i; arDA]JKDPq8+w&`LNE9+>x`KV]- 0]_0;>s*ѩHɒsP.V9vyt;H:&7wD">8%vfYeCBCZ%sk yr9ar]a1`r@#JJ9xE%Y\4d.g Wœ& :&{ X\u(ND&͒P^LÀJ1S,MqnKiĜ/BWj",8 fڜYmy%vi3DD&LbN.W@b yzy&%щX r ;#Nh-l!xpEMu>o`2]S tDZBJ0a$Ehl5ScIۀ5c#Ds:Yk۪?vIp7B9Jen=prYX*uB 鎉k;&%oBp>M0D"'mZzҍ~sAxM9e'qW9((_{6n۹!mh:aZo| w[I .wC y5֮WoBaф 29C03ˡG > N!Ⳙ(> MT@ejǫlj5M];U{碁˕: iPUW֟,Si\f&Š9XU` )ɉt"WA HCl]٦P)N2GN+@2d. Fd($AamKJEbdlAHGfl6iB{[vɯ;KFp /0u ? qOt'M=zO^CCd^>r :{h$#bȠIr)iM1I+`!3'j9DCcd+Jy:r+->)%)-RuM?F AD\[@ H&iqAc^,m>ķyMI?g$L?w_y6lOWC 0z!^U>ǞUg;Hd>6fjzyfRMʇ>d7O;}яn3?繃O~qdr|nѧ_fUi {Kd v3`/A)ϞЇ d}sR?O|ȊXrӽF`S"#AQ*4fXp*L&R1[(m<EPג֩\5OZĆPB v^ڮqs| :)xH*tb2Ra]F *|+%mESKn114BBN2Hm6%Ħi6((\ lbKDw5THib0O f *N e/gp_3X XB}_/tgoB `B^: Pp/ٱt6)E @ )+Эi}k .G))K+"ѐw co k?L7+US|W>*L` }i.PaKfpT R^-g0*4[^8-Y) 7DAFJ3SbbND+䙙S)ނ$~DFi7Ӝ1')njFft2~Z/ UG%jSa,h`SӞ8dˌHN&@ZT\Vj_\%zf@ԙ$ʛ h:oA6uҔ RMe٢(@ WYZ20~PZ[b0/| SS*$8^7 #BFmOF45^+*).NY+p`s#RZ#k|kghvB>D.TA%*t]W*Sڹ0$DTp923?tm~\%&uEc'0O̳Ľü?J}xm%%]@c炛~}3Vd/9cpNz%pG{q'fr!W_Sp|q7YxVR->}%~t{WPcg(0t4膇io(@a^^^q7zPWwYwu &cv pvlgfvExzwy uJd|1hw'J}cR.qexo77rcXdX8()zzp $q@l*2:faUU#|4ҷM'0i;xeIx_YA R GP*GSg#-do;"xZ\Md F \F~n@҆IyPI~Ij+!Z01BU'ublg`Eֈiw %`]`kzl ƕ-%eissRЅ@C y P|0 %*&(9%g/ƜyI#zK;$*Ep:;J@BBT@d2e&ISY: YS~%Y(UhW59sUhɡ[PN}A;fo 3JC;<7 @A`ɑ?H0$6Cd҉WW ndۖ߶mW#WS}RGkJRa["sqʦqYΑ$G|Nbx;X>pʨ=b!-~i2jLi7?!w] 2omJ訧mE:t:JM%zfRꫝ~6jhNJ~͊~꬚ק{X~j8r$ѪǬQ[OAGIЪŊ1q#X p ٮ H M zĮ +dJmAonnoGT+!ak9԰'X*DI@*Q?5Ck5"*ACC)KJ5EDz {QDI۞R[X;Q7!#V caOڲ9$*%3d qQX{7/et 3[L1tU4" t|JGq k7i^dIJX5@1@P 3z1#PKuST˭Fuؚ))A[ჺ[D<IdkdDyyd2+\@K1KMx 3[A1X{y#,e>z d=*c׍& ( `@ǻ%<%lI;vva1+ÁGJ .5ݺP>I*,E 4lwdM('DQL뵼U|H[Ł_,VdcGLIftHڻX4$A;1u\{i1lT @k!Aw6L|eN ŗC [ȟAZ1,<ȓR|Ea*ڹġ,xY>ɽ,H)$ KiẓPG.(-^27^4!><;9B@EnDާ= $޹QUgWn[]_ac^FM(Ar3kRo~w.1vx}.NnLn2j5gO܎Ρ=+bd.Nncm?O8$CB~#\1kѴ쿮'#H~nG.ӞNn~Z.鈁^Z))~Nj~>NnN(KˏRQ7u$/.]xHs10 t$?u|$ T-.//1O3o58#')sU8U$G zHb#o#o#}9cOT7w>n?_/8k_\62)M\0P_g­*RoroibNGg/iOo_o(hZRMQU(AI$$8`Al`)[$%I[:$(!H 0DJ! *Q&YL5ugO?%:hQG&eiSOF:jUN WaŎ%[Yiծe[qΥ[]p1ؗ_q%|%n(1[+fQV|D¡G>|X"̠(yEØGK| X-b-oڏK@)ڸɑ/WޜsѡO^uٱoޝwyկg{X>pK0"C23pT%Xr"!)>*)".;PCC)$KiQ/ L p⏥)nڢ \1ZQ9R !uL%[k?'R)2J*RK,K0H.O.6p8,M03lDMsM\4 ,RT @M$8/C5FɓUYkV[suW]{W_Pb5XdUTz*53I+!V6ń`mз/gٜd#ZtÔfP!rm ot6+~_8`6`V8ាZa#xb69B75:[ԲX݋6髧z~O{_B uJ(&}(ɸ(j]DĚXܦ?N" N~A?+*!`fA ^P=A~P_ MxB{ӍʞӴP5cQhRc9DshhX3; QE*!w~ jOQXE,^QV)bExnsqW67qlEB!} "jKcNִ).HI63@}Q2].7Rd#عqN##IILZs#'!GV2'+ FTRb+Y1WhӢ,mYK\RtVK`t a1&ӘReBSѤ4YMl%tf7]Rve:)rd:N|S>Rog?EyIS2=b>A P =hCP Dzш4@hG=zx"ͅ!%r w+UiKYŏT3U yS4;ũ}8SCjQzTnKe*MTFUSXSUfmR ckYBֳ+h]Zַ+p\V5qk]zW+a;X6UlcX5a+:6+g%͆li)KӚֲ]-XUƶOmmm{[܊moFLV%nqcW)t\FnuƥG ].W$])g,-Bad@l+KFB7jpT%FtszQ#˃/#O /KL0g!.uYt(wi%bc%bՐtWd.mAS[{`Q8,0 e ( rKۼ|Wn_U.j cY i2a.frg@/ ]Հc]05;F{Eђf˓}߲ ʎYrZ"i"WPw'}eżn#i}/;9݂e 20ьA"F}%14K8"FLex&,[YB\*cYS{KdƲP`1̫2n\ mxt%̀59\xDc؋n,'HY>R( +r9*",ˬ]\\Zz=ݥ/2G}d N޸J%-gybʼhI9S6?wXmoB g3]>޺gjvU+;"p.[]0w+|c\;w^ܱn</zk7~窿t#}TF.0vsoO7~u^nɌ7vI~">իl SY֧~otÅ쿇oʘ(,`;4+›79ZQ %P?r1.K(+ Q@{+c=@=9,+"?ӪAC5<fc#6eCcfs;PK_{ L;+d;#A/!)c0p7$廾|2Aٲ?7C0454 4B/<=;@#A9l*[{EY1]K[S B#K8+7g6.n?%8e[;LiƶNFsŬFg#Ɩ[Yl*8Gs:TD4܊Co M||(3= ;L d[+8>% ȭp4˳J’L s:6ILKt;M8:JDbIN)llC385$*M;TJ;LzjJbѿ@, mM"#y?)Ђ`TNnG˭ OLo6nl`9кuD  2~l-*9v mTE"=R<PY|+|'4%DR`ZQ*ҼHǚȝkR'=щ1 3S5ŽZEXI-K9 mҨ5+S?eV%@KӶ?5lTHՋML;2JTM>TO OUR,UT6MUVmջTWUZմ0R[T%U^U_%R- pc%$8/ ozD05#ư(L# bQp;%D$0M@NChUS V?h $n"DdQ#!F;lFR2l !R!b1TNXgX ʴR{d:B\Iy7z@ dGXduV(ob<̡tāU:WKy 7O`/(p8seYXeAK5ژZҩHxԓ ;;)1TڋgZ0΂=XZ|CFM"|=)WGi\ e;Uegl̾L!#<>Rf<+YVȵքjѯYHC^.`U+>Vͭ faɜpM=LeCO+4 9\R4W)<ڽ^:r)V055-/ S?EHȣ]E d=%6+v=ߝ5G %i"h_E e,n[`.cV;EC.[lgh0hÐ 4ug(<ԃƽ=650MʀB@iLhXyf(4nu P5\tBLh;e#+㛞ߛY>PjV\d:yBOTN>딫,`^tD`ب=UGEZdDDȆ._)Kpq$I@k;>#Hc #^ȲUف]}m 0a&b_mnrV`rn3KnמƔ&MNo3m/xۄx2y6c>77`CֶQt+J T, ̹ nzXm>5pFq\u|3/0Fu8Vְi06iVRTY[@@Kީ6B@/ J-85Z_?ln]E՟<"cdcm9TFY/Zlq,Ev $l\#}-qHԞYM'Ofr&P>XG=nhmᢤmt-n7[qrK_b{OQ'qabpVb~u.&oЋM?9cS$ؖ_(vuT= pOtmꊧF.{:d U9ȫ{w)^U-")_#f"xϖ(Wxh`yv<@FhnE}rޮd$x6koFKe4܅x30죠(yЪ`󑬼z|"zO H h[b7[T.]&?M$0H` BFJ HA'F42^bȃRp$K-xpƒIJLpʋ' 1ACRrʘ0YsONJ%r`A-**hPTuxSɚ=V| [S9R$ʌ'.ч *hsPb[&yʓ?άy3Ξ?-z4ҦONm3I֮[f`ĿbuuoeoZR$-tЯr]c[dNp{HϟK^#xΗvgXD w;nGwЀwQ~I1D͍|H_EןXA'Esue~T!Kx}%HՁ]u $8XoK2٤OBSveVVVZk1u4@RedYkaDBjw%y\EGwz|q'~v[;YA~Y(_QTX+AwWz]df Q'_x',2(sJ*Ji]dר){EEz4Xg1i+_}R'O.ja;! Y5gQY碛+GʆYg'>$Sk\p9!LpM!tDsw#x26u'I0?r[ci1K7fMQBul1S 63K)rƱ9,^gaQ\үXv 'q-OoZ>DݒK߿k^W_C?<$x X)aSL(Ǽ́ې3#@|\! KhBHS }Ҟh06?fB")Lo[ UyuX#+~&"E5 %}1j\#7761r@Ȧ%1z#WGqE$ ȯ@2\$#F52`gM2$'E7u2! 6S2_)WWBIz! s8T:Rwc:L*gy&P3L)!Q,+Ww)Sqȸo"f8i#~ҀᖷK4ZBS ЖdS(8)8u!ҕq`暼?r4p4QaqLwO 2bf21)ΉDknbY&!ё7&diݦ\6khi07nS#h}*]Q5d@2 o;J-YX-f#Z:-MLe_aJTt4g%Y 9ejVV] ZӜCanr/nT%h>{mX_l& ThIK]G6 0*7hk^:ZVg [wɣթmq{߸,h[ıZ/%BFGVR 8F8+浊5ϗ.kvS&K_,oŚxǞǭxox\K(2n|;1U/5W{$>8O )!0 M_P9`Mei_@TTI= y̑ P .օIMڍ iPCa*aE6TmZb!r }_ܱ!ϵv!`K!!r\ N\R e5]r|]ONݚ"~n(NUwP|" V 1 X !@%."LPMʉC9\0u0-Ҫ[#3z+j#Ş/&zƙbyGt3Pb͢)"];c8b=j^_ JgY&j&\aQ!; RErh PBݰcF $u!hш5:ڝV"[߉; yN6WI=%!fFIUEyx4bW05y) >ez|e_$%Za[8DGDD@+$ `!^\W.QyşS<aF&pb%A!e#bcHe]HNgn_V|U]z-$EjX.%ny[YFz(]ȦfgqR\LU:D^j٩˝UM9bΜ6yMO*d.f!brRf'cX6%16(1N$yLg>y]WabNunlY*Q`k'fBs9搆.[ ^Py\cvdbD9l٤Oryfb #[U靚l'܊ =>'y*f꨾{ k]a|j ^~J0qoO0b0 op6p {- /Z+ OS pbgo %BR+ qM)3LIU8x1pq&@qDqqr r!!#r"+"3r#;#Cr$K$Sr%[%cr&k&sr'{'r((r))r**r++r,,r--r..r//s0 0s11#s2+23s3;3Cs4K4Ss5[5cs6k6ss7{7s88s99s::s;;3ҳԳ!=ן=3>s?s@4@<A?#@;tA74D/tBGB3EOC[4CgD_FWtHwFcG{4G4IHtJ4JtIJ4KtMLL4MJOtP5PKQO#P;uQ@USUWTgUk5V{uW5WVWYuXY5Zu[5[Z[ϵ]u\]5^u_6_ ^_av`'a+6b;vcC6cKbScOeWvdgek6f{vg6gfgivhi6jvk6khd5@mgmm6on6p׶pvowUoq3o7r;wtCtsWt_7u7vov#uwwvwxwsy{r7zz{{'|w|7|7}w~7~7w}37;xCW_88o#wxxs{88ihXS5u5c브x️y9y93;79?9G9OKy[c{y9s9yWyϹ_y9y﹟z::3;7:?:G:OKz[cZן: :zz:zzzﺰ:#{'+{3{/;;Cc{gk{s[{o{;{{ {<{{{| |#|+3|;C|KS|[c|ks|{ǃ|ȋȓ|ɛɣ|ʫʳ|˻||||} }#}+3};C}KSױh2գO[ֳؗ٣PC ڷK- }h8=8Dp5S=YQw F# ѧ;IߟPW/nd5WKW>棾Jg>)~h~ ~~D3IG55hX4\9"Y?>Ѣ![Ofę?8IGWh~y$T>cQirs9Ehf-ݓ&zꕙivJ&zC T65#!鳍]2d_֛P>nƨl3COTaq>qJ'HU_3ʴxj:.v\2G nT(ZQͪ06K˶7dvR*ʯ׳βyj7ٓ={嫗KSO-vk iGo1icS7*q_7@%B³M>kw^AŨVK^Fٹk``l6nJ[WZ: b!IǔBbD>''-I$@RiNQ+)#IKjd Ki/r#H^щ4"XMk^(>GCk\"M17;,oӀLY|Sw#OY&l[;δI `@|E!tA"t 5WEDěG$UL/vSߜQ3dد>WZ-FΑF uWS6 ?ET>ULE>4M!jҳrCdg9mKnuۚP2^=YmmIY&nqxiVkVns+bWas(SMXQ԰SmDcJh⦓ _\I&NҭugKr#Jvא쵝kl@ߦU>ѕ7DpwnIO8{ m,)z_DyvtK~4ox]#1n Bы;3,[8 %Մʨ׿k]71 1B`!xu=1]u#̻XAc9%+I$~*ψ L,!(Rɦ X'L(+P4'KPI!RTf,Zt9L=ٙw2tQ^Ȭr=ÇM$W MjӴq23j:SdN2O/oO4(}Q>rk_3QQ7NťDARJef>L6MO u 6йO:67Kݱ;aR9֛ZPqVnv31ȻXc-Bڬp#G%%-iI:ԪѤkI :.&qr/Vhv-0T2n-`#4nuKIT{v+]b;q肞ۊAYjY7n ]=G{X?f&f'O|B%iRr sJ ;rjQ,4Se"/>Y%b..$>T@ߘm@t@i( AI1z: B+8[G,TC7b># B=DK3DS:QTEEE[TFgFkFoGsTGwG{GHTHHHITIIIJTJJJKTKKKLTLǔLLMTMהMMNTNNNOTOOOPUPP PQUQQQR#UR'R+R/S3US7S;S?TCUTGTKTOUSUUWU[U_VcUVgVkVoWsUWwW{WXUXXXYUYYYZUZZZ[U[[[\U\Ǖ\\]U]ו]]^U^^^_U___`V`` `aVaaab#Vb'b+b/c3Vc7c;c?dCVdGdKdOeSVeWe[e_fcVfgfkfogsVgwg{ghVhhhiViii@)jjUAg+`/kǕo"LpDmAJ-mZ+ &g"fjv?mpŬek:fpq'4pUI& WOΖܭq3W6I ja(W |QsKUqړqOԬ uMvI5cp,2/7cw?UrGblJsp wWv_FwuC戞y7T! )R?jh尗|35qH'r,'.wW~5x(8)&$͖.7sSs7N&ȓRG{soR+Qa8vHkqׂCPrxKnM@D؅ zAwo8S禎rEr(owV 97)ZɈX؊X؋ǒØC<9˘!рBQ@؎d店mNWD+؇ؐ1xW}YϷv7!ِ 包K$G(JI&hOya>ג6Yi,!핽xBp x?[&l. $$#+Dϗe=wyw,Wg|XJe9ߚtzy$!.F!lyMDs0 I D*9 .LB)OQ9;+,B)1 QHNQޥ)Ж&//PҡzY)7/nB :jkƏ9Y[FZ6XVF̛8xvL0؞Zpx%BD3١p\Mf픊 d S+w W8 S>{I.b@A븆Wx|cyRҴId+uf!B[uW*uxxwmj;y O5Izw[Cq 'PH20nW&۞7 MhޘCV0++#.J'Q('ArƵ\Ͼҷ.6S{ctŽ0z냣v vz;oAW[֤pN{+l@Ӥ|:mSܥ}(0+\;VzR;fy׮?X\uY-;\ PzZ #P)5{8\3];xϑ3o5]ԳsO]NW[C3/=Z5;3֛VQա !W y’=|}S3&V{֛f<)G{]c, GWcu42J7C4;92=;hP ׎]iڬuPm>(^gبyGocMI*z[[2qMe9汸=DKtV>?em۰=;[cKu$Yjv\Lfo6&S:ŋؿ>c=>Ȯvq~HLY6{і ` $8Ç#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜIMRNf3O>: @EyaҦ*xu!V .}쀮dy]˶۷pʝKݻx ֽC7aAe+7SKZt`1x@ȜK5(j~'M۸sͻoG g۪Y:Gź1';L5sF? w_r#˟Oߏr BAT HD`:T`C )bK.xЄvF. `&&aߋ0(4hIc{iXWgSBTEF$ؕTvxEqDT픖G:(dih)RlQcQ$ZbiK9gy6a9R6֦`(j'giNԚf馜v]̅jsD֗Y ު*QV鮼ihEFD,-ˠJ*RN(fv뭯b}+kK+k,l' 7G,Wlgw ,$c-^j,+iwl8 sb:-D#XL7tzVOWmXg3`-> "Hl-_\tmmF5l߀>hZ2҂'wX6 4Wn9Kx>^wyu訧z[FسKeHIo[WZG/g}/~sX觯6Wo`?opHPL:'H Z̠7z GH(L W0 gH8̡w@ H"HL&:PH*ZX̢.z` H2hL6pH:x̣> IBL"F:$'IJZ̤&7Nz (GIRL*WV򕰌,gIZ̥.w^ 0IbL2f:Ќ4IjZ̦6nz 8IrL:v~ @JЂMBІ:D'JъZͨF7юz HGJҒ(MJWҖ0LgJӚ8ͩNwӞ@ PJԢHMRԦ:PTJժZXͪVծzM` {h6!֟o}CHꔬh[7&1cJ?TD -@lS$ڰs/Ni 6; ,8 5&rkn⽨ʠ.ؽ2)yNFj!XuŮF:CCⲞkкlqL]';qO 'gQHEx# K"b Fg7pĉw7U(k͔(/ihdU皦)zDl)~CZjGl`r" VA0RB fdAEHȴ=PDl,gk=s3XFNIZt(:!IJRp!X/қD0="$d1!:} Ҫ}$m*b+lm$@v8g@$@jj@ůX=U|댜K,IӚ88QH?ֳ/^jirH{f.]fѥEcpQ6GhRUF#%TJ)feӺ5"DQS1Ѩ>R"@lIY+0ZmTGG(GT'6 $9)(g*? AO~i }~]w[ i1e OD" :iˈ:Qb9'72^'eG xq.fyI(PztNLXݽ U_FznV#o\WupdBWO]DU,t ;lzF FQmխE4,XkdkXX d{uLbPDj4/S0+WSMRe} wr{` r*"(;1IuӣF&MĮeqgitnhgsI v6scmni)-@Q)uAYMLWk|B٦5eˤ@Q~fvMj ζn{MrNVvݙQMotΘO.沯j Z̔Ƣ{e5ypmjx#K&c7HCjQRȽ]̤4[P@w츭^d#,d9kÅ,^. ^oL/ =N@'#vV_I[ xnlq(LR(SH ZVaM_%hd D {ZW/T ;n:RfU eV rxrK0%~`P_/^f390dy8Up"2*2e8[#c${!,8+6䥎hܸaejÄL4d)CqXEҌ(!oD`eRtt:yuc[F"u<ȏ$me*IA!'(5+ ~…4uc5w)j(Eᤏ%yE2U"K+qF/r{NG\7Z?UbHnipR$f'B !5UaG %Z1cD*"90؈Wsk-R!&!/$r`EzwRKfy-Y/HbHg2)%j9]v 7"pHe+Z]UDX!٠> 2 $h%҂A#'ŤrG ytC/$E^5zaPe|"[gys05z>bAR#8HQ**YWR‡DJ]:ۂ$q!ɩ"/oAEC<#RthAWh6VFQV'>ѫc#*$62wBQ5j0YrrO`z#Y8>0>*:,87~$Gv<ӭ#\eXrmqOTa^4_E+jkr|;'2*e5gAX\چ792J7)Nt:3#ʔSèt-!Th:$Od!W)+5zJ|s"5ZgL e I''z+ Cgʵ$ هX'@jJ1(DgJ;ʩre Eة$mgړ$(]ķ@)䑜0ɴ^%Bkз3fYp47A 8dCFe~\Rf/r( THdgZ[dlRp˟FRd Y UE_줶dm_{s"fFoDKQ+\'^BCh雛;„4DiP拓`faZ= \V+&p&6uorq.\v6$^e@mrD\F|HJLQRcwS|d\\S`FSa\ƌfƢVv n8r85v|`yzl.~ [J3R /M l۳p(\OA4"a 4ҭ0=K @]rJmS=m P]m.o]@ P3'=@ 0J]MnV`R` @CҤd@ %ٔ-&]vxd "=wvP P"٥@ sMA@X-9Kג m m1mY @Ӄ3i-sa_=R?A Y=mӦ! ԒPӹ޵4,ێP V=R] =m = \ P1U՟-A0]sӜPvխ " `eٶ 7Z/] Ye -=!JGZ>A^PMsQ q< >mmB_Nm T=\..7-ld^>|ߥ0RLEߏrLծ"M=6.ދpN \I5S WmPгNL"}n;2LR.`Z2 @H N .~%0>mޭiSxeUեd0qQZ jݙ"N@g  P @ JP,#~*Xl17nRX]ݹ x #-3B˭ 4Q:liMۥ-=4oPbޤ$m\]>=3x >KS*_ysR!۽}r{\.NO0MEt$ |Ǝ&|YmQZc_^ݞ.R%U>XdC~1xqް k~A_.|=A!#/(`|x~c?OOJ-^ uJ13}2 1eN(I\ KrU.Kqi1E.RHRdRl#3Rc-']m" "[krK[lڲU;[H٪&.4qnKInԙ5#[PVXe͞EVZmݾW\up)g*9zJ0eSC\q*W/%*@kTztӖgҭ@f-G&ZcץO70cڭdt]ōG\r͝k$Q:d HxzS)ApJ@IJ*JĨ@ꛆm*X)3ɷ"e$Z&?RvI})>4o B /0C 7P+ C GD1EWd#FGQ Ȭ]1#.\G!$H#\ǐdR$pI)J+ + Ζ[2L1$sKy!(5߄3N97b1  O?4PA%PCE4QEeQG4RI'fϸ4Պ0abLŚqJOE9K5*\U d%kV[GzU]wM RLh@VDU0VQFYE%$Q6HJ 5n 3<9\.35$%b3:"ٌ-eA^-;*G_9EG`EMFG'I%MzBsͷ& Oaur= ڰ𬝡,9)Y$$ E6ݐB.2,0*[+* NNPbE5ĸ8yfώS劂aD.̾D SDc.Jڸef΃x~h֕7W⽳B $SkBoSOsE$ڌBתsiX@  T(tz7Tu0/ݐ" G +Π[i 㢉)\1Ǖw-Bs)ӌeq^]gWgqb@m3"qo  'Qa:aU2(,۠NX2H&$T O|cFfU\XJ* D=~; p Ae r5QL!dBnJ D ůN'bųfQ&ȿQDFHFkab\ų D ˆvi$ R tB;BdT#4ON'Iㅓuc&ru ]8BRD좂'Ѕl ^='JQ'L 20y)e$NhZq*5YZpc@1hayis_Cܖ2LsqN@7lb0b( x1d(S8Q.jTH)aSC*RD{)"X!JxHz2-g)2FaB3cѪfXЕkeXdF5 2kجܴbFbK%b#*"+%WHqy %Q2 x^pt$UBZwגV,@A#M1*Bdea⸍1Gv*IϋYa83OI"HAu"7G%P׶VkupVM 2)-Nnh RضHs[$ kX|0 gEK{ZVxOkmф$8gV@!&k{)^Q3u=GEx-3ɅI8CC&I ɛ$J+)PlH2WЫ*axȢ)(ӤRi;4J2ɕ1ʳ4$&c";R#&M8H˻uHF&V*(DTdtLALK+T |dP؄(Ͳ(5uk͘x1Ђ1Sڬ-3 $HN̮LX"I)1`!ZGH 1{1u#3i , ˕i7) ZijЂZZZ`} :M`RHe6Q:3`+x% 28h klS Ȭ( 2>* vPȫpռԴ Wј8 8! 0_x(VHN-؂QR2`Z d%Sͮd$ p30N@%3耬 j ugS$Z;դSi Elyù$[(,Wh6O1 -Vh MЀ/eڤM( 5ȳp@dԉ@TyÄ"-J0i ˊ4V4ݱ:9> ) L ]ѥkPR[St֬O* ؂/̕;u @6Tr]\ S*w-*x.(b5C-RS? N"tQ 3xHe]VäV Wjm:V"}} ?6E炚8Y y1  ՆPT:ԌOQ`U RtP Љ1% lR  h6REձ HSpѬ` [S326 X60TB/>(Y 0R(M MB8ؗ9ŏ2# ʗB/YHt00[K] l]H7`]Ն)W/RtUW)Ϲ-.!S %^sU. T %K*T;%g+N%ޚS.`.__ Ea[ (ݠ0Rm}u{ 5ӈ \z荙9\y/ W̭ 퉣ӕT5M P6ȩꥃOTO%580:xPRPM[Vtucb6/Z2U7Ռ٥-vɱ/ry^ ذ_k $ p nV%;8}%uMY^>)Фvȹ]> K Iƍ-h 275k։P7ЪoPA"\R )11@ېgtD|Z^WV`u,2Zx68U;W3:5A8ѥ3 Xٍ3h1 p>mVV$YhSbH>ݎ+fbV;\ꉏ^s.2{WI˼aМ28f'Y:0ZfeղPZ ҴV'4^> ZzW <2y%U^.y})E_[42m& :{јV\ b `Cٖhy, Im bjE`܉Q. N.h\1 ^8nˁT$arjSmR ^ _-=%,VZsXP_:X$RRVXc)07hF-)@/蚗꣮ \@ŌXQ-,[0m}i`biIu({0 阜a  ]vqHZ$72aN lVpRz.Ђ+#L8v쬐gy(? 0 W5Sjp r9+._ <CjLW_=Yp.zq ҘAmD^5EYC.;][  _p.^6qqfJ8u`k z- &n6u^n|=v+25)肮pu+/uoJ P/i3x1Qpf#1ViNV&YepT0@=0x] CakRxb$"!vϲ&/w~\Ef5/#qJky~딏Eno2W'3G v()<Ua- 3&Yz @N C]7 F'20y0{ԮU~PW-XPt x /)q5c)agՙj)EoMr 5c6XU_/ 7@\X mظ)dxpQ 'pd]Bv]b߂hLjqDh'כqP xbTٶTLU:ЬMN$n۱^y9NozZl †րjJ!Ĉ =@C[Θ#F)dHidS]$\錧 /ząGSjN rd쵂Ş- %K q-{%@mTB+[r&!TwJe:le ٴjײm6b:4w-YLbh&huks?],xpD)YI~@;[R  eD2bm(X=T1Y1Ԁ$Hve܌!% B{KЖoVtE،9ˌ2া|]t0`(1ŗ[KPEM`T,p\xA$SxPgt`t]&fmrK+ ` I1g ƗY[h5[l_V$M:PQBg^E==Yb%DCYQwdH h DM"GԂ_B֐Cc^+>iAlRiokBǦq(FummŶ'i@BEQK^e4ųT;iTSJV '#hTsUVSHT^Ae+MaV|0qK+dr RR gk("UCa|XCZDR],j>E+%BjIrr84-l`ڢisxP3*vVd MGKyI^{Q/9_} ߰dL$´wU N88Lfh{{7DN(E\f'D FNUbEfJ:ie<0'T&c)-mϖdOl (ߐAKՃr…7IȢ PYIDTN( Eշ.Į5PI@%{")P*ԡFN}*TJEV*VcVd<*X*ֱSLBB(YӪֵvAR[*׹ҕ,rG]׽5M|+`TC=,bq R3},d {1,fӹT,hC{0?b4L-jSZ E\-lc+[veJ-nakR6aNu]^k2wm.t+WJ +a+{rAnw+^v=M0}/I[·լt/,>031l0#P sR0:,bw#>}$.ؿ7joclߓɸQa=kGWt\T["aRIC54!,! `A rP[ #JT`!,$H*\Ȱ!M#JHŋ3jȱǏ CIɓ(rIɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJիXjʵׯ`ÊKٳhӪ]˶۷pʝKݻx˷߿ LÈ+^̸ǐ#KL˘3k̹ϠCMӨS^ͺװc˞+e\ Nxj Ɠ+_μs+KNسkνËOӫ_Ͼ˟OϿ(h& 6F(Vhfv ($h(,0(F8<@)DiH&L6PF)TViXf\v`)dihlp)tix|矀*蠄j衈&袌6裐F*餔Vj饘f馜v駠*ꨤjꩨꪬ꫰*무j뭸뮼+k&6F+Vk_fQ+k覫E+(k$` l' 7G,Wlgw ̚+"'(p 0,4l͘<DmH'}J!,%UH \ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJիXjʵׯ`ÊKٳhӪ]˶۷pʝKݻx˷߿ LÈ+^̸ǐ#KL˘3k̹ϠCMӨS^ͺkS^˞M۸sͻ Nȓ+_μУKNسkνËOӫ_Ͼ˟OϿ(h& 6F(Vhfv ($h(,0(4h8<@)DiH&L6PF)TViXf\v`)dihlp)tix|矀*蠄j衈&袌6裐F*餔Vj饘f馜v駠*ꨤjꩨꪬ꫰Ƃ*무j뭸뮼+k&6F+Vkfv+k覫TJk,l'- 7G,)AR!,c DH*\ȰA#JHb$3jȱǏ CdXVF3#S\ɲ˗ID)6s. !, `*\(`@!,#!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,ZH Lp$#JHł /LPǏ CDpȌ9@(ReHm`J*&+ּRd̞H*-tEG#nF)H ҪVlˊa!\>}) xVbRdHk kצ5ruզH*qDF$CWdW[l,Q3, \Lc]P } 1W{q+/H:sAC|<)m&D@AJ>L??ۃ ~n4@+tGfE\\ GܲIYFX@mAF+b)hn)Wi"b*H]8A iwPe!i@v]m1aOqg imDUxEKt1f^L@ PT$sI)mow@Q'U#y,6^bXv]ehW)b4@_]2IA-)bfn)If0uĘVU?z&f@X9V )'ɥ\H%z'ftbPMQ l_|bF%ngTqy/ $/lzAQiPsCF$^DSgaޡVs&ɤQ"qAE sVaL`C2J|ɢf)1j= g=.<6\)7\KKmD'k.1y hE])mUQ%!j_׎$K:X鈕RHfO~kTTV͚S|HC`qQgxAIcX.AcZAPc5ź2JRA /1[m~?SH=`:D34x֐A"lᲁoUa>{e2 RVoXB0)V!V0'3tQBޤ YH঳i^~$GBFmB'D;,,{@,ROE;{VN @ +j,0 thE% +AN#u(beS`b`()gTA -X`A6'(mB% P6i%>ar  <ɲ}lGC$ Vː? Gٜh0b=! 2 $,wX`c |p: JxVD *O !f ? @O柞0EԶ3mзlX%Ql@B P [hYBJ4PH8(9%ZhιXXŁsAo朢&Jd[PX_9:a*LUg ^0/(ϕIB*%VF&2DhӯnQ6mLᅲV VW׎ @ EG HN@"(Q}SMXLVrd!̪ϙ X3IȯtMR*HECS F@<= \Jtt jBOdOC|p.h Y72x*˨ynFĔ*edQM_QC~yj!ӉnEZAQ)T#ޝd+0A%ʵF{TBWX"q~c)QQ@VLYq͈*lND >&HEKlь)E "Rg 8!!xD\@ 5Ҿ\Wdp58Yy$(Dqj2R!\A 1!i f1uʉl 29;.X_VJO"V<@]-N[z TK(/UOx-ޚW6QX©#$^4$] 2oQ,WVUvߎ}IMnipYe GPTpn3CmM B  7ͯOuD_@{Q /"!IV P+V-P1[@ːW/Xok &WWb`z߅*A=35N*<&$Ϋl])c0t^╘:4ร{|te3EA:`9V$ͱXjg,MmBf$Jk!a8$x4H1P 0 x2Z.gǨS `ipmg~chIFh %ۈX `P9qZl0R911)Q Q"N̔^n&J0 9C ` ;)\Q<) #WgH#"9:u&` 氋Q/: D xg WD'12i 8Ni 0O7Tw26wCya)_t{2epT:ю,I  6BPyXC7Ӹ :6 \y84.LJ)87w+"#=Ga*JiE#* i'7+99x6 9 Mw\.GΩu4=Px\y n;S)Fu1;n1ShHa\ȓWɗRS>b*r)vi G@ps 3"jt:u&)t&$uuȟH! H ֘8GIϙ$0l1iU1,م$s7v8.\fǂ @g۶`Qw98p~c[cY]*}saEKM^v-@ s~dS=1n I=z5#MY !IYAҪ;5**y8QM;Õ焔jS0 ИM h>7OwYff* P4x_FJB(Cq"+n)FRMrRR(R[)+J &I!&z)0Y$kc¤cV*m:3@LdcSb&aRcPyؕkzSI|62r:›)+<%- Vs!ñ!-KKfҩQ63i0嘰xЖhG+&&j0#Rlp$ [ W"%+&z 'ъؗ1HFgX1S3Qj`q#!Fzʶ`!4 zX 1ɠ Cl=A RP+:tw01w!xjn=` ;*mm#8Qc{P:p|; \ Pr[\Vy;J ;1;{I?7S`ĕ<T-փ|;Dkl>Q&~* K1o!bÐ\~)$B<`@JHqṈO"f` - /^m&s+pEkt.=Kuq}Aod| y#`$LbBbD4SP6,D5'f0˭fPW}N]{<󅷃qc, ]==4-Ҝ.qUU!]䷶nLTjXCZ}[D~.w>)e0rc\?dm%eQJ~Qabdzgx`d-blJb0 siW Pڰ 8*<cjcYz|AQj\2#B B%nB(4=7gcP9ָ*umh!vW)ܦx$GWQ`y7JViڋޖl0|lBQnS U>e0Qii$"Q$nʹ%oTUKu짢@n#@Lt jg'mƅC\gC5y~ pr\]EV4ޞ]Sa蕤v S<@4)"`u1WCfWM+cG3'v)g5}Ec ;@~R;[P.> fGq,Ԥaop U=>L>TegQڇލ:X /iәvo ƿ_ƿ qc?_/_ʿג`_p_үYu!Ŗ;R DPB >QD-^ĘQF+J 5̦-MDRJ-]-{/męSN7ٲ׆EETiZUTU3ngKU]~5Ԗ+e͞EqB֭iݾV`qū-%yR ȅ-bƍ"L+[>Ɯn;ڕZ4ZRZZj֭][lڵmƝ[n޽}\pōG\r͝?]tխ_Ǟ]vݽ^x͟G^zݿ_|ǟ_~0@$@D0AdA8w"B /0C 7C?1ĞDOD1EWdE_1FgFo1GwG2H!$H02I%dI'2J)J+2K-K/3L1$Lh) !,);HnIȰa#JHŋ3jȱǏ CIɓ(S\ɲ˗! !,% H*\ȰCIJHŋ3jܨPlj>Iɓ(S\ɲ˗0cʜI͛=ɳϟ@ jѣH*]ʴӞ!JڑիXjݺ4"WKٳhӪ]%pʝKݻx˷߿ LÈ2AL4c,eϠ Mӄ;k_˞M۸sv{ Nȓ+_μУKN&{[ν%:zӫ_Ͼ˟OϿ(h& 6F(Vhfv ($h(,0(4h8<@)DiH&L693]@!,GH*DdÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳN >L"ѣH*]ʴӧPJJիXjʵׯ`ÊKٳhӪ]˶۷p tݻx˷߿oLÈ+^̸ǐ#KL˘3k̹ϠC%|IIt@!,)H*\ȰÇ#JH1@3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJիXׯ`ÊKٳhӪ]˶۷pʝKݻx˷߿ LÈ+^8Ɛ#'%˘*Hs`CMӨS^ͺװc˞M۸sͻ Nȓ+_μУKNسkνËVOӫ_Ͼ˟OϿ(h& 6F(Vhfv ($(P!,)H*\ȰÇ#JHE.jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJիXjʵׯ#D"ٳhӪ]˶۷pʝKݻx˷߿ Ɖdዥ+^̸ǐ#KL˘3k̹ϠCMӨS^ͺװ[J(#͛ ηȓ+_μУKNسkνËOӫ_Ͼ˟OϿ(h& 6F(Vhfv ($h(,0Ƹq2h8<@)DiH"J &V@!,J!,%  %H@!,J!,J!,J!,J!,J!,J!,J!,J!,wLH*\ȰÆJHŋ3jȱǏ CIɓ(S\ɲ˗I͛8s̞@ !,J!,J!,((*\ȰÇ#JHŋ3jȱǏ CI"(S\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJիXjʵׯ`ÊKٳhӪ]˶۷pʝKݻx˷߿ LÈ+^̸ǐ#KL˘3k̹ϠCMӨS^ͺװc˞M۸sͻ Nȓ+_μУKNسkνË`Oӫ_Ͼ˟OϿ(h& 6F(Vhfv ($h(bS "z!,SUO H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJիXjʵׯ`ÊKٳhӪ]˶۷pʝKݻx˷߿ LÈ+^̸ǐ#KL˘3k̹ϠCMӨS^ͺװc˞M۸sͻ Nȓ+_μУKNسkνËOӫ_Ͼ˟OϿ(h& 6F(Vhfv ($h(,0(4h8<@)DiH&L6d=)N*!,J!,J!,SUO% H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJիXjʵׯ`ÊKٳhӪ]˶۷pʝKݻx˷߿ LÈ+^̸ǐ#KL˘3k̹ϠCMӨS^ͺװc˞M۸sͻ Nȓ+_μУKNسkνËOӫ_Ͼ˟OϿ(h& 6F(Vhfv ($h(i0(4h8<@)DiH&L6P@TV!, 9H!,J!,J!,J!,J!,J!,J!,AH*\ȰÇ#JHŋ3jȑ`Iɓ(~Lɲ˗0I͛8sɳϟ@ JѣH*]ʴӧPJJ`"뀙UÊ͊uUhj5[lڷpʝK.T͋2&ʔ(c X#"o9s P{)ϠCNj9bG /̘}Qק1~Dtik 6yϿ?ϵnJL0N1[AxCtg PeV@qbL"HC*b}-(Tjh8fT؅ yGd`,} aEݝp2X9xA%{^bR\&D&fUbZTlfftigr >`&޽EA>jP&l}PI^ gjmʨ.iD|[*@}rJLʥ:iYz箼Jzhߛu0%\X)R{rrg SfkBFnADq).<+yb._8[40YM ]Hk/(Vآ0],HI, &HpC16J|4E;@%CGZJ)%S*۴Z4 Mp2aNԙ`^gA۪I 7roFuxIj}͊V\,Lhl&pQBD6Lu7C^ .(libM>ߦDntB1N}e$:'޼Ioܟ)PFR@A:JI`-}iilK &觯>MV 4B5P??BkR ¼ xa6@r 4H(hLa'LF1O8|C1dV' Dg i{`<0$z3~?Gby^ʣDĩ!O$bJ&TՍXQrLЅ\ d\ d@0/Hq4#8X1*i"A("'r,-~-lrH3I^f\'pGB(Tgh$ B&6 "k#HtL:WAИO ]J[E&I̦T< v_ifb ܎:ydAp AJ`taz &0sK$A* %Bh [@dd,3PfxAj8 RwqLi=ԍn9 N70T8# ӨS@ts*?UVaYl zjP)WI`R@$Fg>Q2M^NU cIT0"ALWPiC5u59Ve3bzFD{2%v$Hc8U*U Z@6)r`3|*Ҳ5w,2H!3P!TXVY(eHAPn![G]Đm@&Фמr!{Tڴ0T& &s;1+[`}ĕ%pqQs@[t@ 502-@(؃H:AB\8XT]n?-@&y:”AHayge|@.43uE`3CQbȠ< 'pU(w@ϱk\B? W At/P%ALP0TuHx,ap6Bn"/D]xç70Layf^`h) 6qFqȠD:Q5< 1gp7.1yfpX{3 H' d22v$R~|~7~mPM$sgaLudK$5;5K%ǚB/PRq!͵|YqfpyA|fZ']O\̍p br骄,l`?zeei|~9ۺ0bmIIiiVle$^KΖw2" .x7 f9KiylmJ|%)纙M&A$aDzf6rgӖ*SRm8qv ֻm g:9B1dކp*?Ϲo5XyRXe䏴]J؝w= 읫 %lpX炩:G(:59'L!Y8*yMMԀׁESZ#CJcc>K)(EzX"1\y%7[Rt'A[id_N3-9]>tLh|8fS086@*x6-\5(fz|5yh"-%3rM,c6kvcFvhb,=!DE""u$fDŁ& (iT,bkxL2! !e?4o%deeXoChF0&?{tfJ+/ Ќ(2!gWi(7p?XR'%!zhHw*Fh #/(&Ụ3Fg`SʳB%H#$ MƐp@[nԆnen|MsAuj'E.L8 \0lm&}skU[ah WV[g~ A~ \m\ Tq]GC }Ae~wof?f'cg6娋pD~FHpZgx VJ|D 6|WZ`14t'TcF#,D"TUPFGru.qX]tXe^s fxhtgQ `UnfR`9,S+дQtp|9v94I{\R\dQ\x%]s#י,YIvy9gEr5Pwsvt8\5ESS`IXaq,z)"h6ew4YBpr٠p9YY G:*["IX4~WzF% )}B)10vY7+YQF{7c1M{X /h'#:C_AW+S3ٙy(Xl9Z$fAB`!V+VfPF( U1>@kR'Y3ChA$ѡ"$zTԡ y HZRԗ*JAx汗Ks yHSyLCG>H/,H[E2EɲW4aɆub Av6.hS\3dJdPu_r*։P,v%KaNya<"%%ZIs^9AI.J'ZdZ5'e7z_:eXҖS2=WB"]iRgfQz 3$oȍ@g~!1)3]@IY8C7vnchSM\h",1TZl0 }q3c{gFz^' fjx1w7V0B@af4+{kϖlgUdlnh1Q|\~lfp3oL5۶(벯+Fo~Y^eG6B6#Ux&un)=$4[4a?asUB9(^*цLqȊ4׳JrƞiJA8b49tivyhDucr[V ;3cK-aH-NDaaxViRIaB־$IjpVKj# x+[{Lymx!MZ:yS!::7Cc, ^ij~0''2ʥ7- UP6T2[$(%y!gQǂtKxcS rprvSP{1A,?^ b5Biz gH'/wFw6fRaا6э!T! )qgE;wG4B8(XAHj"kZ*Jpay968, P څaGE5Ņ(l]4w >Pbʼn0Å6؊xRdz_*bNí/R"Nq]|GF%XRf >9aQL<6:V0mDXD8?j>:oi1>;-0ՌVGQanFO Ke)S!m1 qax!{yA 96;k#3˲<}sMi0R4>@ hck{Rֈ$n[M%!؇eK@@Ġ<$2tʯ%ukgѺ!!Pڴo;1ڿCIw JqL' 3v)AkC̡I_\;i<k.Ϝq+EؗNb3vYü-6lu\uRj(Sxhp _skb̍◱ Ȇbsf D c\H!l!<"-ʎnJM9% 4Y'{µ l1p"/r[٭HHYZ]Nnjuqa ^^-l^dہD~N &uͻdq8>,yaWKkD^s^#1'DH/an2 aZ ZZ.~AcGfe58]*)!o.3>n%ٴLmO{a!5gcxPX33:(VBCDom9;D0daZl`js􏒧fc!3b՞ I߲.hąh+T~:ڝ?($>JwA'mIﱾ#'r25lhЫlʪ;-H,qc@ʉL:{Hm!4,dJȂr \u?҉hl1@ DPB H0AIL4eFȎ Erbč']"T`JaiN=}TPEETRM>- JQNcV\*jDve9Uf, n(Њ`\ ̘8QŔ%9q pADqRxߙZHd/([0AћPRx@\dȯsm @*ŵ̼ea-׀,ZY5v_VA L㍘7OY`0 k-+h40 J 8 Р:" &)C3-:3Ѹj%JCgFo1Gw1!~L H! ң!4):R'h&(u &@$  0"'pKJ)s3n./737ZҠ <̆ ` ڴS> #Œm>'Ϲ!X 224(},2 Fa @ ʘ@ B 35\EՎ hӌB, ÿT@Wi.Ƞ ,< Z4h 5)O=(,xd z}{8`&`QPRf#a(3 ƫXa4vx@Xa4a HJ@.J eb`={Z8/J4S 5c]5Z+ 9p惺 u#{@/E$j*&(7Νa ͵)`Uc0z =8)`&捸N %[1b:2j \o;OT )65M/"&}#-B=oIOBo"F;6<ذHc~=F|7F}߇?~G ')ɚH¿{ANp5ρ`*؁0 qMФ TU)HD 0gAsBWje^ .t QyaXpb| Da ̀-ݯCȰpJrŞZ Dim`jS\I\bA3*X%84OَuK0!a[Si:ra`E:)*2_lK^җ&XȆ#/B1I3-*,&_x2 xY("=>j1"R h%"\{ i 4]X^hZS;7kG~i)ٙ(㩓vٓ3us0 fDb O3+STHЉexU@#ҳPG6c)p{N0i{NYDDsmc`%ͩ>UU fVUvut[u'1j o%"bOT*]IM@H+/K@1I90tR *M{@xϯBLҭ )kp>5VfpPqRɝ2 njW nhA%PMbvk툨8(UxEN]|ֻuXʩ2S"߅+^׽E0ydg/A&6|]h$ :'ř: Ln&'dB`$g3ސ'(\vp"2($y%qMKEL+#΄te'Dplb^HmKfԵ Ɣ 2U&vᶍKZ}\Sތ;588Њ[ߡ/ _JWҗ~q۳² -;M'4PmSƹ@@J% ?uAic+uXDAr<bc8CP!P![0lB.;^P#=nC21ں3sYp5 Y.\wo\U/ :ƓuEۨO9mۡI{ks .0l &p5Inԥejy?te>s:):PYZ(Nw~U%B<:HB @5l3c d \(v}a?{ѾRnfg61]( 1&G WPCc8erKpsR4t;~m"odb.柃F9_t4z5,} /{Ys & ecvU}Juhz=V寔%_  X@,?$+H@Q21kS\;,jP ؐf9aOK X h@A,<#f#Ē;bBl XSt,I,0| .BDQB3B@7A9 @@,?@ +(DB CFTDHXDH3H3ĹĂ2(8A@M/pģhEFKĖDA[1Il;@G\t?9H "XAdƒP]DXgjkFC QU;oR>d\3Ơ8vp$s49j =z5˳qFY70 ȃDȄġ + x;AcȡH,B42(ɓDɔ%"xTɥ@/@!lAGyaC2-9ǟ̓|%J4ʥdʦAK4P ZJb\4KʳD˴TEqI$q\#* g+utKɥӴ/ =Œ/x;tjȋD)(L<|\J> d{$4AAKؤMi2  /٣́14dJFKޤLNHΙJI|TNR{?d\Ot4O^4y! g" PIL4EUeu %5EUeu !%"5#E$U%e&u'()*+,-./01%253E4U5e6u789:;<=>?@A%B5CEDUEeFuGHIJKLMNOPQ%R5SETUUeVuWXYZ[\]^_`a%b5cEdUeefughijklmnopq%r5sEtUuevuwxyz{|}~؀؁%؂5؃E؄U؅e؆u؇؈؉؋،؍؎؏T${:aʹ%:#¢1L&^ o\٭ɤ9X?m ){`;w<Z() Mt&>IMh{O _+ۗ8%A-hV[jˣLU\yMh o,JȽ\}v] ؂o\y%]őξ U[T8ڣHۧ`NH54M^1-hK!\NX?t^W^A^Zڟ_)E EYC,8uM }[[ߟ ܁$ 2[8\] . \%ڂ*+sZfӵ ~ݓ|\D׭,O&`4]}K N)K &$Zf `k#䠌 ]` )&NbAJ$Π)?# |KzcZ\R ͹Gȝ)B䷩)XZcQ0宠ZTLƘ )ߨcJ)ncP(agъ -s3[b*n;kV2Sn Ea@cYF xGe3fvIbK`V[%1 [ EY %5hF^{42(J_ L-g=h8vVV8YH~>قh- l9 ܅M r[&VSI 5"&h[h$I Ve2`~&`i~eh. hRhjX &i6 Ih~.lAv$h]nkejMȦh&ǽglV3;Y-H u#x[m{mv5mX)^=-/$?". ƭ:fq@ѵhFn۬]&[@ [~. )hG`i\M~5&^^mV g?qם.쐗 ^l.6@N UެfA-[$o"Ȇ ഈ*Zq 'ViU^./l>gjՐ^=ܾpnHKrF礼\$#&sjtqv6$ENi~5vEIkd4"sN"/G-Z`"gVlbwV% ~\3LMhyb[_?ETwns巽aU O\>m%ܪvxkql_korfhkvl)xU/+ggVnlD?p g&v}$kuWWKiǮ m|(`Ř=5Y2 ߂$.~>^qWtq{g_v?6l2hhW Uuj/l9`Em3'u>UFփ¥6]ϪZ.ψqxۼׯ&[(q9w>-ޚ@#vL~_s{0 Ge.|(4Ymn t.ۏ ]N`ŦcxY݋Zz?K}lP}ڱv߁B0qV @5fq[Z (`{<`8 ޿I+qvl@SZgTň<ʥoJ`A: & HcёZYp{/PMzGr܂C& 5RR ?z$ўO'=)atà Z+c JcĚ7s3ТG.m4ԪWn5؝Kzh{`1(?2[Hjv:۽mO@][V3jo{[NS';WoE[$@|ebU{%XE"yMgA $a!Yxă\ԛLdT řt-"5?t5PtIGX+~WE Xb~[SznIPrzיeSAJtIލ$^7 AV*4ԙLvG>UxGWPZeOVx q]aA SYgij#ycyv+++ }CR$b^^P6EH d SGSB跅yf JQeUb8?]:޲:g6Uj{p+)i;pV[H_k.\aM_% ŧ"~z`=[طzV4]-vmڒS )-_+{W.Ip1uPx4K=&x~ Ҏ4"$:4aJ#v^7CRML);22~הJ@ /U\h q討Yb{6Z:#~;;w 5y-gO]7]AGJ3Aև摱pb Ң} d F2R2OD¦gvl2zў^䒷m߃ Xq.Lc)TWb?IKjUM/.jզ! JJ7$$=֒b0KuzU6X2(J*#l:A*= EQ&r[f3~R%f8X 9/@ zdTIYJR4U0/sLHjqSڊ" d&ŀµ_SC)Dz`D_ +w \GjT͌JaIP KX1$L9!tz*@]iN68!dK05A2%[:̻˃d;I'?+9}YXG)׹ҵvk(WiECb 9K!)zDZ* IB^*<^ΓHPZM  C'I z*/B Bbo8?!&$WU fE?Y.xP><)Y}tj-(z%IX d$qYmm;z#S(+YsHBHepA:KA3LjX*Gus#+SśI X'%MZZ4ם:,uv/ ?;E48#MX2wd\#KOs$W#|+iMP39Wwk2J3}$LCd"tiP15D-Ud< :H.ms~q<:n/XBcpM̸aͩԒ꺘+uSn<]&m:Zn~vǜ=W.6-q %iQ{-Dn~ l `yyo`l;ЫE{=͢=Џ,-O ?e2/2!0 nG)Yi"o=ՌKy H*'aue~ɭ50kt˸q)dDy;Va!Jda:Ď9k.:pى F l7e5\$( ^T1SNSA!b;)1ܺP/0н|ZIPhNI6Ej -MQ_r+Ѕe*Sdc %=iGy-#[JVٮ*WZĂ =Qg 'q#L #nh_+^10ƚp yL| $߅D YZI,4ϴHAݍIq͡ ODBtPG$IUDHdǴW(C$E 㘅=@<[!Sߐ] a%ř!/GdߑS_dF[`cp ZXš!E` PTُM a1HH<,LuvnPكMTߝ=> GT@ϛx BG(J|-!8*!Ɲ-b%BIGA.m]<*Zn~| |Ly#!.^)/ȋ(!q9%: D#b91( ;d)"%P)-nԏEi&^,>UH,#\~ArDEȢ CĈ_-JE ]/V 8~|W(bI䂘jt#0MdMdCDc!PPeGR0Uut`A D!Y9œEC哳a5ޒ&X)ՓKHA0 YZSJĥ英dUBTSl}BS+"BηWU}-BMݍ<\f< Qz%]l5azShXNNٙ&RDj&RNɉUA9!8EU) ƨK%)`M\a>g1iyZCHAp^eClU^KtJ(ʐn"XC=F\^lEۧvaz&pO Q>(FHUZHlhKQTphaODֆq(IUOd(z`H$jʨXȑ^H$1&aS" )=G(|X(yfJ)u.އŅ$0` Y\]3 a(*@m(DǚjPMHŀDI7ҜΝ$@ X1xHzЅ)d| iǟB *NLZDkdb;])E^N(9IZu!l$k Mq}|b\|Fr+fFzFb*k+df8ʷn+bhЫƆ++넜DlСpgX43ceI ^,fn,~eg`&BNvۺ'u˾,ƬlPrsiƖΓ^,-&pI;l~0J,Jv~-؆ضF@(fkylRNؾ-ƭ-֭---..&..6>.FN.V^.fn.v~.膮.閮.ꦮ.붮.Ʈ.֮...//&./6>/FN/V^/fn/v~/////Ư/֯///00'/07?0GO0W_0gp[amr[o_ pp 0 p 0 ߰ 0 0 0p 00 p 11';q3S+1O1KqogqGs1j0$L11ϱ1߱11 2!!2""'2#/#72$?$G2"1b&&kr&&{r'(2)s))r*2**2+,2---r.2..2/03111s23272;ZoV35_3Zs6cn7w38ks8s8397s:8;3%(8HzXzϷ}c~777wvk@W6A} 8!6ȶFn k-4t+w5ۄ^n8:|!gb|o]0348B/jD0|øCTx㸏99 yh]Q4E3]gd\hvR@UI|0m9R-{0y0;_Wy9[QPӹv˘yy'{q:9kōM} zz9WR7u_95SoGWDFI9p~ԅ2 g Sr˷<̿ǼMRʍZa`RmWqZɪsڄBdĘa,ed fvYHyǍpMSTΌG!K[DeCaUxFnd}E`Rԏ| Fx`ƎVh6ߑ|U7(?70 }lATrIyg| t=T.u{whe]ٯ 1xɅKϸg+ ){^vAfJ/y jvn>wSW[g/K;eǟl6 VЗ#:[Ԉli 5h)RV-*Y7ڠӰ"k&uR)aypƍ-I*"ֱ#{u߹xыO_~y?~}׿ P ,/Eb"R  *P>G %`p$RJ@$L`v*$ÁT|0`L,{ %I#KH! G>`Z)NȞ ڂ.~~E461B)$!P?i2PA1LN(SB0@OOhr,6?qBi#R36C9=A201A n.tI!2?Oœ(F }H( h Pԫ[5y Cb~tŤGDLHrLRG3֒;ZQÖ11EUjĚm4, TIQ#bLQ՝,Ep\Et\BS%ԖH7NՊRfhN*hP wWӚy׻p> SlܝX^# 9Һr#7yQ~r+wyar5͏Jp~>R>1 \LqD@m%g]K!QX-a-ыi2+!EH3* ܉6ѩ%$?i瞈ޜ HCh^jdj(8k̊j#8ld{n {p*55^Ϫ,zC F 1+Њ*0T~>( *YnEN.'!rp/2$;&<:zeg6ڦ^:>'ZK|*Cpp p P հ u0pK?Nr(H$Dh4ef|ZIҮtHRb.@I "Y剸xv%d2dڀS6LR1H."Scv"u4~̊evt F,m$Si' flzAq`|iOdGc9>N%"_EVE`LBZzl R 2 !Zp")"fNM,_Mb ABB:$گ$NM>f֨k$O##DtFr>zG,âO6L2\!"6 moR^M~sD t'#ٌـ)J`)>'-{m@1¢GtCwDgMy{ΦRgb$J^*y2`  -- $ny{$pnL)o4 C.NNMq jgCB'mEr. ǁ"+5S99s8s9s::ӂ,r;;3o1$2>@4!N(3Z" 4j< D84'.oQ2QD!Z(2,5b-s ,7CNPODO)H.-h,JNmA:œk_"%O7\C%s0247BHގ ':.!zv"JF!J ƓX_4c!@X QHT:H2c,j,oPP?P/Q PUQU3R%uRr22?BD(eЎ@g%P %d7@?³@$ȔDuv2"3&p$ťRJ6FB)o*hǦ:'(H7Sڄ*_kz>Bg#"&,*SCfKi*+H(\j$[dj| 7FokT[0.n* bY*9TGrG6(I)$McT3y*VOl`%<8# CO6o&mH:0 0gqvgogݐgV=(uhh#@CL@i]B@i1##1>0R*ejcO0jTvˮ%nCzEW8lQ%i{LTn XEOcn)j3iwlQnn $k6rWnqr-pZ_V oB~oѶoKl/OIDqEr=Wrl)jk vrVCvqtq9wlpO7zz#z{{ hw|ɗ?Vue+}C}9}38pv&h~a )~]sMǁذ:E*wǀHǁH~ˣG.17AxEIMwY]?6d'QIKEʫbQ؆_ vц*x8N^gc7uNLh94j t:ȬX8h8) $8Aύ;}Xg 9 y%y)"̄ L{99~CyxהKypo{INv;7]gk9w}Y99Ϫ9=qLә!::Y:yفGQÙŹfrߗyYY˹yڟYqy;iuX/ڢN5%A:EZIڣM:KOzsɡ]aeY!yqڦiu:yw:}Z{z:0bzU9Oږ:zZZͺ{:嚩QSخ:z; { ;Ix۩WeطRwY.{7E+;wUWLfY{=۵C_{mo F$f4HzPzۨLf;94|OO^{{ۻ;F:FyN?8%!j+ؘS1vHZS,6aFˬ{8~@HsHY <ܿڭ/79<1|;C\%Qhkrhg^7π+[Ӵf'(A Gۢ61\ȃh6tKHg">sl<45gl҅XC 7Yq$1L@¢xK$-&!H#ZBdpV"ބp&fXT+ $&6. q '\TG~WiT.`Ċ l lTHja35nm(W"QbAƿV{ݿ?CB(ePBJ4D[ |_C)[(  #EDLoƒ4Tq&0IdP&Y2( RliE*HTŞ@-GQJV `VlZ`5")&*i߫ "D)]ZIױU[٪妰E{ګ(/f&QՈGnE:i9`v_JedRsqTJ]Z4ؖ ))ݝub*B[@K.襵ϏO~~   Ƞ &NamA]ana~b"Hb&td=dDUReT_4F%A ?4)I%padԊ#)?dj(]Og%f]5jkI'Y`yEgehg@|gCF{`YOmyLrJ&wfDQE?5'WXSA{J`:`I&hE6k:Ц[ٙJP/fuڎ.抋n ʋo 0p /pؓG Dx9"K@0DҏY!MAVU(!PPCn#& K\u)84=}pZXYl냋B5˃{sĥHB"$ތP%\fM.iHD1I>5&k(țԿi64T=QOCE)5 fēB$ ք8AFT Y!"Ґ"G2r#/ILq$'-Pnr%$MIQ~ғD!^ Xr Ⱊ Pf:aC3F*'Xrm6x&(TfYhv$M3 b( dQ@ O1R02eDPFHjFkHWnkTrJ9F*3Q{CbtBG)Q aQ1KQlwSD ԐV5Z :ƭHh$ز|*r tZ`@ j`eܿJ`h{]j׺vz7yk.wm/lԎa5eURC@*~r:hm"2Ai#8CRX jp~ES`9&JMivk<GyKKs´E|xj;42j 03U{˫جl?DgH9 . NK "b6dQ{x%N:h!JLd2_/zkf }ӷCuZq`6,N4#c~!r:Bf%Ģ\N+DFMKT^e⅁ȁ؁!/g'J1W Q22,3$ @ W4|1L19$]D @ ``e"oq$?aHaH7k?}U5ljE0MgɓE .b`kZQO7DcleHe #?g'18dpCIV9heԃ#}u*2+;1 66: hHuA0+_tfZV6abZc:g~hHhhzhĸǨŨ;)h׈ #EM?*UM.dv2|[GU.#DBQ0WPU$iv/#RBQg䆮Dl3M"2HQ)Jm NEDCuBy5#\@ (Hn$E0ѴXTk2fDB3 i+#+?IQQJ|EX[%IE}vDwPU$)qCMJ;kӛp@ 'KỼ+K r+W~@2]ÚNAǷ8rDöIAu *yq O %G}'3J0={ ܺ#.*0_8/05\/Tc2l[4u-YrvV1l2\5.JĖ[WäD`Y YQ ܼuV< ͜FAZD~ WTI¼_[ܵa,аDcuI1q2C񾊷L$a Ka-q$˰ cM"]( !Btu79'&bxV15)5 qCЄi1tL qQ>" "cS ҼwK],$Lq3[@8%!Ιc&Zރ\g*4,%8JܞIo|9Ә7,uQ7a\.xbES+bd>)c7鱵LllɎ| 0f|0:ABh9n=aYE]-c4UBrE߸V2:!Wd_MT1;DãTpݐn@D>Z%I$(|~~dRi*FXw}%p%"ӌ=3.I^n}o͘5c|,MBĕqwގ">CEgqQc#nP#$E'm7;OCWT[jZACO@E䆾o/'bKc,9,6MQB6mb~17 s_bs;Lu7a$"CeGXD7Fy9tA||:(8k7j2&sTJ5i7 %  8` IkaB) H@+{Z$9$ămjXv Q 5_ƃei"H$5IHeUҙMm$E!KY()a5&%RE) TioKJ-'V:"¦d`٩Um)MeLG{kǺ\]˔5geЛ?ݹhӤUfykرeϦ]mܹuo'^|_KMbܶ5o $vV_C%ed^K3I^0_o\Jӷ&ൔ[bW6~{Vd[hr` %Ԑ 'TI  #O&|íڰ3Q1" r+ŦP$(_Xp:p$6"_HCNH2D0Æļp+sڨFk9{04S,JL~3r;<'GsL4q`sS!K|S8'>R`!SN<4VDgUV\k͕V^o^w5X`5VXdkunYfuYhvFL$m"E[VŽT) BM^+[n]ڱ[7: k6n"N lN\nǠ̫S#8WOVI4#JXN+ctH%*2()<*4"k7&cꠎ k*!C BSɣjc`h}DRȐ¢1Z?bnqλk)3k6m~M#!{]&+ӶMB!rV=[G_=vڷO}Y]w܃~xa^ujW~yw>BRh7Bgk9{m]$>yeJz!$`#d JHԒ DR2vdGJV&M!N)ABD&T|t@ X+$h N8)&),)l͏>((p6INrsD,vO7B AB/]M h ч+b"]HP\`H }]sѐ'Qzi^ qA##IFB{d%)yI0Z2"'NnR$'=HTyde+]JiI!N鈴3ۄ+,@:|@4[Wž-#7hʃ.GBSt @DLw cP)Im6{VɰY]kZ:ֶvs}k]x&vͼ0;c$Vm|`LJbA0l+]BԂ7_Q!}_˲E9 |vG| }6Z4hc]7FIHȴ).$*8hCS_7(MMLl 5L ڨk 9Qs7tRma3&3$o#S((Qz>F䀉8!~ow]NGa}Ak?yvs{~w{w>8VIF*BJgl`: ^>$#0l$< $7.˻H6lE%^&aB[+X X|札wJN9U!tIP@O+ no+ eXФ,EFH>rSQgxGe`۟㿂>k= H *ϋ?) J Ћ:Rqﰈ.君`=נі! '>9l#骁 )?Py@w V*1*+)D)<++T+*+B,B<0 C1<"'(C`&)WYƺ9C(, Z⢊bЎ sxG!X̉rGiY0Aq:^\RY HE\ C3z 1~!R\D˻Y6.`s91CJGc0* [HlHdH|ȉtȈCH+R $` Cvarza&?l#C7 %ȟ2 IY*hB1JkDYTRvj2x (sG)YJf'+)1Av(8xƐi4V@ԋ f2k.&בa˜$)#w J‰wp)Ė܄`N` }`U ] ` `` 6>a>R^^^^naaaaIMa\_""!6b%FMb&_!nb)](b'b.m\2NcUc4^c7nc8\3c9f:~;c2=3c@&"/dBް>daEFdNdF^dGFdHGBdMnTdaPPQdQ>eRFeSNeKdW~9b-*~bYYeZb\e^)e``be„ee^mi`g^`h~fifjfkflfmfn~;ffp&KJ.q> r>gs^gwFgxnx.pg{ U.e}g~fU}~&hv{Nh\nh_>ffa&f6F戦hh{c:@c^inv9i;f问ici6".pfg~gy ugmNjVjj6j^hj>h6h6 jvi~kh빎뽦Bff>lNl^lnl~l9®.j̶jͦjl.+>mVk^mnv~mَmfREm܆kmnm>nNnm_顦jninooV^o-m~oώіoooofo]ضom/p?pPoOڨ_p ߟ 1 p5ppqq/q?qOq_qoqqqqqqqpp p!"op$^nr&r'r(r)rNr,W$r/r0r1s2s3/s4?s5O0G=Aـؐs7 :ߎ7s;89:=tA/?'C7@OtF_tGsD&tHWJOt8IKtMtJtKFtS'uOuPtT?UW/VXOuVuWWu]]^u\^_sbgb?vcO1,_{vvhvivjvkvlvmkvpwqn/wo?wsOwtqCvxWvywz7vw|&w~ww2gv}Nu/u7x?sxoOxWGx7wx4x.x0xx/yǰxyF=yGyx xz>v_zȢzozzzbU߸z{c?{xCo{{l+'g_{r{{m/|yo*sA{ɟI_|?|ŷ|Ϳ|Οzw}xя_}wo z4o-wM}ܸ}5}_v{O~~~~|~~|g..~~'|oW~_y p "L$A &XaC/b̨q#ǎ? )r$ɒ&OLr%˖._Hqš4ỏsΞ<ڌ)t(ѢFa2D)ӥNB}*5*թVb5+ TJiȨ`ϢMv-۶n+$׭v⽫7k~> t„NLÁE ,y2ʖ/cά$Ί?{>y4iRj56mնg?=K6ץ#[.|8ƏSf};5̟+wk֯ z+Q'ϣO~\/ý⿯ѯxv}X"8~ %pW!%L5Iء[ZXb{"lt/b⊬U}}T?C"$c4icmHXt}QAXW>V m)_qV%ybRl`on=斦wgZo'8Fej&bnuQ2ڨJT&BZiAJd0nꤧo(br*꒘2j褷ړqګ~"gQUB&Ѣ9fNx&߂neے뭸^ڹ[Zﺛ/>iTZn\) {x,W` S\AqĪ] bֱ#<,!ji3\s[38js'+LC]4K@kk|o7P%pWc=$OsmuM\RJ9LtbE(D- DF;d H[J;|-mJJ)E(/J=(Z$-HaB2l#a޺|댚ޯp\=RLDᅱ6%a=$7)n$A+㞽 r-AK+OQSG0~&_I(0?Y :;&mޖ-c}L<2T{ffjݎ) R JsBBtEaB9ԦqӗV($p4h#J{A3-S; `(!7Eq N@횢9MhbKV(8E &ڈoH8V1q RPGI" #$hKtY6*LkSD):aRi$LQޕ0%<ӶQQs" ?BHc&2qhaDM&[PH" gL()ri*Ȩw&a pKkI(Ԩ6jn){Ԅ@F9R!HHg3u(!Iȭ i-( $\ :KjR\0}"I")ƅ'Rp9 th$-nM(q(1@dMG D gKUs[IܰsJZk:|pv9@.Dx>"R@iѤ>TX4qyZq GpG+v@:y'Zv,%Pt'V)ͦ4+UW~ 48HQ(0(Zq1sh qTE I&z"X]PeLhM(*t8Wڇ!x^$ rI!Y1 B@Qj6wPd {ؼmazJѼ}^+"9S"^A.La[x;MB4j`\mЌLfx;bs!0Ì|s롉lEA-uɉƈ{Z31zDPl%ag"?N"ld22pXˊɗAcF C c6m s˞&ZMp!?N@GQ&ٹArT,^B 'kaLv:VEeR)+QM~Hט- |;'xPQ[h"%Sxوy t BJ3ʐε󐗊##+Sm7B@D;E vD!^N/j('fȵv^ 6yr{H JQ؂& ͍bǺѱyw# Rt tj̴){qGĵdmM{I{A-u:ɩ $Y1˚ xAx(:HUe'+2W);Fz@m蕐4rާA(ԝW`o~wy;"߼ _|4'Fyt_0rǞZGˌؤx>p⟿_v?Z@؍׍C+J ZD=^RPQ&؂+B;` P` V"uHP A _ I F_]` J ALR!E:vazqawT!`aiql]KLEQ$TM[dgId!HBaB$!b!BaH$l&jM&l)Kx0DIDe I j, )jA)BURb/ .RFhx)>ba3>L;j]I!HHKDMFP=B+IAD_-Ԃ=ւV-7:DU:VKp$㐂A(teD_B-PuMDH„%:BPAlDF$KJd ߤvdK$e<$lhFF=@ l=n:tWKdCdQBfDd0"lLNwi_Am*n$ k^eQeDic+BЂFlcwv\)(&\ Qmu ]A-(==z'QHnJn=2^ޟ!9HQ)SA$8@ jbDsLajdFEpA_DL@-+}+hA,"Al(E4bf :)f+Be C'-<$ZAB呎Eh^K:PUNH'):**Uu D/KQ#UQBڐh-AAbPS(@&FDh)^fę@#fzۦ""֏PFjA);Rh$awf)iIJ*ő*X8I@d-JO)i'FKE5-tNAD+f &hr-ЂcBPp`;(IlqڂVD?gKp U=i*@DB?AjDQzNOeK$l|.lD#`ZC[Nb.c>]W)T Lp'N㑒 ')IAjDdHBnb)NLNvc$ mH&m$ΉGtuWp\*FAif8nLa%!AZJEjjD%F݂Rjbe+D#JFED}]Frd!9t&@׸YeʲAhaaqxI6>*KH jGѡ(p8yLͭ*.c&҂T&D DL"P<O&J,"i_`jHdi&Wi(Ħ}_`]B_ԥ3_&w@+c Д,9N(j`, gMD% p,a$zqA,aD@AtQp`$VjmH%O+pPՇ=^ q7Dn\mHqA8SHkٮD&sDvI&s'[ ڄ&bs"lƪx@f/1T'ydĜOdDͪ*ʸ+hu>7_8:HK(&@hi /jAfɢlAlBl=J\3 hd-e:*J$p#D;'({%h ׁx!ҕSR"`b8D3њ *ܵĄR@a~?WR] Cȍ  d.}8JE+Eq'6Fu>iF0V@R; (JANyK+=ײzxGd#LQV*Z%UWp__t6P  ʿ :cRB1󠯂ĀR"p@$ #%E%&@֨ "SM#j`U'F IfL3iքِ+6 ds!$#j$ZcHJpfTSe,؆U֝/dSRh:a JnMXeV($##DWP61?̆a &iLYj 5nf͍ihVncVmZ-m2;ngRY7kB R$7xr77wztөWoαxvI {~<ɧ?aRRRN~R]IQ@q h.R~!-liŶk[[\epod$ЖvlYQE{ sKW$ )n$ |"z!+RVi%=ʎ&X¡MЊ;$"ƣKnǢ228 N*{!Xb+E”rJ NJ)%R8qR-$:0irQKa2.u YtkG89$}g]~y_$E M~p 0O)Uj!bI`" -+pG%A(^š `E4{-E'R-9( 0:+I1*QrB'-$_ra(i f#NJQ427e#+L@(O8d -ꐖ8\2uQ&Ε!c H$i"[(2=q5>G8>.q$BƷGd)Mh#C/rI,n!^R@@>Ez(hi*.DB '"R4LY^4c>ovSb˘8fFbNP !d j ./ha!-^uH" ¤j0QRP+nAt&v O",(zEքQxNz0Lb0q@"=5J J_ZҚn.LS%1#3dw5A Nr>@0#^OeR,fGU*cUX+:"mF[7{p~a^)``SXz`E8 D@I1@'<| VĴl GTR4QQ(O{)D5i6V&汭BdBH_RE$XД24,@l"^%9R&*s$XM/S;.0nf d\G| -rvȰIZ:0i->NuȲZjk H )+e] {X٩\5$pkRׇ;Qḫ* h+abMpQf/^Y]W/o DnpBSPe=ͼl2+TB9yHB#T'xkRʹt&Y@㜷J5N(D,&[(-ŌFwfYhQ:*$-..y.:b5?yS3#vB`o mU8/P;ձ1[Iκ\R$,^M,c71 bU㥎sBl̈́z.ۻ`@[ Lbsչz|قhYqj^OJQSpM 3LMbdmʧ0Tqw(Y.Tk[ E:}*$ =ʵ@ Ԇ\S$-hUI$$ hN:6mZ(02RZ؂yOIZ]-<7tvJƶx';ja4sO!"^i VM?η*RЅkGZw|jEpެ<~Q~2wMRx$ovXI"5Mn,'^LwcJ0&]: yBV $#yy44SR 0AzjtZ3"lAu{f+7V(!x8&F` Z c um[a $ nHM7HL0Eppu)Motvkwy=A;Z-TiUe6eL&H)A@K.gx%L>| A&ً6BXfC9= L vJA..&Iڄ$`q4I=7!n{ق~{z. õ$xn(a cu8׹e78Wdך2ط>dw@Ծ/Wfx;; I)`Fsɩ3Imf,|sISPB21B.%ıFdgeH7[7ő-\a,BoptJ"i֋(@kWjk["fx/=gϔlCt:H c2&fvc W ̣|{lH+RDReqfSeNo|SG) uDQ=5+9< آ 1أD<";-o^lw~sEղI1c;5s]c$ !]9n\o>\0$]$'}ɠőGEG¨IBd!M(\E2#\QO>ʊalX֑\re $8*\ȰÇ#JHŋ3jqC)$ɓ&S\ǎ0cʜI͛JH'ϟ>*(ѣF"]3R8%Z+NJDj)[r%EEJMZm*V;)pigkKdҵZ[m7f-q"|lNj7ZV$eaJBږ}M0%r u?`GXyOy&nhgE_~]BF8˟O5g6߀hK .`$E)HURA\SMQN+ 4a9iԆ=dXex -bA'_-1p yBpRFAG2bAxARd%bsIIZ;[\C7ڲAMHEv/9BmrA{yu29ٟ$%$5Tou(F*餔f馕v駠vQn*Tɑꩫm>U '!D+l([XE1Xei/miXdxIA+jDocZDZ+KFdjA2Bbnt@.6\AY;b)l+=Wȕ 'IA2WεPsZedHM]61rjژjg-'2rndgF!*D13Mӵ&}TWm5Fju\4U*+v)$&$)攘R"QbQrAmXP;'+וAw/{ygpK䊔i^٘\"6Ћ'[[n셻e9<'f"&1(,a?5{F_mS[w`og~SQu$RN&E9Lay @dj[-4'J9Vb洉խm[Dӊ-1BrQ2=L猧 * 4+` ׄI`!O@3C$q!g W0.KX2ƇI{£߰*UO|LgKNw49G':GKշ> }GH% (Z # dR$[dI9e8eIJIFd(A٤Ny$u(ɦRI$NhZ&%yqH ;Bɘ3U/%;@S ' ICzOL'L5SiMNz&`*5; L&U42sA 4'L3F)rA:с70g|PRP]T5K4#iCwBTd3ؑ)8 cL f^)峬9;PU}EPBy)EFЎ'4δX!eMahC9h6ZK0ꃌ %h1nz6! } &cfMS ]P-6 U#pLS-H5hƉ~UݙhĦ)>gH+%71,nD EXn.!QDxhЂ?VЂ v6ZlA˃rkB`̻uRu*Yރf8R ` r|M/J5:9?VRehjW]6/!5p]wk@ ҈ fZ#F4AYgBRd] Z@9*$M` @͇͗=TLp.pH+فP^]Hhu@ *-<բt:5JW~MG=-+x2tO 5h$5_RT@w!`Kߡ$+8+B r'F='*\o0'm$& 0el&fUTҶ} Ҧe<HWJFo0"E:Cvv2GFm6.FpTh H`'x}E.Q2E9HE>7@(Sq?GLFu}c|ҥ|gNd B1eTD 8m8sSAj kkXkrQB8wjQ$ ! $dP 3Qal?7!kg1!y0y F}3<p ]ʤSP$8H"z^W @hzxg q$Q #np~G}lynyG=w|7n5|yoɓ1|'ҎZ%A&5bqmw ["1YY:`;"#pE2xYY7;/qpqqRCsxCgwK qxZ /7EfE:`epf@otgЀ&KPsP9PisTbi8l Zi\[!oxt&t:(Q ux惶u/aǘ*4s6h(0N_Sg3a`4:'5hT1hGb)oH\*Əe5r7 Fq-IYg{KaJQw1I G[wp .K$3&Wx[`e"ydmZ<peXl@` & ` pS@\p?z]@]QlQYe@ygPlzLw,#$rP2X6m綘L{Xs8UXHYc_=*!>joXiI_RZiM("YTG/ґgA9drOApEtO@ $g(Bޗ2D1tr0nc0\6(Lr/$[!#@Gz1l\et_9J?xai0j ; Ps^\t:Q#h0'vI D3113A,)bua;4Yu7X>fWWES;hU'F8YCFs6`4GYRbfq2JXդywdq$p8ti5H$A #n.)HWiWG్zEkM&MR6P3f10fJ`z]P*g60VMK$Ҩ5M(8{9nB85z(FKzM$;(ꖣVlVnvR'D(;舎MʯŚٚNB#g '/{DDq)qSS~p;: H (+xO(ʪtZs'\@HfVPY@s :^eջo@hR ZteGA*XhT R3d\Bh2iAH ʂ־E>jKa5Q뚂pllb)jqq"w9v!"3|p!R`p[1|Q`7nfa^]@FjLKh op glm> 6tU I$ p BK$GLR&%&2nwxZdtki;8')$.lrKFnxw|[EJ_U Q .PW4xR9P7wD.!*tx!§ 4"OʔhA(:>z;[tS@\S5 p̫^ж\? ZeO[ tPNF/}%}3UBh1st5GkGAp7ڄ_%q7Kv ;@92(1A#OSyȚ2:~7,5흔1wXRING|OQ=WVK+;cm579xZP<$ӆ{iPc eMg&A^LuX H/KslOm ;=:`kaY,ٝ5"P"C5q5>AOr"Fk /҈"g!O6tfYs@e@Nr- L\Ys[svtI1@$@ BI%A$ bK~P#EŔ-?z dDŽ.[6MG!{0Ufb t"p)L@@ .]pXbh.P ۏ耊 HLC,Ėvr9[Ha%RjĵcǘtƖ l2;D"SHHHFʢr!/̑L3D0dM7߄3NN:3O<ԳO<崩[`dҥCbFTQmT!)GAM'{F)v8⇏]b/8 L;Ɍ9#CcK~{ X3 e)9m{z%F:iO鐟8iK/ʺ뭵kæc ZZ Rldx 3 e{%3P |{9(껰W0]#Pȃs;tAh .@1O@)E*׾ @8q i;_!1Ђ\4=FdB18Є'Da U eZa et74p-'3bǺ!шY9bڰakX6%V1. agE.vы_cE2jbD$ эuJc8G:юzc яq*c H?ҐDd"'HFNI>L"N/$H! BI:DTxJr[#tMٰ8-?0*R@Dʏ0Kkc)Kd43CiV2-Ħa\7Hn6҃;モỴBtVlAw aL9zR(DmE)>!" C/bP&E }(0 PԢxRъ΋ ID4z$iJ3>=t.Uپ@a X*f(! eIy { ~觊^+l! (PC )6['Rma2 V o0\W)2 @W3$ P@.paHiG[MSt|id]>ֲO2if_h2!XYX:62LBA*3Mel[Qm!&CPJB%K Lq/5i(P9 axj ic^Ee̻^Xӽ|/5uU ' B((ЂpKْ*)b`*$5ѐ  ب*>)aFѨ;UWa!)q2@UR1˱R|'j &܎S)U]F g0sD@AP@fλūh%lr{_67l7[L -|-hyJ=G"HpI=$B>;[~t6[ #m<Vԟ$lWl‰ōNw붶hp3Ɲ6^ρ4&vqxjMkAv1)V&2Ͼl[d׊H0{!ua Zr垡 N3O\;߯w$YO6D1u95܉?dEW!^} _Eg1,xC&UH!~iR$[0 {pdX)PGyF- #t 9S!Ӫ:Q" 6iωZ^ԼM}|) `գ|}D. RX=$'ɸn$"p moErC -+Mœ~9ǀhS:r+z PQ%%NQzTn'%R =\'Vh4\ ` j@V2wP06?x@AAAKd*"2ȪvA[AA΀X¼Ncn @0"BBz  `: 82؟ٽ+D(ՒT+j>曞93Ij38C=t3ҘȂ ^A)GVXȂ$$QBVpQE8UښOxl `5ڧrz*,ō@;ȧB: s ( 2Ј9V2Ԁq3Ӵp j%[iif#GΓS@-J '{39. /%87(PY ӥ? /0H¾*Juо@M`*u틀SSzJikc%N YGhǔطfC(d0BVB  #Əy48|C*Rc œ4{'nqB}@K43X.3ШXR)2y3(8wːK+ ̝|C4/:D>85PL0@ 8?(@CKsQ\UN(@BQ̩ 8:  B*R3%"R;( t*ɀ%@-[QȂ3BD=D 9rQtXKXVX8SXp[DDŽÖԈE)9] R*etF :w@P֔ q_**21H.`} 5elR(Q;Q `8D+ux2RP 𮺢O}ZhH7W IXkz\HW<MU>ʖ8ԑF˂ճL`YU]֭ʄ@1HJlLĘ E\]-|6T,*YK\V[^}_ Ău<ޚ*PIͶS!Ҁ[%\ -rӘMvMl<wUKsl\\j`lR`9}N7IN1p?MCN 9|I七8aQCN;Q6 țfPcOHW3.5<` zîuy %.2 xOP,~ʥ*X!?DQ`D1菐Uă84-Y4DEERQp?GcEu 댊HaD(%_;܂la%jUZd d1:Z5br\lEӱaa팭y|6 1>uF`AU O 3^ 3;38%5S3аN냦ڞH%ȩXʕ$O>m%IQ11X> --^eBe>SEXYE ,UUp(HzN, OASKd^ÿ)-<Qc!qB澤8Djfyo ?rh ),(s>Bf.(j/sR5 -gZNr@ar_#qw :PSj8sRu -?|ӜwȂ3'+xL]ky9P=(La@@dk4@[( rv<WgcZ[-?A9 ?FkxD;g tBΈpeh#ߗW*?SyL ( qzx~ÉnOvOY{?ZfWNIC멻r{ri`e"UWpE l=pSr&9cig`^Zgj&OOS|[^|fn }|}*;+Jv۰/ٿp1Nw\𳟚h?~'~;?! ϐZ~]Z]6jY9['^_fHg#wn9Z&7 B^op9V (h „ 2\`VRʖ"Ȑ"G,i$ʔ*WR%̗2cҜi&Λ:sGB-j(ҤJ$a)ԧRRj*֫Zr:U)ذ !ʤEm-ܸrHJ]dZmզRLk0^2^1d'SlY)ԚL5lsL2El3ϝ/[V:1AWN6nR潛ps [&^t+V͡;Nڷ {LJ.71Y̳oLV[ɧ_b`￝<  vxe`O 24^@jJn8"%ؐ)B-6u"1E5x#T2#=#8E$'Z']t5ْNF %uU2RYj%]zyRIc*xte%q^&uy'y'rv')",b@$bRJ:)xz顙Jxd'~ZIIiO!*:jb+A-UkDJ;-r+SfkPrZ-y'*J5&dK HeWJ.{B[.) G D!R9-m30w:0ı껱.UT9Jf2-s1̑Ln6)mɤ3+4#r5ClnX$Ire}rX_m#ڞ[17@>EdSAR%J5=87r/mxc/~"d-v{9Jn9x<ԩ?:=[{h) ^:~2:~|S2tnwNҼ=,#x^xῤ|+j0`ŴLF 7u???׏kE;18O'{y W@$Nx^V0%ex SYsTأ/2sG4W!P% (;fCD% >XF>Do'9.-SzEr[ B6a|#u4ƍqD/d1xgPf$BV#"i l!㑱CXE(@$(C9ZRIbHn[Qۜ0~4%.sURqeH5n0̷#G>6:y"hgrIc)pЛi@RȤ~:iyҳxTRM-\NI~'B:~PTh[IFfNb#D3QJ#A*lWzGSϐ.-JBI1)NsCS>t.l(PWȞa(RWc\*TLRU2R K4㙖*X:pz#+RB%)LA j*׹o+Q~-!CP{K},b1P&$TR E.b-G,^Z+̢DSrJ1jֳf,jZj$M-If6'QR5;$܊D_/؆hR;:l\+ݍBWteHV4u.xiִ ok\ۢԭܫF>._]*WEwVF=){ׂJ;=H^ j)5W?&_ >;k8fV\I1b1x%_*43gJ [WZLrqgj(2h,ۺMCxPF-jӇBڷhS;!Ҿ4jC>ȶ-nv߸͝ntnoxg~7ݭ{7p{<>x-p' W8So8/w{̕aݧВ&/9O|.o9_.|69o|>9.}F/:ҏ3}No:ԟ.S}V:E.3U:ׁ7d?=`whŮw{, [h~؆/<3~o<^1;3kCы2?߅+=}/Jro{s>/WU٦??ӏ˿C}f? `I_=N_6 2ZVN& bRz`z  ٟ  Π ޠ zU  !&!`iZ`f r~Za^af!Vz!N!!a*!!^ !ޡ!Cb"!!&""V:$!%Fb%v!$bbfZ'j#n(kW *"*.b**"+,,"-ޢ-"..V+0#115!'.(N'v"(:c4^"4V#34Zc3R5fc7rIy1#29#:"/c/#<<#=Σ=ޟ >>#?#6"8b@z6>7"ANBdCC2DjcDRX"@n?v$G~G!;#I֣I$JJ$K!FUHL$MΤM2N$OO$PPBZ>ޤQ$RR&(S&DT"TDUUDVVv%T~T%UU%VV%WW[[e\\e]]%\\%]]&^ ^aafbb&b>bB&cJcFeNeV&fnfv&[~f^&afc&eff&ggkk&m֦m&nn&oo&pp'qq'r&r.'s6s>'tFtN'uVu^'vfvn'wvw~'xx'yy'zz'{{'|Ƨ|'}֧}'~~''((&.(6>(FN(V^(fn(v~("(fȉV@l%R΅h\$EL tr_Ê3 ~8$C$h[H@ (Vjm&"/ESQtEp@EH iJ*@U:)B;0*ELT"X2[V p Ģ*tLne`^jZNR^2 $>%)-*JR;)0DpG@x&D4Ei (识(V*)i2khf| 쪰*2+>(BĪ*k(@`&V*2,,P6lP, z,n,r@\,R((@ t,'Mʚ Ax@.@N6mi&@ɲA-(ܩ hDv@m-ږI+ئ>%Lm-R)-)Bv߲mi mAײvbmiA:ѽ"P-v@ŞbeǚAdi -&@I@f,tBhjU.@L-- x LVz+*,.l$@^r%j@ lӞ.ЭF-VDt (i LA6H-X+*FBTAjpBkTk&fflff#7;ejVRZ l,Ĉ..'K/1Bd |1Eo,jl 3RD.¯,,gq*2m7?1$1+$?$G2%c%g%w2'&jNW)e/v.qA->iU rD@Pfp6kL0.0i6Di3nr3LA/wW2nް.$@92 -3<<3=߳>?3@@3A@tAtBB#C74D3D/4E?D[tEG4=•Dn 0fIX1vDf)@. +oo(L An,T/Ī"ICF_FcX5YY5ZYuZu[[\uXJ$P=?;^3`^ a6ba+vbb;vcvd6cW6d[dc6e_6ggvgog6hv`vii`^2rh+hF+6#DN rA s*D/S0.{20L\vsw@2 ü:k6k6{?6jd{|w|÷zi{7}8bwt{r+/78OWx%G$Gk, tN4 rQ(Y_eqPcIk)2D+'O%I/_Y*k{2_83#yC+yrC*K6rVNTr9mwĚ A~3+ϲnwSAj.G:lM[P4 KrNoQh0,J7~wks/.JKNj('߯Q#Jc¶h.mD*Ȩ)9hJt@P B0TpT/6tϱ sS.$km봪 #y/c橓qmgzA4æm.VSlpA./[nxA1j<GlHs/9ȴASmn7'}d9uT&r@u ,4[ m7Þ-wlw/ז{DP@՞A( |-S@膳/~PAluoBƎ8/IT/..BzV~S{\l>Ŷ>>Ze*>oǟ7DA B(J0?B8S>qx_xLW7=??)|}Ӵ~?L?${H???@ 8`A&TaC!F8bE1fԸcGA9dI'QTeK/aƔ9fM7qԹgO?:hQG&UiSOF:jUWfպkW_;lYgѦUm[oƕ;n]wջo_xqǑ'WysϡG>zuױg׾{w?|yѧW}{Ǘ?~}׿ P ,LPl!P ) 1P 9A QI,QLQYlaQiqQy R!,#LR%l'R)+R-/ S1,3LS5l7S9;S=? TA -CMTEmG!TI)K1TM9OA UQI-SQMUUYmWaUYi[qU]y_ Va-cMVemgVikVmo Wq-sMWumwWy{W}X .NXn!X)1X9AYI.QNYYnaYiqYy矁Z衉.裑NZ饙n駡Zꩩ꫱Z뭹[.N[n[[\ /O\o!\)1\9A]I/QO]Yoa]iq]y߁^/O^o硏^驯^_/O_o__@4@. t!A N1A nAB%4 QB-t aCΐ5 qC=D!E4D%.MtE)NUE-n]F1e4јF5mtG9ΑuG=}HA4!HE.t#!IIN%1IMn'AJQRiE)l H,  @ЊrIl 2Lb&I X^i (s|f Rl-i$[ h- H=l%;d {cL19$xC/m 32AK(H* Q+ $GG=#Is #g4S,yI j4G@;JX<)7 bNd::%vIK2s !, !,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,sH*dÇ#JXŋ3j1Ǐ %z IALq-{/m5<Õk% $KIb”qeM)ڼ2ō[jgȂNq.E2CIi)B)lgoKĬ(`kBxf˞$ &i`^\qރ HoSH-\UWCj+# +ӭ" \4$L;A]at-o=%qQ"<3|IoI~={($p>P(-LKP8QLTd&I !cN2v(Ļ . yc⒌$*BV zu,Hx cFF@'ISŵ47k6/LPqmj!Bbt3p1lS"ݰ;^P 9$^tՐ7NNkb++ y۬7%bUgtL(ŊH' C `TsҼ `!>4_X,MEC I6\2F& Tjqz`Ss3EQβf1hgCsIAKZ^x)1K0KgvX>&H:DgCڕaeE8`Y,g&9:98𨠅<@칒>E)|v%2\% 2fك ]*KdPpĥ,,H):Tt bqHJiGl9 YA+0[%)rjf ŧy%_lqq q(c2+2/)l7Ü;<>@ $lϝ"j^wRF?B _vc\u`-vdmvٞ@֖6ֲi%ɽ6Ҹ)o $t68I&6GWNgw۰Ŷm[I/>oblyUݗCn}] #V[m<'7/f+)0L}ͭQj Xt fJ¦bx?‡81@Ͻ8<_460=%:|3մX"!gM3kK}᪋~aMm_;4CɅx bH!H<5Aݴ6Im9$PforKi"FBD᭍_8GGٱx=#x@gv"[L8L[ ]"| ws1!ºuDz H$<*SJ>M؂,)*{XZUn_{NC;\~[jF״4YAnjЛ6IqD9Nvzhha]J,ZuycOHJ%aK-&PX!DM򡭌(D'*ъRM%I~7?gfG0 LȚ Ҧ8Nsӝ@u!E7Iڅ1!i|ş}d ̡!,Ջv^ +XZW:Sp֣ .$Rd@6 M j^t Ƿ\[ݩ'+N&]gdX6lf%Yʦ̃B0aH-lT7JI.qK[p;i417Ԓnڂx=ep)M/SI$:HL ja8A/|+陼r|%M5:c]$P(’vHgm -&6u.;& bmYu6Yyό8{3\d0Ɯd1+RٲXl(wٳ_<2[\2u`;lu3gyq L[ɩϸ3_܌BЈ>}\icH؜0APĨieCͦKjԨ>SUkX5eMYۺָ 1l^N6|f6ڷ6nqKp6}䦛vw[[=o{߷=px7o3|x'q'ϸ/pK 5񍓼'C%9a>s,as>:Ї. &GIq4} OH0#J:ӵtCR;n}gzڿ}o/;vn;.{>G<o;~o<'/ʓq<5_x3=Eơ~!,J!,J!,J!,J!,J!,J!,J!,"(P Hx!A. ŋ +N̈aLJ#r$H"K<2%˗.cZD9eM7engOIgQ4*EʓO@. (իIN͊iק_r,Xb˪)۷pʝKݻx˷߿ LÈ+^̸ǐ#M ˘+_L3g͞;s shҧE.95kԣUZvkڮs޽Zwo޳ NpaW6sПKwNuͯ^;w˻o>wŇ/=={՛^~{߿^'`ATf`g& 6FVHfv"H&*.2H6: P[lDVF&J6NF RVIVfZv^ bIfjn r։f> z~ 蠂J衆&袊6裎F*jmAJ+Z}]*Ŧ }h*W*} 묜jƪ+ʊ못zkk 뫱+$)Ze[c& =׎(آm;"ŊC)koޫoo+p |߾;Zh0=flY&?J@ it*. 2L6:g;rRJJGm=[hru`-vdmvh6<1e oYMZ$x!@$[ۛqȄn+ά.lVN[9w>{^,矋.街:Gn#nG'p0@$3I+Bэ~cËKd dBmxڷ$,>la[}HQ<*SJcEdٰ@lX,chź8_ c1q&4)jR&6n.[dhQ ٙ iZܵ R^Z *~ (@Z >33%7mFSmN86bmb8")z-D ++$#x2&L)[XF8XqnV *pot5nݍ3u+]rwwv>יrֳ hWW@uw_׺@>vg;̾v]d{vr~|v»p/3<?W|)yw<9?!,J!,!,!,!,!,!,$0 "\!Ç#B("ŋ3bܨ#ǏC)$ɓ&S\%˗.cœ)&͛6sܩ'O ѢH*MtӦPJJuժXju׮` KvٲhϪMv۶pʍKwݺxw28!ᄇ&.`c K.L9reœ-k|qfΛ=wvYthң!^m5Ӫ[~:6ٶs?]oݽ=<8qk'ǽwsȍK/N=zuӭk~yv۽ww8߾ϫO~ˏO] U%0R.X?FOFha^ana ~(b$hb(aP l&J= %ŋ<#IF&J6NF RjXc I=d4D=[܂[jIx?p ߉ޛ{Bޟzʧ~j蠇袊6y2hZiJf)Rw ʍ1&(-&7J;J}|#VK&6P By &b>R IP 582)+okoޫoίq*R=(kXSB.cSB*\82oq ,r$a?tR hlQjͤ $)ҮJɹ.I34OkJWPc-T_u_ov`-d6 7=Lq*m5L4UsP *AdMwɌGWNJIH %@(ѼIwn'`z@2O\+Km7\k )De|Q^K[~~/(l? |=CJ8pA +m\ N͂086VPi 7(` 3RT7 )|I´ʰ1j/*Wk91~P|H)Zq;ڂ`NۖZ-tunNSpu}Foh6xxD= IH%`cŐ+dRQΗDn; evTW %(G)Ry?ȀRLV:u0rI upta0YHaDf1yL&ә˄f3OLjFӚӜTnzK޸2| v'<)Qf5lF2vQt[|PcngVv F( `'p' C5 gqG'6U.qgØ7u>r"G6%'Nr*CWfp!,!!,J!,J!,J!,J!,J!,J!,6!$@*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲʁH$̛5i⴩3'Ι(a݅aana ~(b$hb(b,bdSHSS;#N?裐#ReUTeWzhbfoe֙%t)dy)h|gg螎F(&i"eA -`"iN+Қ*0Z뭶뮺믾 xc?J=d @˖)(#J%@=>k ;Ja Nx`ӊ[ LZ;Coi[&%m/&$3k,ŸZ+T벱4ls8߬s@MLCm;"o;Er0 Ϩz꬯z밿N@ +MFG{̤$K[-R"p,޴ǟkʒBOӷcl;O8%0@b='OYT1˙vƂV(dB+@V#LY V~qPnt_5@Ԉ L`fR:F]h˔Pv7!w;dcԴeIA[ Ƞ2 >k &qGP69M\+卆z3͸!6-q^ˣ ׸qq{t ?2~D$)C22\#'JB$@5n!x UO9K̶1WaFuT)υna(]f F S1M L:|4Ign]bW6uqZ.zQG-/A[ңx4+2&lGHP=BЅ:=Ūj̗1@v!fi'JaɴXe׈Q3 :.楼4jW͞N> *P*Ԣ.GHMw{hBoQ͊P,qk Rp3.zo<&%>5x^WCկy"Iհ,`X!V9ƔM* Ƈrr lGv̔c%jpZɾq]Jc7=l5ns7ubD=gQ϶*M^#y"\eft9V, ˾;*EM/z׫/|#@AT *UC҈4 MCZr0t1s)sU#c 3 ejD&Q\ygsi;_%:R+Pr1NKb\X!ȇ5r#;yOn2,*?W2$99a͘^eFJlI%luC-d>~5qԻfx&d)RDk>e sE=[/ 8e7JSm)jb.rGCcJo|eMYۺָs==k#hbؕp:Ї. Q\[];PԣN[ M=$C`ߺN[F6y .N߮kOy_]w^ÝeN;o3aΗ6RY>5c:9_{?Cz֯=g/^Goݷ^>q?/>|>̏oCͿ}o>_q w?/ 8 X؀ 8X؁"$&'8)X(؂*,246789X8؃:4!,J!,J!,J!,J!,J!,J!,O'H*\ Ç#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJիXjʵׯ`ÊKٳhӪ]˶ۛ@s͋^v/%81Å cŐyʠ)̨1̺sԭ?^zv״e 6ܸo[wā7pɟ_ӡ[}w /<ӣ_=ß/>߯?H& 6FVHfZ(PX^$(bᕈ"Ȣ%"+hb482b=#<idH$J6;:%CNieX e\)e].y^RI`9f_&f¹ift9rf}'|jh(o B*iNji^ini~*jjj9@*kjkުkk*lkll.l>+mNkm^mnm :[H袚 K[B HA&[D?HRl20nBJfb0)j1$l' ‡+\2,/r.l323;33 ]4>4C cϹIl&"n_#l׿]IQƭrMvzM b-(R l?m eRߖN馧ꪷNnX5y[ ֙mt7-}Oo}_}~9.K/2G.zo[,~qڃ)65`ќ4%iJc#HAZpr CH(K0Gyyʶ *P*Ԣ QF-R:ctV4RqD"DvvΪ$z e NtfE)תֶn+\*׺]2&լ- eS٣DFwZM-bJ?gCIS`BF,Q7r mwE|9 R9JVHL@6 LS%fEE.Wunt+]Rͮu[Q6LՆW.ȦY T>3},.ϣ§~AoOэaMmZ{N ݑ _tB2½= CL(>S ;N,/`$XUOU)rM֪Mk\n!N%;CfT+k%hZ,I F.!Rm\[zol=rYnvEg=3|wh}N4ݽ2Q; Iz@pJ1 |3O(}Vծ֢J{ TVY,]֎H4da*'-ґYjS6ns;[5-rNw>hF#zъ6w˻􆷾o{;-pKG;'m[3{&/?򎻼0cN|YsGs<·s@Gѓ~3KҧtWPǺճ~s[u`G~kvםpǻ~{wG; yO yo^ zҏ?%z˷^|E?{>_w{텏{}7?|;Ǿw?߾~?s~$ !,J!,\ H$*\ȰÇ#6!,]!,J!,J!,J!,J!,ZTH*\ȰÇ#JHŋ3: Ǐ CIɓ(S"!K/[ʜI͛ ԩgFJѣEسUB aEV-)aF6XhɪpʝԿ"ն4HP >X3&XӋЈZ| ڏ^kZCϠJJ$O5Uj\cCcRV#Vz MqU(U-L$ >p8# v _IF9 '~@ e ~HN^`t^ t `V~- e5߉(i4/r-X.BcRew0F_2Ƅ %0СP_VZF%R<TUpU_+$TuT tZA-nIaa,FyP `fQtb.'iV-[\AZYǩ[笴r `"GNj\ieUcDcDN.֋aZ(82aN96PsEj_V`(;ƌj E~Uup6ܡuJ٠-&Y)eޘ"p(G %Pnux+m&+)'9|e!"<޳h%0ƴ8)e'PdV @] d{y@E: * QnFpĠmwL։P*lh- T`[)Xjfi!jnhi uN[-S/JuLjpܬI+{ݑ%W=/_m>B^Y%u2}K\"XKi/-$ѮBu 5W= 8-JС#A;R`$MCl4w#DuA-ptAtH(nV9cjsDf2ZW asrXd]/,oA6'¸A7Αv9iaJ],F0B_I/n!kx#WLE񀘼ipTF#6 -!꠷$'KkA ԗ=Iw+X,XZToyjs@lb Q((mc C*V!ʕ ' Y [J>ds t+pd $S")LƑG`:?F{ؼ4"hH˄D!O|sz֢hO.hBIIzR" JNM)h 85LP0<($U4Jո݊!~29gu5 _S j jW`$)Ik.V|A\JW2NJX{cLFX1 KY.y!X'du&Qe(4Z˚vVh?~<%~ꫛlA{IBns٩omKϼ}Mrン:ЍtKZͮvz xKMz|Kͯ~LN;'L [ΰ7{ GL(NW0gL8αw@L"HN,1!,J!,qJH*\Ȱà JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sIO!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,AEH*\ȰÇ#JHŋ3jȱCIɓ(SZ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJ`@UNʵɫYZJٳhӪ]˶mAp ׭ݻKΕ[߿ LB8;װ&/n ˘3kϠu-4Ө?&װcˮ@3mkA 8qٛ{,УKz銍{ZLx1aDџ/ bRf'x Hfp 6h F ''bHQi($hJٕjqɴ_ڙe6e,'ML6餈EɜF%og)F/$]NfVNsYh)tb"}]"i-: b09Fx*裐F*)W U)Tզ99ьn%a2ja{%Lڗf#JE ?? (E+.x4!~ }O<{ E1 z$LB6> \%XhZtAЈ e cLg8p! Zg5ڑ3 fA~$qG< iЅ6*XA3,`}؝ da"2ga]H:)1pU8=ɏ9WMW!WI:HEf HL0uxL% p$%Vqh@$7<ꂽ^@2po7@-9[є,cG!cBf#F &6'KIcof#(NHLgP@ t^7m|ߒO>Њ$ 0 $gB:/ *G @J ;) "D7'"Q, 'znPI5P g<&L8u@FT1]:JԢ~zסJ CTjSU\22ԑ4guI$Kkrd!*hRpA TQAd.r|=,zHW'z6@{TVP~TMmTֺVCbK[`y Ln}e[h.mYF~MLG\UmG-|CY; tU@89Hb8;JͰ g0 t J R-k$[ `|p;S;0Fk'L kj61 3G]d$W80y(IzUkϕ$]0R<p!ˬ2RuȬ,^@xݣzE1{}lRY;_+\do%2,X<͒91=8ϕrABӶٓKLNLe3* u` [+ -o3|YH5Z% MQ$֝r0*&SU3ϙ- [h%p<_gE[P}۠n,h%W{.BZ~xԃzJIsbWr9_DǦ)Hn~2 Y-G/$l ,c tdJF20`"8~}L>B~7ѐkԧ.Z}i۠i*n " m#8o|Ѯo4" 72HE CvRF]&ME ፯bg#LG(Kk;QGͲ۫l4bA:01Bh dT 3 5"gOys9#,8!{]QjKOV*mT[؁ j*l9Dʛ?VHX6_:6Y:es>V t4N A.o||VhDbdNa}Fဵwh-A'nnn&m@%32>灅him9wV$w#fpwy7(/hP^(@xV6*s1]X!ACrg4M4T@yf)FfA(% \B62(XB3斂vnc8t+U%#p,3tTRuWbQpUYvHwxYV731 3'Gql/_;։d/@&eN)q}Es!'Wsq1rU KroBt\5?>V?7EouH'f`iuטqzaטH$#8PESw3QlE8k3U09hR:4lAQ,R}URNDIFl[׍]<["&&F|"-,×{wbw|$&hR|5<Mw}]Q,~"&6&XX&!g} &w !l6Q[Ve/p Cg2'|p6z%hFXm䁘f\SFN@iRh8] ee,M&D1l='IdC5(HYOp]CAgm ng8N5o3VSȗʹYCitp?oTzUfcc VeIIep6dW';5]ДWYHn؉*c%t2*(: Dk_RTIͨU.Çp(Zɜ*ѱz"c2ZWzbt7y&zjWX2uVs=Iɞ93pY&Id Zp"EIL)Q ktJteK(*Qh픢ɡZ57k1;+i6vGiz0帔kJëW(DCc#4 $JC\M s=VyX0)(ۗ] x!jUQ2DCi)J@^e`B3ٍvKnőDzh+Ȼklbj|:zFgj9{z{×bG#ѓcqͲOxZGOʂ9nON ` GVyQI;쨯Jj樼ED"ERt|^1BICM\@@UCyP6AP8ژn9eiYĞ7qYBLij u6ea4Z"9"! ZVDrP6&VF,AzM5fu˷='~3!9VDfc"…AS8oHRZ#ʠ|"+uh7\v\ԦFw%A3̏7@MHwL j*k%9~HDl#h\{qj]Jrq4 ]ӎ&G=7}u-gF}$($M:J}(90A%L%XQbaDbB<ոK^2MT<|9q\2J(7RBՀmuru?:<.~"09Hٚ) FQ,$lhŦk{,(83R H#/,'AE8Wp'DE aAc%ER,rjƭ P7 G"9MɁy{xɦu-9 $Z~c0W%%ڡBl]P n[uqs^8\367*rU;<&<ՋJb[!SrE!A("Ac@0.%?u9 Gt|tKqt,]V`c.ګ[kv[t}眒Fsww]e}4&YS:[>|>} ̽4_8;{f\>]cF`^~>1 #Hl+,1J>.&sCV0b6ӳώ .^^N>!u:! Z4wAc@6 ;(_nsna 䡰!fΫ[^z( "Oviz!v;OdA#"?4_{ ˚.a،M^B?-mBܑDRvΫ}BSbϭ^)/i7n/s;qͺy(mp?}fCs6(4!iK펟?_?_?_ȟʿ?_؟ڿ?_?_1@@ DPB >QD-^ĘQF=~RH%MDRJ-]SL5męSN=}TPEETRM>UTU^ŚUV]~VXe͞EVZmݾW\uśW^}X`… FXbƍ?Ydʕ-_ƜYfΝ=ZhҥMFZj֭][lڵmƝ[n޽}\pōG\r͝?]tխ_Ǟ]vݽ^x͟G^zݿ_|ǟ_~0@$@D0AdA0B 'B /0C 7C?1DG$DOD1EWdE_1FgFo1GwG2H!$H#DRX 4 )*)?"["*Z 8)h1$)V~!%̈́{R&0ң5Rʊδ-bA J@:DS')ܢEҞVZԖ$g~痁~IVW5֏HAU@#u֌ϓhF-VRXLt!\;Sטu#lږ$Zig]7Uq!hS"$r@T7)I=ܴgT eOU 4#0t(`g%Zx܋pm%QX&v4)`RJ@OcHQ6azy"yD6b&(R:-cgӒ A_ٖl̶Zu V-Ӟ#)T ` %ӭY [K!cn'StFm~W'&8J7_cT2 m2a&ZʕW9Vk7/&'%{grYՒJ҃7wV^sD B +e\!~]s 7&φYb~f5$ '>-1a#~"sߝMN,W Ujдh UR@c^R:u$qGQ SgB%Gch\qWSֲ@,Qq`HA +x*^[c=-?2"c14\PT$lQ0[-6, J [6!#6eIlbGS-U( 2͡E2Y;2X@I) $C$v !ބ7 Iw^gYXjRqWH`}LbL& ullff'c)| |&r6},H;QvcIOQ*t#4YZ*hU1_zPyIƒ$Sx*)(Шja.QEf" eEZu ]ivM"P)@u*.! XI ZDS)i1+A]aL P)WxY䭎Sj_ 2J"SXg+ym k`e2yVhALǁlw_ݑI!Q]˗iZ _Yk'*̕,* 1 ޝ]5wmv54mM~#rx2aJ*[b`NEV jeN+GN}K1y xW+y9f,FM״(z]Me~w8&D{Oӥy,tE 8sۻ]ucԍl)) z$?$Bz$;+>۱6 I?*aM+ Z,թ:7\9)$0)+$Jǩ_QC2JC5s9袙*B D,yZ@ji胢;-<-+09@ [8rhy)V;õ %Rxf RITӻ>u)c[9myYLA2?[1:r&31ɣ[{Y1I.9sastEfT뱺^QZLE h eի(YNJFa65q kGqlhљ31}y B(ȫ?xך)}#X1kTdٛsROr@"wl ;8(ʳ]`.+F˩:T_BRBYGyQG3 qJ*ŋ0ODᒼF R<=2Ôz%8Jbiᱹ(R =,DBzEjk)1)9{W#YCRئ/AC1 Grȡ];bĥ]XYƊ1)``! K@!$`HȪ=rlzss2 *tmKdA@PN\~L:VD|0GƊn-{h"LʵM4'*&6W5^z--0I%CΩ >Գ{-|RdE'"CeYWҒXvidT1dڡ߭ڲ?2-7,/ bB1uzle/!ٚ悈N51ܔĉ~Q4Q66_^Ɍ<4WٔQ"hL{4*Љ>4}ɎN5>,#/+N+qeB{RZQB M([M_8I/Z[Ya+ų^1DE=++6t3ݶ/Ai6}@1k۽r6i)>+? nW)TIu6E}P4I:`NW\{uRwdvqV~`A{/*OXMk3$x.EԂ)T1-ݬ$fLT8&#ļYgQNމ8W .k,ynEQWN ҚSfT- TȞFeXDQ$?~᝚8& gIAPIhѲ>\@m =!yuCcՎ*2!R2ϲ@%!'*rĮY*M@7i%|[@y}>fnIPy$R0̏l*'7x9A*,Aʶ;&J4'6# /l +ojJ\j3h,t_4EQD+ɂ+QF)AĈݟX6q=7u}7o؟JӎC/v7;@%_8e+Qn^8Yk)zi]& i^H+z)y?;~|'O;}'@*Ё=(BЅ2}(D#*щR(F3эr(HC*ґ&=)JSҕ.})Lc*әҴ6)Nsӝ>)P*ԡF=*Rԥ2N}*T*Uʄ7|vp\VSE2,ccWC';f #[*\CCK\dI@;@g2""NJ5+n[Gr ŦXBu"=l ظrJa{C㨆T̗C)NjQ2oV^LY6kیRdplg'ٲmd-y)3'f=Ya3ٖhυ}f1Αi,jꑁ!ыbFIjRB)cC9}o+0v ۚr.]k`k8;m6ji?;RN`O lS{k-ܓeܡ x{]ͽy%uӛ==Q3n;shA^K+2!-g#]톬P(Gغn9 i<=Wv]IztK'8 5 $]*ɕb]#&p[Q Wh#puXh<7xİaq|_bp↽|>\0#z[~Y?`Wɟ7_3Uzk<Ӻ;" eG/^(-T>y䪂%9k, i9]_iq_١U{e`ٍ˓E]EMܜ }% )9&BZ qʄZ] bFT1 z qhDiPHE:ppLJL( \l۰g`k![ѹݖ!&!A۰u<ֱa"!#[ i۵%P:b[!*!#F!, =L UAljFbM$b5zu6aauIiQq7`Z` 1v4y!ceE] 66T}3K^7F6r⨅ؖXE+DUGljeW\DOa^I֞I)^dHH^_)XgdGhOMݞx^!L2LPUKY_JSTISf}dyWOڤBeEUD^1Eф" ʕEq0Y]B`]-c :>6٤AIx٢] Zf ga̹1&yY-fe&jqda<:oƦ cYib@N[gyiPQDFpe"Nz'b[pw *v)&"w(gIwK}"k!V VTazҧ@|ZK''Fʧyg^W h{V(VhF4g#"JÙHDEcʸtljQg&DYh_>>#Bcy]'Z\=akRh] *]86i5#9ݣ1ݔu$=W9 )8KAi@WY$6&i !鹋AB9]@iGYhmkDJR QΤ*^M$mS*]%JJP^2Q_QQZ'jAҽc䦮]xjLee\KPȬްꩂޫܴD1P\ԥDJ^òDTMJlfa`]ic5e"e2c! ZY,mfæ&?ݠc6, 6Bpni^)l2{ ,l*hl^&>,abZ,r,n&%\(Gx e[$AX=,\dQ+MXTI!˪Ib]ؒ=h]bZ_Z%ۿubhš(:bbiխn&.m܈Λұ .go-۴hXC bDVmaJ?nŶw,].vcKnN&czIliᙛbF`?ZI3.A.)Z飝f/z o/RuM]#mWx WU^%@|CdSL%E+YrV*|Jv*1YfYEMf %rFDJjq[HVq gYN%ypSpY!Ε֙Mf1;JsY`f1`nZ߱k q[!2#q&;=P!r`r$tb1{{v)7 ((ϲ2I&/nMw,&wјҲ1}sZdݘ0$%V36SBd)33΍.SOf90{е:3S;<3=׳=3>>3?3XI<;Q@kRA[@?/4"%A-3"*#fECZPM93G)/;j`8 R1`pXZ3L#M7P KiS~4Pa8fkO 55 mTMU RІIVaPq2IaQT-Hl[Mo tO$z 6+ Xv &l L͔f&0Duc f7VGhE &2H9jaidd  C@%Vt03j٤q~mJ173xQ3MN!ɤ>-Eݤ4^3{B1q 0vj56|O"JI6P4^9YA.5uĽA}"VZgJogof%ٛ]Ncvl*mNu;fm)qz88onx&0fxpY|8WC?WYRh Ims,ih{lAEqG^HraYnN]~an̛WJKy@-?]aDb@ġ (QH.EG__r黴xei 8vhR( Ǭfic8\hL,t8`8?":3ciܮrDnZK<iyC_U]lBdX_qC!)jJevJ+:G&sNjS {ßXױWYbjlvЗ;0m|3ɟaH'Z3a.V6Q!FlM;GzTuJkhT 曅V]W.܆.({v+h^.:[^ߞ' z= >zgBǵǵslD^B~+|wm u66 [t]Zz^گ7ˣ_= V#A.?W6n}P⚷wXGwZ=dЮB@`-{ $8 l8bʼn-:! p8NJOzeJDp&͐7rdÜ.usKD5RbI#sJMBkfpjя\::lYgѦUm[oƕ;n]wջo_w 'neOJP~K10 'yÄ@&pK ?[I0>2ⴵ-f6ܹekvDޞ-_n< _lNnԷޭ}6߾;rKW*o}׿ P`2Ml-%  #B)( 4Zh1P2C0540CrP-qmq,+j-a$#!Aj':"'d2JǀlJJhHltn/e)K) ܐB0$;S=?p5 S!%~0ZtmE؄ Bl:MKG4[XsAӴγJ:$3 /bhVVRSkZg4[UiY-e jPe`]4T۵-`[bc_9qc3@MWumw-I^kL!26i# }`[I?dm)+ޯ[.l@H_E"ÆԴSlUCE] LT~̪LSĊk6U6lt)BHw]=,AV_+ǝ%څ5̙UNONxҵ6;c?'^驯!R4tRF £U $) &gZR%H|Y7 }`\rB]]Ɂ#T K D!Eܖs~3! zI/޽VkbE(4Q;ɱE-%3(1ڊjɲ7uG=J+dyC4@4!HE;y9D%1IMZo7 4)QJUEw{*YyˏM(=FBй(X5UI E ^SdT*eOWΣ٤ZkƊX%+Zdڭ Qdx+Xs9ZH|qg7d բ|X:r]d(z6K,H簖82<磝Vlrؑ8}5q))H@IN#0= SsǢ!Eh,0$ sK?Ja :[*P:Fߴv$G]؇#5Y5 G?+(N!ZKB3t 9 K!k} Ĩc B%56Ɂ|r۲;;l"nb1- C#)u)bY2@wSVA\H*R881WXTj7{kq\,2&D] 9uv^fyXsuu+:%qs5cMo:@jE˽0!$HEȎ= zc)g﹍cWq![` pE U&]l'Ɏ͐UIˮ-leiEQZ;fGM&njw#UcwѸv]{<{ YmZmXI67pI"QIC0 aE- #qq\o("ðJ^sump8f8́y;l57sY<:Jz*BO/-o7L\@x66>uT?w>5NEmR.=4CM)MqWѫLB bWU 02QЃMatx`9X N2E Η4&[by^'`-G&[?kjKI<,!/wo?O7f9A".u T- a+lŠNf׺Y1,`\B~S|,s\u&M|WҌim;pțsψ0xR%tEfΔXP.(00(p Yn: iBPw.cWhPp"F4C/ !|- AeA4"Z j%f !`,|f!tMԚq/NN, i,(+4#L &hb Mi'%@-gU­j (BqFI,Q ג& يݬAYnLq Ct C(EBHe6lk5юc3$p0S Gdv^0vL('8.\<1zv[ms<q0DyZ&Ͼ.z#u8}Q0 \rӬk/G vk#3q%pEi%B^C 5 GL/$ KBohrz/1`ϣt<-r/%/N>Lk2`æ&R*GB,^ hV(r-0`#$lA a,UZZT"nEE @,m K^#kx PR C1n134{p[6b0%ҧf6 /2'-8a|- dc1 1b|jV/ O`0 Y25QIԭ#r)-yL1Ck&S'PI6;qj֓)H>aYxh 'Tk*07C&>QkfsDI4#" ~EmR8+cn7& bƳF7nîЊq=TsTHqt rI볜n3T0)K4SWhnnnMWVt붎LAWQJ5KtP]n*@(!!@e#Ha(R  o%LQ(j@42@GD%P 3LFJM P2=4o(K$삚,Wυ0 YXo"D|Յ~s* ZUuu*F/J[-{IJPu- &oʠ0Aj-eͭޖ>mSn{g@ ]8rAUA5q:ޜF-*F>эBhQwD=-2"߄buuuoᰂK$vWx*xwDw}>WD7yxvwizw6Bcz7uɷ|'W/`CZ1l.X,Gw~'7W)WФ~}a~!D'3)-p1x9=X]ExI$M8UxYX-xa8eximq8uxy}8xEЈxX $n(/TSZb ZxaX.:T8xl$5/B#".fNz$%~4Hʌ}쏡x1BDR"$ $ _֓R{`D]Ļ&1sFQ9ԧ]e"byidc Œ6Fe!^zyAʚ'5ؐQ`EV}u19&:"HAj9ř-[ABmA$@oX!x]똛wMR|6RTT.#C@Y,.f1ty)V3/.` AQƺHMk"y{, :>j^Pcm-}| _`8@ }V':y;ځC'"Yט_%:Q5|l.! ẉ:+j(hYԶyRZ{3ⰙDB ]xG8zwC r9G DZz!fE|QC베ۏ)b DY/m /͊ U9il%lhS{Aʫ͔Fy_K-2BgGl«=ƪ=KɢM:l}pۥ"3-oQFy9#!"!;z["Q!ČCz{7;aں9},B$D(^[/E-ōQ4'`W-B(!xc`EQ\lR"||e}}Yݡ"BwW|cWyOMu})m@[^Fy؍)*㥢y=ڥVEij۽` ~Ie-ӷ=A-o c|=}}}> ^~!%#/~)=~A~5>->MIW^Ka?e^iY]>y~}5_F追ޘT}rb,u~e~꥾>빞~ž>ٞ~>}g?gS6m!%#?)9_1=A?EC?IY_Q]a_ec?iy_qs]qC16߄??_??_ٟݟ?_h`A &<`D ' bō9jHI2ʕ,[| 3̙4kڼ3Ν<{ % -:4D&mTiTN2Ezԫ n*ة^f% ױj] aܷc纽koYN XB7_/v|Š#S~G` `xן "F`^8anhr!a$8b"h-"Gc6ވc:&[lR]NbC$T)G>WEI`?r%FF[~&@A0i%mrwsY&znq&^&XZO;y.z'6 飒*):jinZ)v ꧢj*zjjj $Ah+I#Q+IQ^T'l+c+fkJ=@KQJ>Qtk˷DJƝ nJ[L.+bO+ 0\o7p #O\p[pck|0!1 SLr. L[Xkm^3Y9sTμ=LtyJJihK#_ML ^4b+ ܺ7vIun rMv׍z~ ߂N~T̊/x5*=d4JCbWq1'Rw:yUDHߪԪzw/|^B=DJ2>zѻ(}_c?ws/~_xϾLI0?@$0ɏ[،&d IO$~4-*🏀d<, u,ef(Rmm #%ꄰ:bN(iIXS@:Z ܄SDG$1I@ |0 DJ`H Ġ"˃lJ l9m!rBkJ#g˯d[k^)drM Z󗙉\uJXڊVm];ֲ&%VI3QKjW8gY":+F$&pJR*4+Qoj Zq+c7y*ZŠkXi(xT.mmm~+nl*a.m\B}nrKVWnvKqfkrܭP2IVNL5\A/IS=3b.' ZqV5ӡ.Fjk ӘyCEXI> :':,=4 b0)a+:{%h3"~FqqR.% 뵇|L߬v3_0O^ˌf0Yi&3fwtfYRaۊFF< \*4gOIYmѬEEѐ O3I̲ +]An5\@ފhi~Zj&]5$`giC 0u>4f*F$f9©+Nck pЩN12i<5j Hp{;nq>nxcw*8P $ē>הlʑyءZK-Ҫ ^&ՔRBd JrKZm%Hd\*[0y{XO'^aΟ^ MSŸ pG.p(JMw}\/;KvjOݾs?w5yM/Ж34&B/Uu6ڢmn*u)ʹ4|S_?( JfSaRb"ꀿG5/g/~^g d6/ﴢkى[oYjQ-?ˏ?_߳@)(BI_>5?LDqs0/V(q'ΣSCe D-"֖(Tee)fj_q- &56--4ZdUc;_!e r%,!dI&6tc; JQV%MaIOuSSV8\fq6effghHgȆi؆kqpHuh7`_3Myp5xB$h_*9Di)^Pb$leEAwT[MUx" 6.VBk{U(|;8c54$HxIPAH*F$l%SΦJGvM1m_PXm8{Dm(ȍGwȎ'I^1lELd_h9P acӔ.Gw`dS 0CtPC<Ka}_d%WW'(LsUq:/7N>h2AHAq!Au,MP PdUrd4M$g-u_7pGCirwBEGO)vumQVɔ_[A%MS=xVxh8m]^W}6$@ozr!mՖDaGtUE-6FA.IZ!XJA{jUl8ポAC8|6Gfyx}s[]}]Uui5]I]) ٛ9ɕ 1T0 W_/w<q1LA6e:aeՃo6`?eԓȞOcC'RhI_"eqP6L1bNJ)AC5/̣.́Os+VsEty;A_5a8I:)lMᤰɁRUIMSI'Wɲ%0۲Y)57Ǫ;UV-/a"|EcYEEZJ[W|sfSTD;KxWSI(#tStEҏG.4{7cVBZfj"AKhhnEwyI1PwM5%4UuW|HC|Zc \BP'7Kk˺~ó+@E9#C<݁ɻ<㢽{5sMN O-QM\PmY[mKЮ-cm ]hbd gsqr._25}]ׁ ؃-؅M؇m؉؋ {؆e:E=GMٗmٙmԛ]ٜٞkأ>SզWګڭ}ڮگ-۱M^Iڷ 3wtл]xۼ۾M=-}и ݹ.M1=]-$+]/ =ߍ-ތ2M,ٟ]-+* ۬= ^m9vν=]N",n635N7n9;=?1.EGFIKNCN~.Z[_ac.)Una+.ioqrsnu ~z1g]ނ]ރފnN~N~.nꥎ~^d.\N^n빎^Ȯ2-N|wN.ƞyǮmN׿NBَۮySnMꪞNb~벮ﺾ.n.^   d?"o%_'陮!$+)#O=9?QnNG?IK/LNMEXoY?acdT_.kmCn/o?u?h_B=>@?|.2~R?یO/I?\P \v/POeun@wx]f-O4o&/1ŏ nP/?Ϳگݏ._[cC"d _?!_$XA .dCPŊ.j̸cGErdH)MDeK'efL9mĹgO7hPI*Eti2eܘ1!Օ222=ڕWMz Kv,سeњe֭Zi)]y_CZ1@aÈF̸㭐3JNX1ŗ5[xΑ?=yҕA&kխIvl۹qΜ7߷W$#9VvJMX^:r؁kΝwٓ_xg~{ß/}"4@T0AtA0B 'B /0C7C?1DG4DOPR+JkT OFwG 2H"4RTrI&tI2J@J*+*R,K01S=24S0,5s7tN9O:Գ=tЊDS?sQD s ;2+3ʨ L5%OBrTQ?ET[eQVUUZ],C}]W`v_zظBvYe=d6Zjju$6p)۳` Rp-h\zs} Xx` />KVa!v8b'1X9<8䊶(SM"*\)cd?cPWp;bJ3zaOi.ig^ZiG+xjzI Hk돸NIl>lN[nf{.w6_q\ul|sp$V&*Ljcor\vq=w_wx߾xWZ{yu>u- 毗{{j $!GvDD&R)R#HGFd%)yIKfC@ gx*!&M r1 ; [H)mI\RK`3/ $Ke*JwZfb&Sf45f[:Sʈ9;o֤U2p!*fPgTɅPf(Ekg?O^AӟfA zЂ12U 3&%. )#\)Jz[bt\7~O|뗳υ~WQ#N<R;!sk/  ; @@c?1O+AP;ALA\A<49Xb>\j;j%2ֲ7٢4ujbA%l%lB&DBܣ=*B+B7I8XH⼪0pB?I8"+lu뾹B5C.C==) ļ:?#KC,DDԡFRa>,K ; $4? +IqSt"*"4T'ڊDlEC|E\DAŝ?@\\^E-83$̃.J;h ʼnI`88a94+o9.p[<_DGi Fut8Z|ǂ ?-|5ÛgCbK̻5'[8-yWcjJ17d*n2xoWɝIًI<"WEEl˵tKXˢ*ƃ泡0&)g )(VJ!Fl)S1r˷LLJK$"_\GsLGM]l4$&c\F#ڄJuڂ k ֒@ìL1l):+BB,MvԬN܎NzB'N O4Oˍ"43pGKB(I +2) Pє| a AT\%p!ʬJbPP $1K Ůc> K,KR㼟Oٌ   ؂6؂Qڔ,qΰRJK)L1J¡ ,5Q--BP0ΔK2L42UMKHtDLΚS85H:,z:R/7S-- @ Ĭ ,T$wL.;:1ESRe ST%ռNWUNXN *JK/I0Qa VbEJJ&Jl.ҫRh-+{zVUnoeՄIUrTɓGLs]W|WtWwWzN:lO9dOOXXӁӁM 3TٲNUjIuuIyz%y=YMY)WE"/}.M-MYͧ/j08mb}aM6t@׳@F:i(V4+{C̚R٘ZcjYuRUMӴ=[ өO~;S(<ۼ}9S}<=ȁ;ڋ X%tPm[\S][+[Un ]pV]q=]Y5]h#ՠ_Eڢ֣ݔqJ|qcC+RVکZ cZolP؋]ՍUp=ϽP ^^^YS~,A؆eX%\ۂ|WeX$;N.k]2) >U_^^^f[5,cZ [ u7p;]&Z$ZZ+3fmN_%U^lJu´ .b6b=^\ub\)~bNhȯbb5Ć-}_ Zb>M!T)TPDmXzcPE(bBddbDE.]}dm襲)P@v;p7d=C1VZٝ^V@!SFe-1dߧ^a]lݯVGdfFD>fhWUmff~ffvfjfz K ,[M߂bE~Ũ/cB{auK784` c=I?XYgf }DfƋF>b NQ)FNƔLhJbJ..4?0@d{NntRe[jJb|hjɅ.0d(N)F)f䛡4Ptp2!FZcgxv\Cߎ])Q<縣AVBk.DkbladHf&->LvvɟgD<,fDaQn]OVie Lb+YVZ&`iRM``F^ mh`vnᾥ/ڄ^n2!JM)P6-ƷضZ]N7E!,n~ofAߞrhj;Ɯh֙L晜9pLZ3Jl]cC&9tnFmQif\< o?q nq~q NOVnqƲި wnYٜ rJgܒ"1gjW%<}N1ܨ skqm4mbmm7OJVgl;olv[plW.ozpO>tQV V]2Djpibv*hV523o>SGY.hkfuWGuVuH dn]D.jJF@aSDF.ETr&`BC{XuXopuYe6w)uS^qvg99wp>s?=hѨhfQ.>e-GkgNT GKJғldLR2ь[8ЊQwlCtP7@]ΏJE YM.vS]YF gYBtM`))YM:Vi=ZѺ5r mh׺Uz^׼5~MT9Ҁaդ_8:uMˊTMo(GYY;S5Ow DŽ\8$>5V25_wx+qk\biRL.%;JbʝtҺٽbpF5Thefdc+l^7@v:zIgF7x8 1}:0O|b To*׼s+L]v3a Cw"fqKl_ekOSqԲbk2~cL8Tqc3ADobהj6kU9p՜ƺJ:t26:2()2Ԇ;9u3gzySAV[ޡoN-;ܔ%<$0l:b% {~G'EH7) 0/8wow2O?_1 ͶJK?׾°FޘG2HaRB$ctbh'ڔ)[V^؅rr4՚Ӛ!yI)L@mZ`Vr`ǥ[ r ` `%J,j#5F5Nc3Jci dTi`-9xl# dd̕Py^98(bah8a1Cʇ <7Vc7bEj5f$Gnd6~Ez$%cIUt`JbjJJ$K  "/N9p#-;;.)f"PZ+|b~͇mV]R"]|dΚA,B}dZdL%LKd\Ra]]a^]%)@)(6eF9Mf9n ]+plAQ:H)aUЖBpTf)aEWNP@Jg&fk^xalA}%^oofeq'Fc0ar/cs*0:'s cȉ+bNr/؃N)EXJyH)xzQIPeI|I||uaG9`Y -{>|ȃ{ыکcp2\τ-ē>gPcyg`ty,aB*B6e33mnc=k}k({ Gׅ˭ocKK=)1s&@v;QZ+75\3[Sz;܋UY~~WSH$4"ibRڨtQo&Q(3\0(채rh@ nCT%sRLI)=&|-+@2<4\6ݼ3+9;S>@OBB=TF}HQJ!J3TN/JRP7QMTД_Z}Xaϕ[DpZs5^u`!, 2>h2hIFTb< 2hreikWliQsy"&p'2)3WVnS9~߀  ^a~7%-Ԗ;97EcC6yU>yY~QeaYuyyq6y2OOs@ m)pZidHޅ * if7b?p6!CEcúA o)lHS螇r'1\/))h1i=4'Rۃ!R!? RG81(NyƖ GO}DTF/iTȩPhG=Lb.)d*FZQ~իa*XTPi ٪Bpmk B5y]׽+ I]bz;@҈@MszS8F3lÂӨ7EU~_EqH" Ԇ,R'xѻ^1ySlb/vq\O?"[.au4>mBBlDE1fٴٮqUVWz fg>ξ/ǮVW,-ƪS |Hr-#Ѩӈc4Y#]iJ_zҙ--4 }[<e 07N\ F7UY5LM&XphA[aT@EQu%5F/uхa5D*dHUuiL{[ӝ]o[VW[F.-&o{gE % ѹ rHWe QQ6! zCHQ ?³ųiMZhӵ$׳W.5riֹwoZaЉL]HWzҙt7Ozҥ,ziY$)~Z깑mD&q϶|TA暩&܇׌TJq!  rCːm q s?%â_%9y}A_:뗹T}]emC!>٩Q`VCef_ϋ~I>gr>?,:ro̳UЊ I/zOPr_M{iBʣ8A`C@455C0nVkFЂbjqfbŅ&,ٖ  pX fښ m܀ @/O{0pO~o. {0x  t)x)Ha &CZ@N pE E#E V"C/g\/"\n>~B^`kOPϱ`pD # 0 p3QQbO @qZl>QJ=1ECUQj:J I$ P5D,0ْ.FnljQ ˨!!XHFMWY[ku1ʏѯ"N LͰbzZYPBN OL $0#%CP $HBHO̴ipJ/^]fb*h2&sRu2{R(Mpy(()%m椠rLHDZ2Ȱb2xZkv6p@-FNjMfhDap@lATr)R000C(S1M/1+1%2+1%3vzL ! RNR!W 0> P }LH 1eU+s_]0& ZAO225S::#:-:x;3(<+/b˴\n!SS! %V+TSN =b bhb|1\Przp%T4ՉEE[M4FDtD000NTڮ/[!$5Ps2v$?ꨂײ,#kD&5Rقi*m۪mNYN_N[5tܔVs::q2o;{5W}uWS8S>f\ HsOL$@HY3OP I}K%koJWK8^+"kL9K!,tu23^U^y1kU_2Q5Q`Ǒ`va`` ˋ<\J՘%6Q%C~YP-y-k,Vu PZ5NH F,KJ͕tl \O[YJnVάO3tjjVqj;ibV]Ur+r/WVupHPbFD4/#PWwc`@Vd7av@/Si%Teqf. A*3&Jg$" )lA`r3rs(!W{W^^{7|_ {wumMYG2ZSs# I vJi8[\S~ Z^/|ׂ3w//DGCKO؄S6װGbbGD2J 3B5m}4#fOpIKJ]ک$/)`(` X8;y)C謗zMzOYif>X#t ߐ~X#q=Yr8h9nX&لkOvYGxǐGK~j%ho TJag9I[@гj$\v !>n"Bw]k"칞UڥYzcgZhmZi6V}:4YhՖD&+3>p"%.yf/A]fF 4UˇL[@0UFyqV~Bz@Yn:i:v{w:7h9/j[W8zY@ 4" t+$ԠsPW`b&:R9LW$E`+Rړ{R ۸ ʜQ[;R  ̧Zm2$c'c'Ezg--TQS욁mkX*BJd[ y5\# zjTG4E 0觴mY؅#oYC6RIz]n\/'-|ȍUzܓɽʟ\ʣ\>c>Z~JhTb6zqCP0Ӯ~ԝ 8 p*y\d90ټ;t~{A~~^[Kb&=DWY_QI,n%p)c"J% T$(EigǗ_(׍>Ji{Y%,[_?NLݤ]?;&iAlVН $ D6m2aRdp!զ6*bԘVn:~ 1IW&3\%H)I\YR̛*GɊ$˸)SѢH*5TЧ(tJ @ȊU+^v ׳b˪Ek6-۵n㶝 ۻrk7/߽~ 0+^̸ǐ#KL˘3k̹Ϡk`4iWKVmhקW;ڴoͻn߽_<6đO|sš+ޜѱO^u(eVlʐ!3eK})[ e JH!b*1%HQ|dAJ+QTJHeSBMa~8SvJ jHb+ x"K*LbrcE]CEBQb uPzexQaݓRv\~edVYalp)眍՝vz~ 蠂J衆&袊6裎F 餒VJ饖f:ާ|dpB7$EgЬ%&M8Ӻ!XbV}b+kDŽmPoU] dvJ 7x˄n8 x+Wn Vu}.zs>饃ꪷ~멿.;ڡ=݂yaD*A z+DFXFQQ漑fo,n_$>Ѿ$섥t[ԵIiaTGnz>:LA&39Mҙ$-U 5hAbp `?(6h{ff=h@2jUI@&@B~Sgg򑏎jX-%n9#G{IʶEm[̢M46nvkjrZw8Aq#8H= L"HO]d"JE|%-LI$(G9RfҔD'W)JULe,YZҖ+eKZ%XP÷TbCX!&^c@#) d!ڀD$… ӓ'N(9Kb9){kg7ϔ<$Ė}[k `ԕD(0j]T B%Ј ӢD5Zх6 H8A(mJOR4,K[ ә4pdp g&rC?@s q J)D)RMSsbj4m m'-kjsQm55sÏHW `=pS`kJؿְb&&"KZ,f7Vsᔧ>ee&SQfH"b<ċ+J3j O|$DNvB&D:+ӝ==׬8w|,k . lfK/z $!yBڷį |l nA2iOT1Zbth RT lU* U%(Y0.jv3FjIznujl@"YNr~d7Yu K*[^2.s^3,2#2lGV+c)y xխYCvsz|t#-V:I ܙ ~gizduEMQԨWVw&bgXXִm\e*YyBb8[>!Ruxq{Zmb[WFX1a۸KvY[^}Br&0m__qN:}xz!.Sx@خRzDn9(Up-A0\ „f #tɅT?X Ί^Kh乪]>iiku`aHv4^cd?n?/ eKu#};tpi{ֆU@ gWϮ6JwS"C=+?H<1P=o97Zq$ޔYG?tG;ʇ~Nסh7og):~O?~@z ю)U`5 /g a<˳+o*QrrB2tct:wO=diZU]5>uW>Ri k~~뗂+~086Ȃ*h~ַ<8RD8FxpB,)фB5z& 0y9T5>ҌF]u"O#( QmZ ؉8H(;X9'x|}|Xx8_9S`1!BClG w4VZXyB#R D1\bze7{")(T1{({WRc\{$ x혓8>=Y%8DevHJٔLq#gL%R‱X+[q]SuW EOxo6 MPN9Y(E&&k(X9`Wl0S&"wC3Kb-7s:4Dtb9 ґ]EVV5V6FG{w5y4WB&7XlC2W8|EIy֙ٹviY9 Y\`mFZx1β[=M@#\C'tڴ3:$ UV\Omis]Ձr"?֘yuW] yy&.@IX?ɓ4zA2ʣЗBN`+$cDݤ*Ty+۴j:!8-9x45Ȩ7(ڨjQLTO;3M*gZ*dgGvhh=ڈ!zuDiBPZjҊӪZA:٭ 3xA%5yYzCYaZQZ*0DIDpXjts#^%ct*1>]v՜kF +۱ +$;&(ˢ%Y$rH>87\P;s8S*M$U6%gtH,ִȥ,Z6]Aڗn` ൲*{cb!٧:ʧn˶opt[_WQDŖ0 ܔy5ay<{D"F^bGW:fbk!>$?o $rjB\mk~ [{KjCyٻDur \09{s74BN\5hHk˳…N=⠺O:2].T* oS#;['k\S[;0F6:իzUK&mpzoohVtxn 6++UAtisTQ{ :ZEuHYvL]:?dPtBlD:>\gs{,,*S%96Xy\UIbYvI$2V웪,nfwc#"Fq\Ga%½,1lȑ\|̉̋ ڬ\к<ּ׬͵f.aԠ[#Qô۫J^\ӸzCqêi;sJA%- , Ύp;8lBHC`~[z[ʕzlʩ1;Yc-ҠKWC{v#rj/LwU5m7 sVmeZ\ ]-CLTΓvP[>d=o}>m U5Od+}u\FNa6/_^` WȒLƜ̘̚m\qDl,}V z={]*)#hDAm!*l!EۨoF L$ɝ˽ ݎu[}֝+lV8ufN,hq9"i,A#v~ BW[#>"XT > ./$}}[V Ө'{>}U܆(L6ɶ5U v{I} ?v" ^ ،I@Ē\V9?l`M#c\E9G,Og|mI=Fp.Y;֍KLN}Fٗܞ}܊ʍ =5}; \/2-HBߛEPԥ)fF"|ǩ.^˻mnJ7,\G}˾<;N}^ 4NɎ.џT;c}iSGOգpZdN&OV׈߀-r>O]=\ O.^ƞ ?BF fmZ'RL{bLx55"W"&/#$WWH=4N@OiBa ?//p _HJK5eP(jW،N[>]Z5:td.y}Nrj~y]G}kMLNYF>셏 Pv &]騅霛k5!x;"ӬiS$,궍M˭˿lꢻ4@ aO!_/~ʟ($VOݎϼ \>\Ȃ?}--NoWg?[O'AC  `ƒ >\QD+RbƋ9~RH JruǕ츄i-3ki%VnRCنOm8e)TQ"eZ4P2m֡BZŪڨeiL .%PK^vMp)s[3E;S" 2Ȓ-W,93e͝994ͣMF=@j֭][lڵmƝ[n޽}\8l@'h<9rʛ3w՟[uٻox忛yۯw|ۗRvld)|I@ VZp(䪍R$ĩ$- )l rjC' M<ʲp+p ٚlK0ꠋ+FjJЕ->)󛒾*2K+rK/#L3D3M5d !9::NDP Gv,GŁ1H"/[)Y"g9k=sA]?7u[tDvo=N ' xރ'~x}/WyOdu%7j &Jh إzq_hjߞ +p ~nZv_7]{̻v䮾t/Z(/[)1O7QP5AZ!aE8BKb:a 0L.d [8C07a3)l*[dAGt7CN~ľ%QE ےx&nPy5qg܍|F$\A q(Gڰs#G>d  ˆb!HFR,ct$"# I5ɊiRxMh]WӉ!˕K!_wr_ E?||2 >[R.vi׺h"c+%?y&1Kv7;9Nm d:չNvh@;OzӞ=O~ӟ?PԠEAPs6"nEb}Dt14 Q)42:iؖ-peP.ԧ=OTըAmgRT&|UnpV*TUvU"S^XwhA߻ YꗬcEФQ!}@aZW4hŔ+Z̺а*ǯ$NUUV,fTvֳ##E{G҆}t w7]8|yTGx/<4܊'IQ*gbY

UʖF!.[!_J}*x>wt3͇%b; _|Yvch{淊_]?* ش$, ߂˭_@ @04ۡ9ds|@t9Y̻ ļ7(" =)ڐ[r8-ʛ* "+خ.r3 j!$B#,™;%d xB׈֘B֨ոB./B0B1B2B3B4B50t1|23456=C>C?C@CA>4?TP6^:ӺL4+?i!ī1h%#^Q[ C DWDX,DDWXY]E^^_F6$<,FBRķ(cgBRKNٛ#2Ȋ|k Z0İD`H`YLʴ́FIT3۰Rǵitr|JsJA"qJʋIӛDTd䬓0\kKq;KȼGKԐȿ\+2M h %aS+0y[(t̚̕ I{z ǔ2GtԌ|md1O=R= %ڐӤY] &buSm,4蘖O ?&0mYNFF& #V=bU%d}V5ߩV\A؇d꯮ _eXVg=$w׬`TZv`ZCTiMib599NFV쀴ekƵ=pUht6@զKwd Hjgᜰ%^ڶnW\w*m8.slP<2Xݠ]"梎cnb kuݞMʁL&6ox$KUBߟNNO\3>jd^oWPR"meFm֞~Y<^hW1&pӵk@َ3c^3k{lEs.H qlӝl$LJ6&L.sefø9dզrs7~Y'27m>Nk@32l]7fc`hn@e ϼ H3wGt5=^|:.bCdrNj 1tju,OVT'bN%[H\H X ehc8G_ݎ>qi?-++:+Q=K -q'2'Y@Fޓf.ϒX0]U.Cai6 X9rWwlʆirwOl䘍$8wvZA`Fd$3.Z 6N85.<ۅ:dFv%A[yޓ7 F@Flo!Op˫WEne-M>u FبzFp.g_exz$ m|K a'v6i6vA>8Y_ O=308l  ~)O/{xC޺%ua[Fg}\?o6 qXvNhmGj3@6g-tɧ9O]8@]i"kGe`2߾tύqnDGGRP֐-7n3 [ ])lÇHV+5f6nJ)3$2mTlYrK2+gi#I"?DIϛ rG7b9šG=BXUbU/niG7"E,djײm-ܸrҭk.޼z/.l0Ċ3n1Ȓ'Sl/ 0@ ܌v4Bwla=qk_]6yje=jS<8r3n7Z濇߬oҳ)ܾzsMqٹEeJ 2? 8 x : \hwA-XaŕFuTX!TXgu"L5L$N)h7W]ieVOQŕWD#WYdH(e")8%UZy%Yj%]z%qee܆iwBEyY{}GZ?\}6R)%W\qA.tM'Mvn܉Fڦ繆)mY)晒|%'}9+z+[k[%ZĪe*+6F[V:-J-Z+.j-ߦ[.` @v D!FDP oG`hH5M)hL+*̒q lO3TRa gTCZq!s'ux\&!\Z;34k38\37;<4D ]4G+-6QKfY]5Wk5[{5_6c]6g6k6o7s]7gzevj)8pvxk2qY(3UMfhi9^*u8:-k|Wx߽?>5Ƀ`<F߼Sg{=ڇϽ槏>Ͼ*/  1 CKSl>ēF-ItE' BA(1RɊN!B- c8-| kCP8 YjcD-qNL"&BqR"-rJcZ(ܑ1e#h5jl#(G71u#h=z#(H?2$"iE&2wzI'CM x89InOJ~r01HytN4p9IAS,F1L}J$qUX᎑l5ymj&6MpS4"uhiN{ϧ錞A4}ʳ/)ρ=*QZP&KU> U>թ*Vխr52 EyNCJpZ:.)3G(ȹOq\Zgs:%47KYU~Mm:;R)˫lG4LW3r,hv$BMCCv $SYh{0ȥxJ A8Rvq[vB&S!V)(IT/5F1")+Ԙ ӏJF:vӑHmwvA>-1f>3́ԠflЪK"Ss끙]T;=5Q˳(ESl+`nv0H.MsӞ4oEjc4mXڐ< cv5*B-bA6k\Y02#69)3k!rK  6-q'n/Y¦LZrΚ/{gI0*98 A^ұTуE *mb3Ӕl0 8Cyb3bl Uzƭq& F&lA+xB܍͜LmPJٞr9W  WqAױߐHY8ȗ]}.!6B{QaAiR}=ca=ZӅ]%^Y(al=!  yI@(]遠ʕ#`lVM[na͹ Pܞ9Y uP!t6F"OU)a."/bIlu_I|m/tqR3/>6F5~^!a9Ny\N3=S) v\܇=#>#H@)@D"b>b#~ $`)ʰb !Q XOTWLN=HE-H@ mvAmWqA#LƤLdd@0|\C)#m1ZUN*!R_9ڡ q_\XXc$YY_<+e"e#1t-Ybm"^_ [N 0ȋPV E*I_IIDeTw)-_-ա%hh c{&12#kނ3&QBcA=SEE݁^ o*7)WM4am-2deXBu^'vcp[$v j-yHW2 ޑYsŖ x"AIz ԡQ]vf'6BW B{Y`aP]3zPJQn-[čhݡVV"1aTvP>(n8"%Nn\A:!ĚH_ E'Rǖ\)f}b) Rӱ"%ĉŬHp pA $qWK.)ZB&{u|݃-C2VeF5b+e6".27#~aܑIASt莒Ƃ)֪vJ@*5ix$F)zԕ6die]pMaYu{aJ2g$*VF]F1]֫]Τ(=e L-[S>+k1ga.jupn,*σəi%Яd'ekU)g&QPb6Q~[qa,`,rԵfYj6>{r~_3l‚cZŨo_8-+"WwB\tR,iT'⑶[.ɦ\26XTDⲶ) ~)IQp)p`dJ &JŞ'(NV *(֮npBX>kęOئQaQdmʕT: Tp*ը^a`,b(Zz݋Ai)n$#yⰱio,T*obo)(/'e+bf"g.-/WŚaȆkb]!ś~hc7n*o)ڊTef(yc `->'"G\Z Tm"o2 Re/'diD6EubcBfkr"DT"p<{.T[Y!I+ .*O c!4>㦀!F,R&)(UAX(:%$_2{DSwf 2,o@@oPɾ^1^-p91IZ&2&Y3onq+viSsю#Cj+bIiJIJ:*h$k&c3&+ u,oَh(t* GXhY/J#=3Gwt"Jز2. %P/w!" 䱞C)V=$PHj =њ`4]~E^ـ!ՉYx4TG5*Lsskn H$N%$w\2Gr0 CN &\yr ;cXYp m-J5aYčIt.Bif1koI.udvMgL[3`"5fD"pz*I+g-"9F@.6n6>nޚUPVplt0 qo:8Zw6# 5/0/z6Xz3%ve,C~3fdL/ t͈NNGbfg$)Jq${w[M䉴<_air!4J/q0ZvW]x֟&U527y v[@9[HZPZX`ŖEG999O9yWy_yygy9[rjy5NJ(jۚ&3gӴ1sYEkggo}*>"8#ǜInӹ:Ϻz:zߺ:97;S?_{Og;o&Dř @{u{3wNtft-DpT\ջ(-[qpo:w  (aJtG;~K[~K}k>o>{Ճw>鯼 >뿾>ZG8S܈'Q]r6gN@ UŸٸݩ*[j!ƚ퟿??QϿ[g?@H"4`CFT8ѡĊ!bbG$@L7fܘtIm츺ek/lμʖ;>g̢@rSWMxqǑ'WysϡG! Zώܽ?~{ţ'}LJ?_} +ORJj%WreA3L0f&;lʕ7&I GQDQ,AVTŽZi6b4)Ъe\Fo,F\!1Fz\G#alI1(gt/ZĞ 1EJ(#ؔ4KJ=@>3@ P@oEmG!TI)L/SO74TM=`TNTRS5WAuXiVRkVTeW^svWU%Vb] eb؂PZIIlI);Z*)*l/jН.{粋޲J.7_|$W\1 ^aV'3202,+ HYVQؕOVeee9旝MfkvyqFgL+-裑NZ饧{.~Z魯ZZl&;llӶюmݦ;ou@T$BIm5(Sto)1$7_RэTrC'2K OGRs׵Ii_8Lm,x\) , LZ^~䙇zo>z^zm/O?9د}޿M%ZW+%d'=ىQqkA/T ʫ*4>B0-k1a QAJX0 !\'a dZr6q!M)I-D(:S>-n]bt(1˘(4Qm<  'kBlơC[&9J#EHdUj]9(NFBZcJ%L\\4uwcӛwD8*d} _40Ee.t4Eдfή9l6m M4V@p(qm[;ىP&7P$lPV(^$/ P/ 6 ^a&ib?Tw{AcUCb2Jp!H XzAy{ZwQm9tf}_{Ͼc{&pg?@)1OpȮ{L)"/2_U)e+qɋ0XkRr(ב"re3Krei߿Lp)ڤ ppc.cF&B.%H&xŁd"\ȅΠ8Dad^H ++pn (f s׀3*:8  0 'pڸP mɰ ʵА0 p ݰ p`}Ovu/wEx.pƎF!IHIt$ 6>OMNHJd Gbw?+0iqmqṵ}QF1J118P%6\cQ<+R툢C(N؂NKMnP @*k]񔐣0hc@3̀$ T!#%"[12#'#9#$# NriPxp%%5%=D Iz$|$LHK*ȨD,F>qKX dE/^xpB,G-=R-Er-E#2., . .2/P/LBJ 1P24 03%x)HBP[C( g DȤR1 Сd]- Frpӆr_*ӈJ"/809R99Qztn:谳;;s;œ`.M:KSRD=xTd{wAD(:$o 4ʠ,uPn @Iu`'k\nB_*b%f,3;;37s_4\VPϪwB( /Q5g0-`55iX6i6[p/jcsdtd6/9Udõdd[Q֧e 5ave6fivfmf&Rq =)gogyvRoi/P>C?+j\$A?.k[-֊d1N1+&o YxYqfvnn ]voOs4P5pVp#`؆4qEJq{tq; sI73s)E@%tet\B7rPLc(+$ JHGHv4w3bow{*NkH͊ 8CO٢o˘9Z8=78<|U2Vz2zg'#(ؗTq(nam-YBulhē@'v8TlVz'*r˴mӏw+ͬ BGEAR؍8[Zah릙gdq2M'ts8_33d8+[G8.T4-m:vO[ 1vr%3 {  ڮqx} :n;ogQ '5%AfZڗTM[{iGUQ?W?KZ 4Wϒ58J/*sv*BSQℂ2F{;³ 53M phģyJrxwmZ]\α(uܝGTBts<1.ڊ 쮴˵MA޵$,<3|1GHڬӺ\Uá{\c͝{I>s=b|"wttˎzŔk˂`t2N ~~ϞUN]a_l@&ߢ?ܹ9˛?>ۻ?H &6FHVf p!TBO\UC e--hKRJhL8Uwزc<أTMCJ$@-l]oEeTƍT`C M2Ic2VW[HY>a֚mU\fcSYY4&>1@n袎6 飒FJ餖Vj}ni~ jz@ tj* k Jk :Kl*6 2;FK-l|ՊPD/JMUk@UWEXaeV@UcFogk-`uǔN,SYgMAqju1kBeg9 dL\ֺ2L6|9׌s:MtFt)ʴ@9ݴ~PO-uO[Zg5[{5`-v_j6k6p-wo71La 1袹gu8H2N9fU'dT֨KNZoḣF)$뚋cږOu s9쉧/Y)$gedzwy_Ocws/~~^t%яuϯ?ϩu7$` Rb)K}Y Ed 6 _&)nHZ>00R^0vbl46iDfA4l.a 1X@(/"V1OܢEod,XjkǭCn#71LcGJY@RKILvK8pBQ/[I$fln㹂Em'2-OSe(Dtd$F"Mtd9 rƳ$|꓌?gxM$hjЄte(BZ10)neX m(+&H.p V!ňjy2Řsɝ2k(KRР M:,*'v 5 >v6 CP:4W*WUsd-Y5eu|jE[ߺUm+\Wn-ӷ)l@ [ [*ucl I\zl0Y2t-IGB/K L =\:wCv-hYƏ֏ )"Zf'9X1poq$LeƇhqK89ؐIbRms4ggMՄ#_C3Y4i5J;ɓ?yAY@9BGiRu'AxB1Y$VR2vQ>dl$%`?1.elUp1aKCNQRP{FI}ɗIj=I)h9x#lx8XupH\"yri&ZI}xq88XĚ98cgvlgX`Ȅ%'9sms ɉ[c 1iש֙ɝjK dRql.h/ Ak_2/)~.W{m(obCJCyrdPٝ9(ʡA!%'(: ڢ&w9YԗKXDdf|.Dqg9JǙ4"87sC1(r1.6<ň0[W~~./z$zdʢeʦkhjGJu{|~zU ĔrgNٔT k-ĕtGws'AFjlw DaDWHk޸jGu'&zڧʪvJɜljzpp fđ=#MҐ)(ZӪIw}Aꞟ)J\QzB;6.[+wsvr MJJjZmU[*u H [.`S_gR_2]#~[wQ {%ߘ1XzT~J.(pxf:=+?>ůE[79I˴K봔r9ʑ=JVNZ&y# 5ZQZ{~gcٴ2yR ش}뷁{hFK<)k} @ٖc{j$Fk=.b {:_aAxw3B薷+& n+;[K3xڼ[Wݡ@8[y IW@:Y ъkkqb Lp$p첽Q jo"he#jM :L+b5C=ݶ E.5dKS匽Uc^"BEtĶ:do.EL_`\ߤK߹-ikR` [o]8(S%/+ςΛWM_b^B埞Oʥʭ|Ƥ>\n pf(1b6#/n5#0.ҳ]ۍ ҷW[ #]Lmɪs'ڈ< P.~ \GkZZL5,>%i;_ۜ[PX1B aoΊ}=3뻓C1'+31oUNTne;Ô7uD S08N /8ܿ?U:~1MI[/lkQ"57?w闾|*RF6UhAmo[>5_<<Ŝ; E7ê_1}~s$?za.Ox/ImŏǯJdw;MEDd0e"UR_>@.hĥ2ny`+B`A&daC%F8bE5fcGE$9d)UdK1eΤYM9uOAaсHeRQNUZUX^嚵VaZYhɞeZqU[]x~E@-dZ슰í\ Vܪ 2dڐjSjr2#Bf RH"3ɔ-̔DKٰLfȒKreRnڸ̥q3ХGg^}Ã[~rO7]]}z߷?_~}p@]"4(d#TpB%B10 ; CpDE,DQ41[dcTqFeFqpō|NHl [ R,22m)&B)&آ628!2¬,28'x3R3.Jnӳ0s \H?1I"3t;SM= TNIQSŴ@V[uUXcU V[oU]yU_q`*W`=Xed6Zjj[nlV\pe[r=7u]m˕W]xmV)#9|#l1B%6C3hBFKZ+4.f 4t7n魷V|8.RCeS327ޛݝf{7蟅Ι碓YivYzj& bPkk nnPl>lN[nf{.w6W{,H1-#5!˥6$P3?-02<󳁓78iG Йs9dWQFx˂6W_qo{7|ſjw}io S4GQc e V(3-$a g<TI VM6,d oS2A/3Grޘ$OTZ916T%T;\ZWUyIWJl"a D6]aX< ,C9N1a8C(sfPƁO)mf*\ \[c.,?y <5LhowC=s0c#{\&˅r˿FWuU,YyWҺv]v7#$$0Nںn S-ӗ"*Js*ʊ }4%ɺ-jyv-R将ǻawęnM|b`&)b0mQ:αeB@Lm9y&e*a`&N /|qBC-ou۔q1f8xq{l7㙘(ss(8A8)%=BJf(CV<'a`kCf_ȶ7< e>!=2|k5M[)gWp\ qGa86usgqR(Y٤9gS*͔-q}u@^ 'ȪYCTO  $JMY%v@wx²}t]=;>sޖwskL4f;>o|#-YN23K# j/NvE ޜluJFNg1N"[.syЂY3!o+˯.G_kj}__WljV-%zKRP~$[\5j؍HG5[=IK[>@ @ \@ R uC9A,A$qЬ $΀ 'В2C8:hOj ײr2 }KԷj¢&ܣz!oA-*B2?;6BI̼MMkL\L~E4NGlN|%@ ؒ(L˔Hf4!rȯ<<ژ3~!Ѝ6BDNzEO|w NH P PP-к @X I <"x ţ6AD!TD-Q=QMQ͈lQZ,ìQQv v2K EKTxP) X lG kδQRR.RE S1L3345mS4IWAJ HL+9b̈́#2H:7 6c7UEUTF}TG-2TOTNTBI|D\%UV30;P\T^ U`_e1J-CJDVLJ\dIfUfViVh}֣ILRѹ-J?Ԙ TDl]Jg}WjVxlWwW{VymJcpyѱ"X0=XMX']$,X,$>RC-eؐY-Y0WHuSHeٕmTY=+Dɩ3C?/xٖ٢=ZMZ2JYE1_էU ^ըګrաVҔPUxw2KZ֪u۬}۵kZ.[[[])J $=pщB>W \[ʝ\˭\[ %5Y] } *XxP p'x7(]ҝѭ]ѽ]\5)EUZ^ @:=%^^^mݽ^Z^޸޶[aA@@_^9^%z|mW}_>y-`f`|%`6` ` F 6`)]aܥaa>a6a.an }aaaa aa"f_EMb=&_]b*b'fb,)b`)b/ cc2c3.c4>c5Nc6^c7ֈ/I^;cc?c@ccB~DFC^dGndHVHfd5,dK.,b%'-+PLe(6eOeTd=dW ` eZ^ e[`Y`\\[eaebe_>`._yeg>hi?kfifnfxfpGdrdssdt^gunrpPez>zNe{g|gV.{gQg&gΉ6^7nh~hhhh6Єhf0nfoihihwvt~iif癶隆i͖igj.^e.>j&&h6j.hj:]feFf]6dꬶjjkfjFf|jNi6iFViki&.l>lfg餎꧎jɆFjlЦley얾h>mNm^mnm~m.Q.hkmܶhf.nn>nnn~nnlMnl.oNNk^fo^NoﶖooVkFom7p>pOp/ py^np p ppxpoq/ol/l'lp؞ئqqqqGwGp!/r"? G!f r r+r,o&wf_r/gq32Gs2_sbrWoo:o;s:9s=<>s47e%7$_%otEwDtF7C:DLtMM+NtOLQN/uT?uU'U7uVuWTYVu\u]]u^u_\a^/vd?ve'e7vfvgdifvlvmmvnvolZtp.U@Pw`pLwwzw{oz{w}w~uv'7wGWgx/x_xxxxxw'7yGWw/ygyyyywysOz_zozzzzzzzzgzR ҙ{h& M}{vvKWقO|uR{[{ Wh|{M{p|x{xN{z?}2_`}oM2hh: RM)oY!ַH+_V!I[p/{MX~w~Ro )}׷RH2o W}~~{@hY0{L Vl=lHŠ/ZW;1& b+OLr%˖._Œ)s&6o̩s'Ϟ> *t(Q Z6%&I)SZH4iԋRHٔT[ ùxv@ &!ed[af)8)SU&\.*])IٞeU 0,ҹQ$Sf_I>L0޾.|8Ə#ֿHۋ@)zrR n٭M]ȶEh }NھH5$s[NV%!m?rVt -[]jw1R;,uY6Az~*l@hȂ{Hnc.drd$@,Ң i  7e/] $ ! j#]Nܯ_bZȢ2l#%=aI),LA+9eVe⬀ژ a#;%&hX PA}eb۳S0+A@D>(Y J\{UaA`$mdD =W=hoҡ%bY #9׺5sHTUK, l 46)m쏌0%e6#A㥇-+شɵRאSJ vSI$Lݥ.V!W"d!q q%V93x>_}&~N γK+~;FF3{ovj~'dHJ/nQ[U#z~MyODpu6@biO%r-PSUqM[EIp*7.uL;EE_i D|9B`lX An}  X] vl bd X}LCAd[lGuCE=_ϤDXM~` {$!A&H5YIY5aѸ dl{ˬMt,b `ĘdSmat $Ō4b ydӈ@IZtZKdOP\K[$Xsh˰E 4$v-\J(ޓ$CQIbJ)05,FUvaES`KTgELŰ|ǘ I\EWc,|Hm QlOMbB*B2@I#׆$ȂP\]ȸU[ͬr1G͐Stf!̕ Y堑d ƫ %E a,ShdӐHDR RNVM`K`BLфYI.ٍEHڍVDKK!RsЅT~c4!Q,̒ 5b2fc^d%^5_N9^s*PbjjFrӔNGjBejꪲX!N@Оvj꯲ǨjmRk*2M6kR.FkZbkVkjkz뷂k븒k빢k뺲kkkkkl l"l*2l:BlJRlZbljrlzǂlȊȒlɚɢlʪʲl˺llllm m"m*2m:BmJRmZbmjrmzׂm؊ؒmٚ٢mڪڲmۺmmmmn n"n*2n:BnJRnZbnjrnznnnnnnnno o"o*2o:BoJRoZbojrozoooooooop p#p+3p;CpKSp[cpksp{pp p p p p ppq q#q+3q;CqKSq[cqksq{qqqqqqqqr ۯ )!+2MF"CI\u /D&x'/r(͆PDr+)2rL^xﵙ,_ˆy-K-2x2Y. '*s2k+H|2CزW8'h4co|B(6g7 G1Ҭ 8o0l3s;ӄ-'<;3Q|r r=sPxr ^s?tO#j+IA3Mde38jC1;#G@!,J!,sH*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJիXjʵׯ`ÊmX* !,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,}D6H*\ȰÇ#JHŋ3jȱDž@HI# <ɲ˗0cʜI͛8sɳϟ@ UfQG*`ӥPJJիXjʵ׋EL2aJY fӞ@מeMV۷x˷߿ ,1 &(@%%yqʗ-fE? z *3eͮt&L۸sͻ03x $'Μ0}z3߽s~ݟ!x.er~QA 'H uuAIx`YXfwxazǡwS%Q HbI(4h8eX>Ё|=m`I5Ix9TA@xQEgƂZ)dif` 0d0؅ 愓qf קwK4gIgBr褔Vj饘cuH ]b ]adޫQ>^>j\iF}k8F8k͚AKpR }akmTͶEK64[B(,k@WpQBfӾRBpMjP*Vy ` ,rM /PcMPq:TE1fzڔ@ b;?cfD[ܼ(dG4hEMĭL^@:]]ݵ`,d=lW[R\E- *=d56q*C6gc-MD1Aΐ}vuwǍ{ݷC'jK4Rٴn)zrTae@c[A+@.VDj!V}At/6g\EY-M>Jd஻-p/hĥ eb.PHXSDg!g &UaIB'#$8: vH1l8Z{d. LLLQF[̦6MXy$.w4N:N%OzHҹ5bg@@0#;8V0er(@R ($P飠>Q M{r"ZSiזAt'3O2+o2`s&[A#SQZ'TM2jV_P:v` X;"T>UHUMfxcOv88N [uX!H[Ŀ*R H0˃.Aʬ 7Y <@YYd AB;.,qk!AT^)]lBgkͨ !`pكֱ:ЍU$,"@Uݜ%ZdP3hvZf9PCr$8-PCV`u(>H@NDKvBewCu48 K:1;k2T=dOxJ :wcܭXWȯEjvڣeȴD*1/P'|\@IFVvX9(evmXY ^:# dn-rmY%Tl̛RL&p0=>fSZCǨN0B}UImi3xf!]HL['{*ML'p 8 kq)E&p]`LbXR(AHr7]' d!Ublqv^Hv}4**NAid ,E<q(VӨ_Rd:0݁S%hSjVcs2AWwꖻ喊4{eяIc2zUsj<g(l4pONIbdˆن KE(B8KYL |)G%HֱGmQ\J@vmVGNnkֱE>:c@?{K)4om%3QT y>5BD򝮗oƨ  ɗMGwkjЃ8%_[@wCooXqX> 4@6xʰdnQZ٬;%@Z*0:z +-uYazxh#%APF?bZ!qe\0KXVGf#KqvI0Em_v$AIw7;G6hXłtvm!&t@h'K\6wUeVlL8VHFeA2i 1yycߵWGyjlOnR?/[ku3w݂O8szlc&O!Sb]PUTOP6uSUSl5PQeSn>V!"Qn#?HBPu@3"I}[%r'5I&SxxJg5Rcf`o3|JSpc׆ڸ24\mssNfUW8W48DgtztWt\@Y'FYWH%$n@XvBY2jv2x)G(\$Z'@axfgсҔxִx!$,dK>/Ӎ>@W]R@+JėPrX<{D{6Ԉa1 V!VZT3&{BUB˧X'Cs` .<$.7!_!PI RgH6{Q?|R^2-8pyae4GMMj?C//vJbǦ Y) 8~*YjiH5) YWd,dQ$'{t&xJHقv5fs0 0jrf9HAZYV_4ddsXnFךх27 ! K)%r|sjKMUrk ,=ňI8l 8Ox "ʆ@[P!+F]MiSJ`mP5aE14n'XnI3 TR"ly@& f^Y\c7 Jsv~xTC] q%FVfzh:)W)J Թ|w$y7_(W#t(M5/Ֆ_)P⁉3U䪺P#5%S?Nd8+cNI 5СKHL>hkA(} l7<5MҢS ShhfEW\fofd* Cg_h!Vxf׀mzJ$L|]ui9 -7ɅĪܙaāIc]\5R!!ܝZ_U܈uP0!#}n ].%s3%q ItAYvV]K&aRb5o?E]Sbٲz(fĵ1^-+&0PU>3^1 7awFBQOfa1>Kl`;%Mդ+js- enP@*~x)Dq쥬^MOQTgZE&0qrރ9 **[uޡ ҫ D1(=z@LĮXE/ӄ)f.6UF!p޼NvUMBM'TϾUy^&X% W^GnüOd%ޮ!Df)מtmi$gzWy Tx=Azl2%PLYZ)F/l㳽%C%\\1ㇷ2?".* 8Pq}~;C] 0']k;5OAE/1&z[{Baz~[R3mBXfBe QD-^ĘQF=~RH%MDRJ-]SL5ml0'Ï;w"8 TЌ>5`"QJnt: jTT/Zu[hm5؝U*`⅙j((Ҏs9ý閽X`… FXbƍ?"[F@RFY战!( lL@kYn޽}\pōG1W˙ouܦ oؚ`}"ݽ^x͟GhϞ̾3ϖ v蒽.0@$@D0A+dcJ [+c?1DG$DOD1FoEͤ$L8#]crKG2H!$HƼ砣%h+;3ÄH-K/3⃯L2ɜiF.1߄3N9礳N;A0O :B32.(PCE4QEEGǖH",SO?5TQCʉ 2VUW_5VYgV[o5W]wW_6Xa%XcE6YeeYg6ZiZk6[m[o7\q%\sE7]ue]w߅7^y祷^{7_}_8`&`F8afa8b'b/8c7c?9dG&dOF9eWfe_9fgfo9gwg:h&hF:ifi:jj:kk;l&lF;mfm߆;n离n;oox'xG>ygщyiO@-*HjK{~|hjZ|{@ (I=> )R?!IRMb*Y 9J7b?mDrI L8V; #ÈHI|0b%)V DS"ŞxFE$((V$ pbaB`_RpC+`J@?;D?8#xͯ!?\$B ]g JQVI@} xV(.$ç}5 ep,aY k#gD$`|0̊BRiD|HIFRd;X2=Q5G"D"R I%fxӯ"&bF#.P{vr}*ދEp*$aiH`JQ2J2чThУ44tHE Ȱ`oݫ: Vt's)$RFK30IBM#`SPV4|`D*& JDk:Y5,]ЎK"TIIjA*QYN < ҮN)(BO VD \5)Şj,1JesS"$ڲuqTQR(e+>=mm G~4*?#¤2!H]SP"΅$K-"S'K%GV$bM`>+i|؄q%B ֆ=Mr@1$Bu[=0mG.ѰU:-Tb0^Ba*) 隠/1!yk[1R< 5KUClp#mhW!//PI@x7)o=d/R=PƶQ[ƘNQBf< {#I[(ҊQ%Hj8 !ݕ…G&' ,\5UB6ۃ 5ɐ:z6zi4>/DFcɓ:Ў!9!R*L, dC&YJ:HY6;i7B|Gd90G$;dFV4Q E;*S8T@OS%l̰w=tDuP1:f[:,ʸgu@:<$TkxBwOy$r3'sX$hz #á-1mb6ل>+5%Ý K ܊{A0K %l$Tì 2$ P{ +:$ɒ".∬tO1B*c(<͒Ҭr.(2p@77BL25P77 A%HJd')! +Jz./2q LR0[j5 ]J*d !NG ʨI+1%I$IlEsT"1=HAEt" (;$]‰`9Swۺɛ<I̍)!#  M#HX'rB@3Q"S:-X"eb±R9HWJ"]GP3jA7 @B*ʈ XA#:5Px UVP}Pm9}3Eme7Wp,,6r;JR7uRh-18:K0#@?8!%2h= Y #@%ܓ;)x Q<ʆ3;RJ!裤@UPй+mS[){¡.UM:$X8@C$i#B Qj'3rB)U)X$pL'B\qغ4AV Kq3FS#0>NxV@?Z:R1zغ]Q c[ZUK[- +zIЃ-*%^?܉N< B\EۦS<[VrK;@#TPCPRH2h '.;P  եJ!R4ʈ<9{=^55zS2\b9˽fTLˆ( džORXf;/5c>DRjYߴ1ERc0N r>:.6)3OGLY, bƽ5in-C">z갶)+\R$ =;-(q{Uٽ+CL^K=5%A@.Ӭ EkfռKHY2.XV(MH _-e42AHI'-]KubU*D~=<6  =&0NtLGj_6JS3dZdjBf JHe P3GSMR9R]VSU6TUV/C7Q8=䏆;3ԳFGX.ȏ&V1%9" CT6V&:VmŠpZH HQ_etpJ(Ӧ[&l~bKa4맣0c?nՈ39f5<B8+-D cat2OP+#@#5ualqr)΃X-y,aџN^*P\Kĸ~.U=Sٳ͔˞($}$ ډZgX1FSt x. qB/5^Ke]Ē:uv^ҏRwfa 3sUXH*"C=&NCa燸tQ}k۽p)1.ɜf"n:N>N5V池TLorΈ[* -7?=RU={>[cZ}%wˊE<Ff2.¤vW#w-"RkkbC w{:TBSkL *ww2n\2 „ 2l!Ĉ q e4rҁG]ʴj@"ʔ*Wl2AHdN jJe$gϖ6&*I` W)n* ɃR~ MOʺX'mmMI7]:j Јs`˯[>>x땤$AHJ&MW7c5P p&{w>luRYvdJ:tqn{ e6\p)5"#/5+>~6ȇ$ }M:u\Č d789jrQdX"u3lH'y/åp3doS@Hz:o iEP6T !NhX3 @FI$Śs!GṄT \ B&)EGDJPJ\/ ݱZVDKJ9IHmrʩRAL%eJ8e@RYΥ%H)'UΕ<(ykMwʐH%ZbӤ)EK6WVEGYKũp:VU֭E;2PS*IP+yeTj.iط|Ae T.VkEՏ,i`FSʱGڴ\uZ>ʭV:غB G+^t(|- Rn08ũlJ 1Hpے nF2` p0HFRh .A* ; 3ݔQ!~S-^ˆ (k%~)J($U44 ߘԋAcX"<̈<$C^]-[^V.Ph̜"Ҋ |%h@Up n,h[+wh'05?[t+Bҡll#GK4C-QzV)E}g=^%)I | Ȃ`g+A)Rf?--Tm~IygS1Ջ/x6-qQȗ2S+Cno.9U#7زe }z5 wO!ں=b jyz8C.`RW̫H?W_ZИ HkZ+ 9F,Mjѿ(.7v3SV:-"拘?1e֖JMUEnDKsWwCw IW#܃ZX1O0 :13/ώ`e/4_0Ws$ӯ?{s:{µ}Yl Y홃q M V^ f`I@\U_ĄȠ%m0Ε߯An [ɛ )`C-GH v~!aq Xj >δY^L% p v@!""&"~Y Ӫ-`% ۪TabG""++",2Ex  ʭ&AСrJ ^!:cΥb,N#5V5^ړ@Ӻe~EX ~?EϽAF#;vea`#>>#?VO@~5%b SpB)Z4#F2=#uCb?H$Iݛ8#_7.96u"@͙(⃽#e]!`Dl$S6S>%T&$^/6@ޢV-!WޗL_) WN^CXe36P.cFepGrAT^%_[?ɤn#p`a$$@8&d I^IfdYfIDc<;vc Rd_Ʀl&mTRO`Wץ&pW}qrfĜB(M)tVQcE2Dj\Bدm^iC&yyIL>bb!||g|*YHH[Y"LTCALZ-%z>(F&_$@'on~(Wbv-re~(~hqڢL*?Q?Q %]\H%lN(hȝ[@`f&}v|Fi|gk>Dc%huh(~FEN)i+d n襔ap6boڢhnLj@Y~m@!wC)fn*ʜ!f{J>iaKLjn)j&Bj_ͽf*JT憎OW!V( Fp*@ViZhdU(>TQL@63x+ƫ+l tcJʧ`'i`99ؤ㿡LLJĚ#X#v~lla"+qblnکp,~`FXA7cTt:c,&@톚 B)vlA旞fJT,~iCg.-ƭ܎$%^mb-ʢ(.~ޮ&-#v2ڤtR,Nw+ʍ'έn. ' j] !eLl*@J-^dR,VlNnjbkv/L6/޾ahbVaR}2+ ܷNhH8Q)@ %/Ư,]B/>A.Lj 6,1 cM@ڢQ*J+8v0%pɲeR/vbb+.,K$nA".lHĪcE1'1LٯJmޓہ#0[ ?}--lJ!HL$ $j!H*1 p oUm 'k #'-w'k^%UnA3bn q2)2FaFrۂW%~|H*%%;M9&O4O35COV`-2ގ 2\!";~$@$Vzc7c8irH 1uD=Z5@t7%p}EmrB+-/&rfJ.r:3Fr|D/j#sO0Ěk3{A4MtM+'R477ہ-7-2̅_-̭Ȥ**2΂&8&&>/ 6Ff2E GYFZNYi]U[W{MC ٹ`r==4EO*6&^ .HyDDe(jt"sW.sآ=Fhr-g1Em(8@@Wo9mL]ࠥl)MjĎ_'7`DNu7P5B? R}ڥ$L C NA>u&K'sr+g 3܉s \ Ѷ|1n/S P)E ob4 r+OXDD8E.DWD3vj%rJ|~s}seHs;+#3hKl(?JAK[yPy? C2OqyN<ܑ'N0LϏPENۆCNNt;cPs}ExU@0Ŝ9ø9`aJJ)A&OnQ\ R7_QXT0V&yaYu\e c D.DʒZJLȥ{ x+onϚ'6E2çp.8ڍwx[axW6+A;bDlBeMNNjP tRLlTHw@aZMTPe._l-N58_UPwhXCYL,t~LS >o}88@hFWv\9-X 2MtXьŊ=ZldI'M YKS&(`fM1'Lᢳ%-ʥ$UiSOF:jUWfպ{H[ZXzWV~k5,dZ @ מ@@-#X{BRZjR=gmbVcju۸,mR2$ -rJh5K%t ඪr@3nKO6zkoI^f[JvpVEmBX f[nę$QfkZ.iam3 kvr=Qj)ХNYqZbӲ[qW Ҹ*cN.L_s^^Ő+ITf +ŏ:W%-ܹ$8kXS%ԖUS*8jY%RJR--s>sP6% +vݒ 3M? D6\4E0e7vߥ$(UMklI2tjCq4ώm !rf4wL X'(ĤqmFyͩͬUDISLJ?n*j1 OJ|oRt+vɺՇsrjņ@G_J>ҧ 4T?E?L;-8(čG-&H$kMD=ݺ-OęΤL B@:B-DK@ I2s+)E?V(STTgaʂcϤ++&E/(nb00⡤@;P\6ԢtIg$&e-n+n$@D"=b $U ) ̽$+Tg&"4m!f` j ܀ =\K*b$&$ގG eQ,bF"b r XL "]^΂Box %8@fcqvd0z^"1ćbW a* fZϭ7 %6 AJJNe|1;&E҆Ҡr0M2:B ƍlBdX("]GrVC3$atα-J2F&\xzfoڇ8~-~VCe4&3-txi(RP pO\b`@*@4䃶mIJ+ "(+$" Hf \ X2ri1)"3.0 1#b;0)Q#4FY"Nb`X Bv==39 6+l)`Ad$^Yp6,7vP' "Fƅ7+>68\ u)e;x#6LV䨰v 1`e\  1 dr=ٳ= fsgI&h& @ 殃/$:"HhvU.bIxљb(rި nIhq3Ę7ɀqTʅ$ 3rT(g3.')n (gxrrm\!i/$Ŕ iOiiqkA BAB x1M CW:?tݐ.zKtj!8%xwI.,.!s9 R/ t` Z!E%#j+jxw !w1LV5$IzxW8GV 7:DH@q)8x`6w3S4>܄]H$uAVⷎذFD؄$$  o𔊻͑m9%y) rI7؃Ã/>dBW sKr°)JPp,+8vMPu=VM ,sWG-YX]xVz ެyyyPZ!wrŃFkWt%@T`g}c7#|?,k/ojI( n:z)J;78 IMQp.2sٖqKH/|+vFxwrFHxȧ}E\)Жz:{؛9 TÒ` W N| "%tjf}b Œݚ zbklLj ҆ 8#:{ ( 4ٓq)Z,fKM5|Ua7h9@?p&?[Z,מRU-xv腀zF`M3B*T)zP; :fw{BI _%t"Cf8Q?1E`5 IK0ϮݦF#VB @Cs/2ByCã9bOpl#EI4H]q3ro&ƹr!¢M 6!N@7NDWpƥ}G^d@+_9fUV@QuZ {U(Lm^|+P7vNRK2EUȐ2eC.:[8*r[/kD*$m|ҕ{!ʘ{evE4XX HSYPWRWҰk'`&fWC݆ W} C:ҫAL Ga opP42PΥ )7"f]P,"}ޭL(5%+߁/`ѷ`U2 ?R p8a4< lZmS$Hp|is{C;ŕ0@\1Ib 6!9Fc{2 =EBE M86w@|fޭ^Yry"ּ ޭCW?wg ]hyd{u;m`':R^%`w(.؁CYiꧢZ;cY%bLQb+؃DQih|;s#!.ݚsB>ᇹeN`*$E#9;<:լ[~ ;ٴk۾;7lO]mWvڸ)U -ӹPҦ7v\m xۙYe̔.R$$I@M$~ ܗ@L7|] { >`N(aftQ%P]taB=Xcb*Q $^I X` 5"C 3Yh-I4$<4C[BZ%EQ\c\eyUWU5>Ęni?t$ab')Μ@sE&IkJdKɸee>a2Vhggk(==B'i6`d Rzf e/j# U>:hRU쎚mnm~ ntm-b^ntwfWlq/ooWBgHw}RTF+dH1` [}0&1h1wyW2x nxB&Ls]|ls  BMt]uA&[6&!EZ#D5)YuNKRWnF5)cTa"j`94ٳVYk׭AOi]A;mH (B"AmqZI^ a^i]UcUfn;&NeUA>yҮFdY<Ӻ[ZGeZ7xk&Au"NʓC}NO~A{po̍ BNf| -s;@p9*dj>H0 .h$E 1TB4 k ,DP+60 16YB>$!E"֢H׌AL"Z% . 2$a ,Qn qSEd$jE*WS+A,:\ %br`ʔ:( M PLG6q9{`kpIAZ,Mh\CܫwE(˔#p#@ԧ:Dq)d1X!s+ljsD .D]";yOy˝1{O<),'"V\Lc `izFPAyFT~'5\Qu!gcRtsbrNnY+]J!y5R2+@hӟjVTE!tޮLF>ZLIdm1!ʣMt2RVP1^S7<-jy$U#M: )#lb&d 3i²Rue)IeISs:W/s#-Xvmk[s[8!NNZtd܉ UVP0ĥ`o3DS> aK`]T x!1$"GB(k;41r W:WQ1ٚ")&xb$]1C[úrzsOrG6كXUHAБFJJfVj#$_-b>#bd*5#aS&څGٹ05Tۄ[_>8kk)8 N05,6 (-8H<πEnQ| 8)Ew`0kY9 hwzpϏq` Z|Vg}2¡ݴEO6Q~uFĆ3Jj_*%o)mA~/X ې*vV0E&Z+YSWjNRܤi8.kӌdXb rj=$NRD D4[adV+琠-jiSU5ŧL5A3-Pm>) Gb\w|,cm3\}1Fq\|4ynWY5oӲl%3۲Vyu!>ͅnC]ZSF6AvC>=!rg | &X(cM%N"~Kl**0Q\Bx+Sb@`jcxmM0dv2`9 SV# *JDHBdV#Eg~քL59V2)3XrOHA> ФNjLXiEY]%>)4B0b[Ϩ#+#"f.&O8h07gA  xAPP`iڡNj)3jiuj-sR&UR*c ɧ1ap.VDgcSSI%ODT6S5JZtQbU~UZUFI-=Yeq(ULjC6BBW^j#eW&e1ybȃ_,GKyDŽQ8gWՈSb 7ae88E%.B8c2hݔ0[??`ASA@te\\T[0 `70]e1vc7=dvE"ר[j'4j HwWW3X`q_px'`5!(M%`E"%y^mͲ9ucva' yZwa6"X$zabca҆dEm(bcn9&) Lo9I68Z!Ekb7iJ`c41G-WTd9"0f`WY-.y..N (03V/  @15P vCegN!j(Q 3,X0Y&/sE.a'+}T2_TQVFa)0^TRSLe{qWчaSgkf #z1` JY"RxLܳonxO'7AEu<(:7]U%#}>EK>4 A?z[![AL7\ 2C1_BUI0S`7CtH2'j3)*@*2^k7Cz g5FFbE-b5O$xEE7I+7#'?qGHZH!KrYKSGF)\ЎkRzFH䆫FHHY$aqFYfHLVG}z7-n#H$wJQZpQj"t}sc<H|9UmA&cwFU9@L8{G:Mir>*:]zg`A 0wp wN hƀnY[`ic#[0\%$g񲭢/j'j뵘+(>3@|f3e\l2$JlPSJu:Dcq"T %ᥬ1paM A< #,cHRW`fbhfnnA 5q=>D6f{}E}[#q޸Amڗ C ڠ@,.mD.ٞ fw$Գn뷎빞g*I }G4.~$mޡ6VMjުK --=m,!e٦!/#O2f~.ĭM} //0;Ѻ00nMg-heQ\j@fI(lhp2t0\PYO4\_y"ضe mΈbN)Bnf@3Nmi65[ )"5#JX" Jj e=Ѕ-4WM/NIcph M8t/:&r!$d^E:Ȑ0!_SӪH .SD )6 [c2KB(觎b V rx,yILfA (ɾVmDoޥ ]隍\$y0Ag9-Vg:ewKb&SNzxb'u O.Mf0F"ä@җLp.E#&}ٙ$LB%̏&pVa$i,)= *4࠸c 2c-97AТ 092X9<-hb[BK zɫ(B:⚠j r ZĿ3j ɰYõ0PQ"6T:F/-[RWԐ0spR{+Ed;]#6k7*ͣE1 .BB` +H<.pRJː4o?+9Ĥ`3 W蛥Y?5WFi?7r6(9ʣԝ [ zӚ|*YtԊk; |TčAL["RxK$ILL} B7Y='A{8U ߐ6p78!-68148)9`r-;;,CJkSL)̵D)7OƑDCI,G2DBM|@#phTrѵmZ[3[3}y[&@Qǐ ;&>P)$L?қPܵIxʌzLIqSdA@rMQ@`lĶL7^<*4`L i Ra6R7E5$LK@Tº*B1JCRTMUl t[h(ᱍڍq**V%hcu;M.+2 9g%93V\<ēSUa!+JaoGiXKXN=. N,PcI 9ܶRgaNqň/L WC3H vئY⪻dvg IT)J/ʙ0%(R"[LF s6qLe+OWIИ` P E%,#  T[GYuE} QcHyT[TV!-ܟH&(ZӤ؀F CԡMiҺzOTOuiA5=zdJŠIאE<(D>BEݖ4 V}]%p_ʴBۣ\<%K7*75Zޖy#(N*DA Au"BC6`ƜÞVVN *[U)# >8tR+ÌJgԤfVo-682V布-` EY4DTDEh C 2 T)Nz RNdDTXr٨=(+%eĊcr1m *ō9 ? "Q bB5Σ\d O2RLe)jc<0VKvͭO;Zk4GU+,f<^eVN180QĢ„аU؍݂vCۂpQ[->dN3XыLMH$҉Y]le鴜#A8LGx(4R`ٽIRpʟ(;ĮX0Ez2,A:@y"{؝d- *3*iϓHM) 2BTPCʨ҅+Y#;jb;j 3UҞ$UdNsjVp'D`4,֓ `,Zy)֣U`26pV~6< :laDl<(ސa , lv&R l)p˞ ~>H*glNѦ)e~lm]vD&Weq(*{T^ )*48afc_"HWѿ}M nj[ 0\n oRpTo p p0@RPp o ^<,2 Q8zK u`tᏈVZq8da$  mVapa!2p$ObWTr'r"{ 50* neP},208=Q[>K>kf`fgn4Hlm.n-;>]jK(?tD_)DtH$Vvt ZUͤ2zq,GϜ3mV874kk-q]k]/Tl`pH?vd P Mvgvkـm|qr[ނ)x L^~ݐ5N,,2h[uf=o%w[fvwwH@@:3~%Uv?H6Axh|'Ãl/).W8+X"1wm`\lfuY_ᐋqwfQyuuz"&B _zzv*?rNn.vW]nm-^2Uw\vsD{>=]osڪ-?|O|_h WT--%%p`ϟ2G`]Ⱦ^V;}:,-(lӺu~:ע`/``/zd|o~~hgG&[k.s V-ߍQswfO=?u{{v'f{wыR{ `"Lp!ÆB(q"Ŋ/b̨q#ǎ? )r$ɒ&OLr%˖._|[~ռikV=jC̖\l)R%N*EBK!ՊT\&l7eϚMVnض\trnݱwv-_˶Ze>v#[nΖ1?-z4ҦONz5֮_nN{;o8l2\8Le)ğRE.8˥lPJ*k֮en{׶cs{/=0u5W~|n{gۻL2%fUV VY1HFlBzbrء(@[rK9dbO=QD-%RTUU ԸR]U\1PmRD)]f]mH%{WU]^t1yc6ZQYc%r@wzО{Y"2[nNaKb֑rU8ڙziUSX5sFmq[-v=Ti'yG_zaY,z2ƜQ;bѧ@v m[碛n[b5bKT/PAn0/M#R[H|RB'GEKjYaeZMqƅEfb^*&Y0f+ Ynf[A9 '6C]4I̕. `mܦIkP T%sEq qE&vuʤzkvz~XW|ٝ1fY|8 R ?]hBK9K~-xB'裓^d*λbBQMT&\b\)Z KiPFN0()fQ_xuy[a: .;V\I>Rz;Cz&???oB#!0 \ %J%i2vَS B8jN '~D*leP؛m;ۀ5QX3{xk0 gI>(-iB:㼵-)ЊZ㶈EyS X3~)71@e'W؎* ׾8` P KZ^4&c13KsK| ` w(Xm$e.Kr~.9ar;+ "?[B3Ҝ&5"HQiOP)`?@%ֱ"`bۧN ildς6xHdfosA Fj+*,8B g*ƹWr3rnfa+ @w$"^ )IFY4!/BԋNQ/1GS숸P]z# .9Pw P5!I`Ktc$#\72e/$f`Vɔ8k>*$\l/K C&7L l!d\k+c9)yTN"/Op=s`KNfm$UޖjVs elmKwy&1р[µ;HrSV4N{]r7=HNkPKhԢ2[WC$SN=L$fG\Edm4`0xaJREjmUk`W=20tյ=@L` K,t?%W'mߔY#P1/?:ͤVtȬ=Z$p7x+XqkȩkP-ܓ:Kw[X ?Eb~Uq?{ﶔ!He6 BmRaf_b$}2ynr6:gjp |Cof#YTK;N)g:}b?ͷQ:Kh߇>o~s% C:\gly Nq/u)SIǘPpڱRmz`[^9F+XDYfaAfY 9 \ !\pWW rd>u]א@8WT{Z$[) Nc<+lA}Q\!za}Wb "$R ]$1#L$Ę]V&baF5`b%edt H,"੉,bIE^| /T# b3:3~jI{ԓoqpB pptMVP#@o#A{ 06K$Y@YEE`!DCFa)Cn4ZEb$J|JQAՕ($ړ IbxI>&$'RT ݴ2|DŢP ,@- L 䠠}H Z^FReUZeF$)LJ7EUo$aIP󘞑 ~ x%YxYdײ< `*ZQ.ZAA"+(d!3]edJd:GG q&{<&* OV-Ll0B #ebO$]E\"xfୢ--QbeO21އe2gsR&l6%\xVҪ0O %%Ŝ ma^< >xV'ejW@: a $|R|ftI9Yh} (}(3%(-(4hH}@R(VCV.&' ٔ'PyzԒyom~؀-`)aB˲-N$nZ001 12#s33;s4C4K30r%'CE.ڕF&(1l1]{d1Dsqv`ƪ|}$4$wJA#".tBCAGDKtEStbsIEo4tfov/ِ }*= r-M2oda[Hk#1Q+q3qQ'Q+5R;uSC5SKu?5S sSuTgTQoWwR5VuUWV5Wu[[5dD߂7o7ƻ^9s<8N](Q*."etaQ2>P>,,a%ECc,"74Fi4CGi\6žʒvǺvζmjvmvo_hwqq{,hAW_8M|Ndª8M5z z㺫z[??\UK?#:ӷ??{3l-WrƎvZ-l-n&rSM72#G#/v$Yɒ#St"Lmr#Û3qzHCOiD3etIZӨNjQ[-ӪȖ5{mZkٶun\ е[]y _ poaŃ'f㺑=|9eɋ:Žo~,Z4_ϘYskZqjГC&-;/nuK3lᮉ7^qə/w@V~{v۹w6[ x|]ѷa~}VmJw>ݧ_> zȧ}*>)0RТJ` Cç2t*5, /tɠ scdF ȗw$K|!,#}2&t')+dJ.kK-H/3BA28cCӓRpħB$ i Ub/a.Oؑ!&rh2\d'/OV eWYFaj:/$u5C:R7㪚Gx:jO(g6! dL[_-JohF?ё%]iЙ>2:sԅe1eA:փmld#A w0'ÖB+;aI %a]L7HAӈlg7ϖv=mkWr}b ^rB\:gꎓx]wͼ[vPӇmteIK Gp?%6 4 bj~'æc/V"ט(F2wlw"JH<\b&gER$`,N>$*SI|t+=H_ԣ[Po֍uw^'-xY^?GNM|oL%;%λΓIUcHs'|)?yWyBIa!VL A#nJX\Lְ dMn-Ǭ|6A֏8[AZDxcǵaTm6bh' _~_~e$2Dm\B$ڱ{D=ɛOVTmA(%hakRx6!|i 8ʡE ځ cl25@ $@ T0T_ȠZA?Q0w0,k2HSMi" nBc'Vnc>NR0r8.u~-h,g JJ:p0$a D* FA"p %p 8&A D Iܡ&L QOqcԏ o1..o"+x*) ? x\k(lͲ'rjv@9a . Da^`F! Lpm t00\6 0B6  'YLp 7R 9#C:@t0cVvhnd/^/ m eϚ(ktﴆmZ>q">? [1cOЗ^4h̓! ["p .!1:#;#?Cg;G,Ӗ E{n7n' rEgFkT _t?^'w 3`R:pl)I,ʂ! R91`:1s X#aA@q@LGaDa q@tTMaFaF 4aO%a.CTMENtkPTthP&pp o@a1G@F+,U=&kNU q!33QSkeVVgQW{quWII5X.ZG6,F3G d1[{38 $S q*]/u!^,gJAaumDJ;K# OQMR" 6aQb.T#LT*vacE!Vb6ccK#v$6eV/J01ee[`Y6A/! 1 #40oɀJ#@9D6Dj9#*5$A CkWRn(k.'kEklpr״ph2,8ȲDdEp I02 2VJuB%Q7xcOoX/L iG "EP#Cg{.^pJ7pR2p8i8jXS |U6E  EF_vi''m[nL+Q Sl eU`sJIh`hK` 1G,O kOvwC ML3Xauq=㴕Cq IZ qQ@n7>33~ W9W99XW١/b5w&X$dNŴU[ןZ1[`'8bX ]igs+TNQj59,sD+ t/ G.,h=1/p,=].q> ,-Vq+v"b8`A% " t19x2r 6r Br8tgg/@`hǵ :8$@]خ9rj7H)UR$kgtl(kt eͲ QH@" V2aDk9۰)bEo$`Rh!udU@ 5 7vQ,OI@Քb}Dui[0㔸kW&/3RNTIP"7JByh` `hM[Lƺ׿aKYUX79eE]t'7\Cf'y8k8}wNTJ\K Şc3Aݡb h8ng#v!!VTeبwrK\30/dsZ2l7clX0␘a#{v/^1ٺic :<$/)f 6 i?䘎y4g 5HgsEaVO~noIkl1o0lAY TswOK4P{o;Q 1hD8f2k 쑺Ɩ[3Yѻ"5lb kp{TSKukL h6XϹYWo1Y{1"LU OހBn[[wq @Z8?\z bJ7wo&x)fG@,WIzB v"ae:bT?KYx0// rx[:04a: TT]c_ږfëaCHagέ>:~#n }Oʐ CHrl#܅"`tmMV,n }XlPn}T48KA.B} 6_&9OucP/GβrGl[M[tc֓d%>3OԷqWGtwĻИ7 rL/Ƞ __G9Vd~L;9 ^:Wܺ,71W8 ܴHVv ,妌e0lC@ *L &pc+\Y$˗'Kxdž8jdϟ@ JѣH*]3APJJuժSʕT)sdn٨QF%I$ZƍU\IhZ۶jQQE޵sMlv8)nɖ]JeϙCWnEFJhg2E$\ʙ*')]O*en+T%~5NkesϣC.:ֳc߮;Ã/^;ӫ_ϾEvlq W؊Dq` 6`%DT m4(х]X aHf(@P,h #֘ -8-$@|@)P!AG:uR1$P>EdO*+$UNdc2@({&Q@y&)r*j%&&+mړ%I[G(xy(d)E+ldH@}IIff [la*r [jI)Ik)ܪk9l[dȆ뭤fd:SA;ʪvF .J+.›-zKƛ/&0' '@/b-O)IqQI79$2N#\aC'A"<2D9yBe0lG e?odF Ni5E!$ )Q`$l_ T("Ԥ)}VEr\6Mgzҝ@Oŕ-آaP Ӧ*թ?]TҨRUV}Vժz_*XQ*Ҳu$Y1SZBKMڕ<זR\Kҵf#aE$-.{ ׌uf$NpRg-9L!RRR<鴰EaVnsI^hK\a>ӫKLgl"?֛}4m!PC. ?:Z$ rL/i9dZ%X2uv[f8UTU>uW_-T_5֭Smj\ߺִu-l_ƞuyldzЎP A؆q*I^$d7_sgvEL[ rٸ}X*3Vnw!z m%炿O83pNq:oύLn1 _'HdE+ S(-^jtt:@7 f+YqWHqҗ3N:ԧ.SVϺuS;`eo^7Ž"F4IAfr?N90qFIb#0eB#O[<7Vn?wECq/e~AUٹ2bag$}=6nd3Fd=n9vAxfE34ewn4YDR0 yƳ:e#&xy*H+؂,q.<'6Oz WP#sh9]V{QA=fiT||7x0GuhBTP\@QBAK\]0ixXxz؇|u~8oTbex Ddn5I7xwwvcvxIvXnuvxDt7Ww Kw84UX&5fM@"Sp`TgPPgP;38pֈ0-;Xz>g\i0G^WIhh&OhEs E3:A{r8@ڳsfi"s|G uQj痐l ng`oS`fp]fe@}類~& "y$(Y~%ْ#/.ɒ4~h:?ohm6c.vK)nX >v-SXXP2\YJKoheG95J\!RA]K]ؗ|~195HWÃ?h@85h$Wp^(SXH?i^s\Iv ď(29IqtQ\` ]@N )i!y֙ع֛ٝ?W׈;(Bv#dJ8Fu2+W4pwn<w+w8w8I ZYGD2n%KxP f` ZE$z&Z3~'*ڢ>AW#ȍA܃O„ ze>أ3׎SH|W?OjO+d(&0miF|59To @9-PF192iwJ*uv~zjj:PR0}` PTXBsXXInD63$o2cXXŪȪoa*eըDfy$U(̺7]uj ;x50z6*1w](|ieԅyAPꎌzUH@@\##_sfǷUEY` )WA]Y޹ ˱۱ "tڝੰ7\`8:$e(EgW*Sn5ꀛԀQKJddhqDt(`ءY˸jh{ǣjKYs 3wZz7zȣOb?]3s쳚{QSJ{3?[c>Qo%ŧpg`%kۧJz7Y{ۻ뻸k\uu?,+Lvm+InK#V#Cl4#JK۪b;+XͺXsi g Ui[m{ĶDɻy54: wЭqN*y{r$"Mx!7A!h]?@ZѦXyHl$⚹W Rr%7)|루^1g :,]9\!EǸc?M{ \S2¹#\ ӡ|Ŷe%Sf B| R &s}åL]V/FTOxY>nJsdo4E4Y+xcw4Xn`Ҝ4,0=]d^pmiqEv q8J;:{犟`>j7Re]^xAV|o q]>2ӊaF&>?ئ~ꪞ~u)/> . ԻٔtII٢- W|\ۅ̾ZrQkI\Y!e,gn[k^9~b?Xs^O$O s+]P h 57ȼ*'d]{zmЬ֝\~#!$/ "jl{lb(_(& LPbD!^tHbDKaxѕWvF$iҤ-Ux$I(Q:,㦍2fʔ(cgСB <UTU^ŚUV$leǚ-K6$jѺe 7-ڳl뾥6ܻq70_x6aƊ6^cɅ#_yf˙=s&ΠG.Z4dԫ+& iӬk:۱wnpōG\9dXq]znX_w~wûb)>=XS:̹/ O D?LȕV>[BI@ +HB>0DQDA !?'fF3(#`9+ԋH#DH%drI'I)rJ+J-rK/K1$sL3DL5dsM7ۄM9I 3O=SHy :7la{c;{eHeT%[^ӛhTQ P :Vhԍ SczHVS:(_u%VOc/%fS:i2(u6٥ZkJ 0mڶ[n6\p\r57]tU]vu7^x畷^z7_|շ_~7`X\E8aL簻$[E=}A{ON0trEVm Iɰ'DI$Zhc!OIQjcpa`Nk&{lFlf{mۆަɭ;o0P>Pocc+H/u%OG!j}gT[](W3saV&Z|-VX٤fˠVoݓ ,*zRm6t;7qKwߚy韟~y귷^^{?z?xw66ßPCc3./ylDjOӢ=EB*7X"v(PDMHED+!1H @nd ϋh4. &QOt(#+"sʶVhĸ5Otb8E)VsbE*NAc@p:U[NRTajSã(GaHtѩ9VjtU!b+1T2$aĒ{fՎvCq%FRN%[C6avlC%,eKZҖ-uK^җ/ɭ2~Df2T [O:1X<!IΛ'@Ӝ+E̔ lB2;;i LdI=W|?AgvC0VYʵI%QNԢE5QnN )24N)-xG- Z\L=4ԑ[uVd]j>5|[YݧֱUi5k\ZֹuZVկ}-*cfհ?Rlcg< b4p̳Qɠr3B8;mQ$Ϣ9deZ{ڤ6%-hq[2 !gC~nsD=Q.׹ͅsD.՝n۸/׻y#J[(Q޹GZEU,b\8ca-~{9"tb$"i:LTr~Z70IOև ^Yf9b'6qQbu0 a[+ځXIBnf5O?>4Bপ(L䪂Ze׻x,_}W6ld{Uv iuʃqlW-w:?O,=l1˶{\Yz֜fgͪ!-gNxWPm 9ޚ8}p# s6]"RҬBgE_J{.Y5ڕB l;lA-HA1fqw{?xknGU&;b憔}C. ?zw?;[h_4gҹp&mZmLe^D-([v:as72qR5yW|/͇$|cE vIHq)|jGR0C=J:4tE}?Q3+CjC}\,TJ@͚*h6Ó6R-g6f@6o @ @ A"0ß7ɢ&l<Ù<̳,iyS23- "8c )+-J;-0S8~*7X--L(„*@\YR"鋾6d7t8>9 3C?DQ/A4:J["ӷr:R;Uq?;:Ub5Cr#;Q)N5hɐ PDLDؤ 2Ll#b ^J̰$F$McAe 2fiF' 1Ɨ=|FВĈ{ -ZL7؂N $Q,5EQ9Qp yc))CˎA͖DH ]ґRIɽ%%IJR2Iy嬴Js0O 6, tʩTJ4J4S57eS4}l;5 |RÊ.pO혦t,wOx- Bz˹,K- rBC8y42*T…87((5;1S8 2;\Q]^VL9Y=$ye;4G9"G D<@HT)Q-:F 5IIN)8WͤC*5T4VZaE ؁؂b2؂|](QX$MDUFhEFi.4~" !'1&ٔOK%'LSPԧxr=}2=G{ tV դ ֥UڦeڶV ʉXD=?O9|V~I"uMJTDĕURDs5< {6ש]<3e\=u8m}\E\u\-,<Ƣ+hZ”Tl䐒R"4P Gs1UἑEIҼ{28UP>О@Z3Yܖ{Z.Z*M%3 M9c/꺻?VE/q-!#"͊@HL,*nθU^` x֥ФZSi;텊MX `Y6;` Rq 3PD;p[ZslK'u]ލK5¸f( -spv$#Ͳښr2+b蝛(*./өQ*04nVi훕,[T#}_ ഷ]W)aҎF=m=XcImաgY`5c0]˭\ʍ\˕JK\NPdKdL\P&6㼑-1AX AcCe,.'΃]{;Lb~j#bz+B' rԪBTR8R qUopq~.v:Ud@ fuZ, MMEṼ]Ʉ ѯ@*WC$u_`# ^(?w$$N, Pg &6Fiq`]Teu8 ɳelwdaڬ!Ɩ`fefe}3$ ̣5fmv$iVofvo늟7IۮWVcf֑Q82:jS%_HZ=>[uh%Ҕ_H\ARANFBNCMN$sd.VGWgqʅmq qXi17%FYf}ݓ-Rᕕ^w2jͻ3{bFoo~5o6g7.bqAV2_"zIW#rwRBBlUl]t&MHt\0OOYɑ2uFuQ>TouUW֖uV_uіX嘂mޞ`w]*7΢r 1[rz%ߐ]#sTv|h~P^j^22i%Rs7w8yw]_$PMTcszf09okxpKh /Jh.肨_v>R_|Rwۊט2+ ,h „ 2l!Ĉ&PD3hc7͈trȏ%CqʥWlٙI՝8uϗ- 2I7)MQKAmSԪWFu:UU[i{,XX5*Ykእ2ܦf]sa. 5&۠n6blXF2&Rl2f $$3ϢCm4ӪS^5ײcӞm6ۺs7ƒn8ʓ3_9 2Sn t)D\lCM;]KG{AVz D{ S S.V=F[Wv"nxb+bG\,Ȣ-#2Ȣ%xޕ)Ey$%ĒM2QB9URyYb]ra9eyimq9uyy⹧}gI :()cyđG }#=Z $ M6մb/8^F%[V[pYzV[UhESኗ\zl_t% *9 b5ة!Ej@HڒKڸߊ .Knk;/h.٫/민Lp'2,0 7ܯ?\1k0oq 2'Lr,lrm;3G&0w3J <}s e˜GiyӤRЎd{6 l2ZkI5i~+Tz®La9کҹk2TK&X͢%zBeG drTZCZtVgzRTI,VՒ||n-zr֎v~-[g6i< I҂JeTTZ?ItE4]]$=nফ+I`tnQ5.ߓ^)0b锺n&c-*ή®.bd$flAvRO2/t,)}0-jUk." kXqZXx#uE2 馂FѤ~ ]Ơ W 0MM"j3dC;W0S騑ꐁE k4+B!TTm#޶֘">jvh2XpQX좤o,}[--E)Ex0g6~cfb^{v1;C׌LlhE5L|lR%d[ui֩ rvq,A~Wq`HTBjZ9o կװs/a Mw)2*f-+++,2yr̈́ {d qD&p s 2m GJZ]4.nJF-]uʄOQxЀD.9+2>Ј)*.׳=۳no>.b`nAs=oKnci޴ zo)ʪfׅ?r/t2Ql&2&&'G$A)7OtPP5QjRR5PmRϳ:] HR°Sn^WS^2OWaJ.ԩ/iijeB3'Nk_c((>`ՆqabC6b&a ,쮹)`EE[IɂGvKtpڦbR/mjr]4o#g)-CFgpEEeO6ve'r*-r-?sGwsO-r 'GgL)B[|eV!NU6/3ư1*5n/Dl-@['s281( l.{4^ތVuG{?3B_gBox?'tsA _moStHyvHqFsxCI`KHPVQiFD6icvl$4zxW\jTm8w9)npL^0spyyGpO_Ǿ6NwX[;Ҟ5VvZzk^K; Hk'yw_d':OK6ߧgfp{MZ#lBwb8jO݀ 9N,!9ov9Mor6r{Z~r4C"D;A7u{u;t;ʒA7GɦuwҷM3i;i{^^nx()Z܂ޝ*o<w8BLJ8ɟ-vzSW>Wyzzuױg׾{w7xᇖG^|zo~zߟo_~?<ϻl0$Z %cB[,CW0 ;4lJ1˞[^[ [pQG qdqQ j ̨(#!l "ԈR-/ S'J323M4\6T3N6嬓;ߴ3O<>3P>SC)kt0첌4GKL!KJH1,4cV_5feV-2"yMe͵^ TՆV۴)DkV[v?/\pr7]rmwu7^x w{U8-/zlG~R<SZlaaŞ_ntGsqy)Ҡے,h׍JIRe[-z .矁Z:*iJZꧧꫭΚ+N读K(Ҳ+MkS;/FK{ՎqLCRMƀX_'2XzKSes1R[9IOF/MOUo]ف?pw?WR݅_xk9[s >ċ@ROL27h]ۀf-Jm-_ %? J$&C@,Ql c}T* h7XJKZH`"SC-!"Үvc,7KYE N)  .Mt?B/yՋS8/-V1\E1zd]z!eo`XӱN !#W):e>sq5ln{X }>,CYNV]P%hֲgZ0V撰-8aڑWGq-|d1cy-!R? &Eb)XK ig9èHo ~3jLȦ:rZ񎗼e)(*q-tZZYW-ey_2E-Z )5ಲJC39m EJG5|7TXǍq$cnJqcюǢ@jY E&򑍜X;@$@2,LI(fђc5F#H4q2 >Zyrt~lL0>=|-rk=dE/_V %T.QS1Ujқ=MF -dš1»m* }MƂv)Ќ_f6y e+y!tُ| bBy6&X\n<6ά=Zu& oq8R& -LaR*n+ԧ(rE#m"as gVsS9~.CL=pMn W}iv HcKz5i_]o{_UC7}bSmGxޗ-͡H*í3h +asa e0Y+DKſ9b*/Օx~$'@ g*P#t?#?c+"QprZ2Gșc-+$>\ Ssq}3f 1%9l-4Ch[#kbѕƴN׾{oR4B_!}Tk-gU+rv9LͶ /i6<|W"pmqAm7 .HPmS[Bg$@ f&S(yv f@ȀJJ69H ؎ "Ό0,}D V3NP}/患 (0 kҫ Nn0iI }N.lp'1HrlJ D9̫*Ǧoroo#P zpN, :Q1EIX\ae1IG%6 $]oSGan 1ӌPΌ͐@ &>LL L6,⒏pϹk 䎒lLl1ձ/(MMJNQЏڑjEf NS`jٸϸ` Lr &@ HJQ86 ʀ$Mr6U,3)q&is\p]Ppny}w]jfn'="͞Pc@# }O-|J@$ǒe, khʇ-fO!Q)m(r/0+)н0pPr1 s2Qe *NT"C""gl؄m>l9*>L@iL*,ߔGM =t J b*%eJ)Vj@X `-L5uS0+1SSTTSDZ Y 4;sZS`t߸$Mv`F+r8Y H9mpL JAEU5[wb[5\`O=!(pLJ,r?+U 4M. m3etAs7ty y>"N)Vfr8 h?=vv3{oH F3c8 vy6'Q\y\lL5o@TnmY-!_*f5Ljaoiy,E xe M9m598DBV+Ggue7h^Oe#'UO6veDXaԁt4@<ʒ \Ù|hˠڭɯ<|-tTHhwfٻFWAL iw"rC[i0W C\qg5#L֬u:򜂻O ]' ݘ)}ҏ-@ԗmGzW^G尒PLb]=UB@m3'ke(MֽY}'p?#lU  Z@2 Pht<#[jh.9RcU=lqmށ rMm$ S~<2Maod$#ֹ#S}j?^=~|8'ХW商 6a ƾד_ oF+H }}(*k$$-W >Wmfk;Nt oT$NF4>/-]4&~A=ggpW\ lqa۴MKGCCI y_&pGϭ=Woʒ\ _^ p+ne4F`ɏ7?!HnA}nqUa=U^U .[ 80… :|1ĉ+Z1ƍ;z2ȑ$K<2ʕ $eL.iʬ93'Ν7{gP< -:4)ҥG}iT,Z5V&DW)b%$8xmaJٲ 2[ҥeݸ[6$ 8 >8Ō#B2Lɏ#[|2͚;S4gўC}4ժ[~6k k۾["I ŷ“(=e7l` 澎=ܻ{^fǓi|ѷ^Ӈ??}`'`^ .Q@&Xe7@k}S(0[R䵅&b*bAɈ(cx;c> Y#CY$=*#KbRB \]r)AJd9fruqҔjfn9TLA5Tv։zҹ}gCIh,]9@XJAe]JlW^inFl~*j*ꪮv kYvSLp l] (,,FK,j+-z{zĎY~Z% ofdOڋ L4ϚyΛ{yϠ榟zo5Wk;=n{z|dL' }OOI+Gy;^}O>.z~tON5R׾ߺ0vn}C[@MLvA V0MR?k{#JȽz"l _C}S_ ghC21,"8$pk"(JqTDI<,j1\ܢT0qdH M3l\aR8q=̡qqy@ЉS"!DC&rFTd HJRz 5X Zrd&? ANFf,)-6QkL+wXr]m[>/ ̊!Db19d6LZ0 h5kj17I(V\%9ǹ'8slv O]3e܉|3;\f?Odsm~τ*td(JAPF D-ZQLmaG? ғ$IωҴt,m)FYOs3KoӜ)1 T*ѩQm*5L]SQJu<9ZҔfU{TW)ӰT1-ִRD*[j uT]*KUDQ%,Ėqjc؏)v bY/[̒Ug?B֭q%mPGKW&v=S_vjko=oy wfkV&7TmsQӚvmMuKjwE ;whyKqw n[}3Y7} ` ؍.u L`6 npd aJ8Ɨm jo=J x/rM,nq`VҥEy W 8qxw$/n28%KyIQ\2cj\1y4Y0nfyt Ag'sqꬳ ZβD{ sHVєEzl^"cF޴3=duҤ.Ͻy ?}?Eli~[K?ԟk?߾?ˏ?_?O?߿'Hh Ȁ }J0y ` ` hxȁ(Hh!X(x#)Ȃ*/(.H-h1X8x39ȃ:?(>H=hAXHxCIȄJO(NHVXXHMȅQ^xV(dHfPj8lk8]`Hsh_xr{}X|~HhDhIm(聒؈+Hȉ؉XHh(HXx(ȋxHxΨXH،֨jxHh(Xxո؎Ȏ(hR+|7|gy|ʗ|ِ ɐi 9|'#I%i')I-/ ) I.4y6Y079x\CGiEoEgBHYu#O R$uMGRP u+Ǖ-~aN5qF8ws2-bɖqoqp)Ԗu MU ux9V}fiJ^uٕ_Yfisgɘϒ ILsyDDyyd s|93Xɚ9i)Sp9sey>4)Bzy~i@ٚi ҩյ9?ə蹔ٔJY)ⳜYɜi2I=CI#ɛ੠މX9I* 0u:Й!*8Jz))z? ٢1c*J 7ʞ:ڞTA 6j*IZK%z&ZQJ42ڠUVz7&[ 3 ^ʥcj2GjDz#dʦLqrmJቧ, {zZ*+9?*ڣ :,Ei!Ǩ+pjoF)zڧ.cƩ)_agZ(jh*xj.r_Q.JWJ j̊< ;*jpj*2JN(ڤ_jQ:jzt I'ԯU[N:K N!J_h.Q(@`.!"%|Z  @ @ d`pI @+= IpMYX>굙I:{RBchR2 Jk r{|+a`ڑ i 'A #)d"m;R 6A+!!!bnk_!@AѺkFk+ {Ἢkkg Ѽ7ͫN{0(K +[!{ !u(ka{kF1 -/4h[ k R.R #k!K;¿ zi l 8{<.ѳ B+>+)CBK"`#쳸B1ń -QhLkU;;x';ukAmv7NJ2 oĿQ( }8ܱz ! .@?A@b|71kFî pQ\%"pGr@# Gۼ qB 㼱+<м*ew|,7쁭 ,:j鼱ClwvPp: l|G͛|[ͤ@,,vI`p\1MLR+1[ҝl:|@ M9J}[g˄a ,}eKAƌq4ɧ h2lfʹ{]wԩw]̱v{AŲ|7q =A( 2h [ ؛@QX|Q  }K(\2›ڽ*Ÿܱ -ܲ]!mk.ArJc}Ũ=|,YMKu(;0]\Ǭ*} U⺓7 qʢkk [n}L aͿξдw]7Khvx?I«-̍vuX¾S֡*lC%[]ή Sf["@ Öf[]ތ,rLkԶ0+=lr]N4U"B۱m=.<@Ryn|ܜ- Œfڇm&|arkU>0];_۬>*&l&f\г$.Ӈқˋ}h ڽIdhxwy]A<@۬ ̭@x=ܬՌ6ͭ~@ .~,im3/a= Ow ]i[ h+~WlTٮdMύߤD u> qh\ i=9],e0- *?BƮ~LJTÁ/~-\ oʼnېߊ}Ƙ8Ǽeq nm+ <0piv޹\RQnNxmk+5_KΟ] X%R-嗦fᗍ!_RP`R%0C[#% C 6'El5<2=)mkPĘR^nٳaI[> P$gUH(UXdV`JkX~MVm[h厍KwnٺxW_&Pĉ/fcȑ%O\e̙5oܹ22l @_,(e)G$@a_ a+i ʝ`w56VJܫT^Z~ G+t[Bٽ9dТ? >H*KAt>ZnV 5`sW+7 s( "drC. 9b# p GGrH!$H#DrI%dI'rJ)J+GϺK0sL24L4 !zɞ M` `H2JQ) 󡒪+ΞB'}2pPS;#`M{&FyHAJul6EDV$C:au}<)k b5覢VKV怘ա=@cTZ5Jj +JWu]xՍ]ywy_ _ & NvaNsb+b3xcBMnچ#!-ȣAMN`'\(jbK46 zRk;72~<:b׆VN`k'ÍI薦52l. =԰a~&[Ziz26ud&(-ȽՅtGuIi)t s?s+M]uWOuco}vkm]wwwGxx7x7M$HB$'- {D( )V!f޾IWTSbNU0Vȸ(. X Jhe7-@vt ؤkK~A )\MKը)1[F)5) '5VLKe$P@0\b 85h(Ǽm6cEX>͊9 erb Q$~Ȑ(P#]wHD&Rd"6Di9Y;&! 5pODcI m%n|nđ٢X%tBH*2DC୒d\wF(giv6r&G㱘!*2gd|r If?5#*טsfl6V1*'(3(fQ"U@c% A 9QuLp6*=YzRG$a@6Z#hIEjʑ+M)JCRT)Gk қiO}SU2kzIQgVTmb|U6HK E GT FG3|J>'@嘝(ufhgkJ<#ZlaG_]!w^Y5 ܔj!Bl@4 Y/j2d22y dTضI"?vY%e:Ea È{&׸-ns\: z]fWݳ9$ Yໆ-w BCȾ^oe|7) J>T׽#yvw/ R`侐0F"`B&Á^2]| 8NΏzQN]g<9}c 8<w|d$'YəC^9R5 9S|1bbv7 t߻w^.uMmx^gϥG^|+c~F|9̃~R|Mzԧ>Lb;Yz밟ѭz^=bw׻Os|G"w9an}ǜg9}\K_gs>z{>_h@,@3@;@k@|@<2K[> ; @ , AA<;KA\Al+AAA`C[ A B,A# /0 CB4LC5A>77>8C96;[C?C@>!,S?S.m)N(}RC-TD=TEMTF]TGmTH}TITJNԚ QTPNO%P%QERT5VUSuUXUSUUMUZU[U_U`^%_- WAI}JVh}҃VkkVlVmVfV?Wq-Wr=WsMWt]WumWv}WwWxWyWzSAWVnW~W0X-X=X5X}TaEc}؆WXXUUS`X]؎؊Y5֑XMY]ِeYR[إLXYVjYٞY Z۔){W=ZMZ]ZmZ}ZZZE|%ڙZZejZڤ ۱= THM-Y=ٖۗUY}[XuX[۾[[[[\5Tֵ%E[5[Μ\̝\ܮ}\4ڪ ݫ]-]=]M]]]m]xZ5I\ڝ]ۭ]Rܽ]%ؕ[%\-^ ޽%ءH^]^]]]M_ _=(ޠ]}_׍____]uHM_) `.NUǶ^^ ^ ^E^^ ``` ^uIg5`~Xaam]at_aaal/ b.b"_fa^b6[fb(n(6$I5a+F.-FՖb&c/c3c-^2~└b99c:cc5b>>>c?d@dA._=nG;^d<&XfdFTNdh|3fLnc44eOeReSdTd5dUaKdYdZe[&}e$d^6d_ eBN aecW]H\ef^fgnfhndTVYL^!]Ondq/JOf ERfl~gQvxVyfe}inȠQXMxT !Z͂>N 66`i( VX>݄h-0NQjfGf~fgM(u$(8M)i/԰RͩZL-`Zi3M(JUjh101E66jZ KV, 2hbk0%ckSDVig (XZy$x* &(Mj-  10kڔ:`_lhR(Nf˖M!ɖm~JlYdgUP^nDn-dnAD)PZlwo. kʫ6juF-o\l-XkPFnSoFP@VklNQj P"m[ 玽_yngVq{gxq?aW&nehtDl1XlܔWiy!ڶq۔Z] J67 m۶ͩℳqM`h.ik/1n4R_HfSQ(UsMD1s$UPU͖uUU='(s?n|CWD7tD_vJRRp칅)iAO>m6T7fYJ[J-hhZX1]QjawϧI-V`:1Qfj1pVk+u)@-:$h -pз6:v 5nV1(hoRZvFjzhop)vhV}v^-؂^7j4po$`ւzmzg7P>op cV &8h`…>tȰ !^q$#bܘdȏCv4$˔-QrfL2k&Ϝ=Ws(PER@*u*ժVbͪu+׮^ +v,ٲfϢMv-۶nb]pܺtMjެLt[nM$au#% oYXAeռ1nMM$-lX5F6ZJaCkZXrL-6&b;r-BtȓgDA^cD UFaՋźb91w&4`jv{1x] ՃR8bxr!8% \+آ/3X7NŔH(A @A*b(̢` %# xInFQ&ŏDh\ DЙq)AIJ-2mvJ1؛!Њz9 s]hA ʕnJ0J-#FmhAI@H,Z|Ƥ;})1z)eq&Rmъ-hgVDQR묵b{r݂;-8ۮ{c]D6\X&qe iQ2;ph^')rbBFm)sjEHQ_-W\Rw c1ʁiɯQ`t]QxwoN|֭2aɜWv(-qxI@T[5_{6c]6gTkݶoZ]K7E& \s 5味 '-ynWЏ8c$EIr u+bgsг6]hJ%,J + @Gy'^n@diKk5?A#nR*wд$DE^T#Qoվ쯯@P\ R: J0 b0 ;X3KF1 Q<&TEy֯Yf=tQC`2.tC96*f3#戯Oqr(h'KQ,=Txa]ӊ%:hݡ2|_+f Ae$D# IC2\IWA@,*EDeQ1w ݳpYYD w,BLm8 JW `6D 8ͅx/vB+EKdϒr_Tה~R_2w (AjЂ" -Ⱥ(ЇB4! r_0ؿ EaNF|P38|i2.G,ŊZ';+Xa(CRHo4M8dKnv#4Mp4Efj bd>5a^*VjֲjUDַ5j-U!Y:%aL'U-N&W2sClaRI0"d8EɇM[AN"c-=)0-S D\d3猏!RvVKPqozB%"< . _9 - ! .F5Afuw;^5xۛ^)j+}k|A!`,dFC+Q`SZNm6#⅝t@jX8&D "5MOqSUȌ}M_*ş5r]@sm[u"H89VkZd+cZn$~/Yn,(fUB pYIP( cȄ2aUdA 6SrɄV ='#EĵYt3N!#;( 6 $Bq rbE35jӯ2ljLCGkD@QCN,<&eЃ"'i,&46n۠ 3msHb(3_㗺 @|i@ưyD2&ld.أ;&t&S(89-h u*-eHA0Ƌ! e5=Nl5!֙;tb~^ʆzXk[³<ū}jt?=P|mc=[zAWB u)-9p;鬅:@9t "Io$ ZsO[ ͥVJ;{ ? de NJ$14#<曇ދa ,~@y/׎0ҿ5/$v R?`uV%U?Cˏ~/S&:}t0l+M禎귚o> ,}[&( f[2`&@$RD`B\`jn`r۸` z~Pе ½`   ט`5]EȖWa)!bNb:RVa!aa*C `a b!EKA2:b#B#Jb$R$Zb%b%b'z' b b))b**MՁ+¢!rvb~-ba.b00"0#-a/"/*c2 c16cym_,Rc5ab66jc7r7zc85c9 "fb:j::c;;c<:r9c>^88c?? d@>A1:B23.1BC.dD2$D>d4ZD^D6dEbGrdF6d"dId}@dJJdKٜLd[]Fe^^e_#]f`Meaafb"b*fc2;`Jd_Z_bejffr&Qg&\ZfYeii%[jkjf[fj&0dhnffgo gp$oq~a>c2r:gsBsJgt.`d"ubpj'qngwrwTbx'Vfkll'mfm'{gzg{|zD\g]gz hgrRt:hBJhRhPZb(]rzhdNf}}hʧ('}¨hh[&U~hhbU2"i*i2:iBfJQ iZivxNiy'(h)ڨ陞h蘺)>)i)~)> jj.ąjvbBJjRX9"*jrzj>[p)bTjj*Ϊ*jު k++"*k&JkR+NV+bjk^f뷊k++뺪kkZЩʫ'gVRNJFB:Žll&.l6l"*,2:,Brlv~lƆzlɂɊ,ʒɲlʶʺl l>j,ΎΪ,Ͼ,m"m*2m:BmJRmZXS@bגfؖ*Urض٢S-ע֮-U-ۆڤۂEmfέڴmUmX*^+r+rꮲ+jXr.TU.2bE2#1- 0oU314-E6s5/7Wd3-p57//p1:28r=;քo+psno>>;@W@X S?(+CoAC( 48GUXt4/UHsm~E?[ELARN4AOE~WMsR7 75SSr;;r SOu[qMSr"b6/1U; Cu Gn 1cu _Vu3T['oϵDmY3&5".X-]1[// 誵).Q+ue'-)K%q^{p!{'K$r2gw-%6Wh [r."4i?r"˭ig1'grQ6kf7Km5ch~ElE#q&#cwbg;ݎe,\;?o7<*׷:o47s`Sv0.C66S.|/6e32[z /8=ms1^;Sw,8γU3^s/~p{wu [2r7.W8X7gk7wjCC4r4RtWGopG8F@t^3t?tF4G0GtqC_BK4?ő5YHyBg4MIsRC;9W˷9v[K8Sa1Zx0B5^OJra4?uB 3 O:ws[0bq_5ť?:{xrr$ogs)f1lzv?qw2p; S|;mkz&7gxCt/7_sm(1:t#;+wi;F1rp1){){i8~892W3=*30nϲs4_<5xK]{30rxSz+_üz1q緲sguou;ynS[qA>qǜA6ujTSVzkV[vlXcɖ57նMq߶ѭݎqֽ.[y;%~b~%-1ޘ=EM>U^]~emuޙ}蠅袍>餕^馝~ꨥꪭ묵ޚ뮽>^~垛ޛ ?_%-5ߜ=EM?U_]emuߝ}?_襟ꭿߞ?_域ߟ X@ T@>)XA ^9A~!IXB)T YB1 iXC9yCAXD#ITD'>QXE+^YE/~aXF3iTF7qXG;yG? YHCT"HG>$)YIK^&9IO~(IYJST*YJW,iYK[.yK_0YLcT2Lg>є4YMk^6Mo~8YNsT:YĴ$ @;ZA ) ` P` {؂Q-d`lB# G+5Q‹^cP[h)),lULO{B BjڄHoڊAU$a< dHR-$7)zڊ$aMɠIԨbU+zR#]\%e_p.?lPQhBTGvmE")Di䑵فdtdP6dTViX.b\vӖNdihlf)tix|Qc~*蠄j衈tG6裳餔Fhf馜g*ꨤBAꪬ꫰ *무j뭳!,J!,;!,&!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,}AG H*\ȰÇ#JHŋ3jȱǏ CIɓN\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJJ5dWbʵׯ`ÊKӔvS-AfK 0D%ʐ6߿ LaWa&.A4|"3/̜0+MӨ]=lڸo6 @ S >Ee뮝"gNh>Sxسkν; (/ˣ@ /}|wT3avBf/ Ĵ߂A\ &ЅfvSHa$hb(hBm/"~0hn XeHMǁu(P4x1I\\؇Xf\v QJTYdX `&|moVI )5%h&dq O)IGQ! ƅ[^jꩨZZR:}HH*$,`ʏgd&-%Nݘ܌NЙ1AEt0t/twSJbڦ覫MM0ff+oj@ 7RݧoA QfJ|{RdHo\XSQ eP`QoYJ^V0,Hnk3?1 d&9̹5kS]j|*$@5W#lǯ{5E]Vp-t T wf($ըqfBĆ$j0AWc٘{|)OIr -Y.a7Ѫn*vk $+]6(.A Aϯuf,9H˙;/h]fZ~d+ب,|Pi@HK-@K@X8p$ȘP-+b@$! B2 ul!A6Q#B2BVP+̡Qr!9{9Rp%+-?k IHΈךis3Rܑ$g5[%qDe{A&ц1n[Mkz 阠ۋ"80n$[{G pT@3G&JL*W2N3ShRrt Tr&5Qq S8)2S09L%gOXCMjќpreeo@S@hN` @t-t $xKW:dILb&*LJ. i?gP6Tm(AM`L1SC[.J-<6u@( 87IdtN3pfZ"Rj\$LӘ@p0qQ3 RŠvi` дd-YAʊ3kqWCWx1Jά5pѕƣkA<YcA 8{cRql5f`Lk[gpx YE̮Mǭ~@V;(`  ]c5 ,`6kq%>ڂ_[]-V 1Ԣ2Qj[3bMz+^Ndgb xUx3̢ařd |  'a0N`NL&鸐{lpP[*8XLOk\3?KO2 3L@T7[!;yP;Ӓ OPX^kX8fmce.{>je<F+V(w&dJJV鵭E&aRIp!k dB*!-@keke l R$do:,AP=.Ӧ6R [ؚn݂ąqd) g%vx\`3$iHHi@`=8c&7Q94Z[uꙏBP6]ŦNQXkCchb"W2D6F2hǶGZc#G)Cz:&(2 'z!lJg1 't>cAuYs&!CZ9ܷG&g1Pstv8%KrVGYPu@cT_ẔIO :Bi%A1o9*%dg+ftXyhXvj\bV#x8q0HK9o8.eHRv+j'>棬ҬPs:ˊs&dJ઺0gh qKZ sZ"<+'KE f&LuQ*`2oӉceFfAb?è<4ҁ(9%;QAm/BK\yR0kGÃ$ #*1#Y:;Z.@|9<1l{W[G!:-`U{׬EXypQ+㲲IS⩲aBt87%q гùǁG 0ԹDjQSKX*Wѻ e۞IveY=gpqB&/jBZ;ˠxRJJ aaZpz2/.Z[y-3ԧl1SB2i lyo(Ϥf^5=+IӥY#o/b(jhl#ؓk"rz? A4XѶ]c|mEpz۶50"ʊ؈…-+\zAœhfq2AYʽ1İDq39w{q{UlZba]!+ƆŴb%Erum)epڟsA܊&@mY@Nd :ιpU,TBȔSDJP!I_\ү)Tigt\E9y!Nذk.[fvQ 0r[5Ɛ8k!WYײȜvõW"&.8xݳͯuJ{eϱxۀ).7T&m*6Q3AG5a! s d/@R@[0J=j唩ɈXid[Q+P~ #TЂq/=Ák8jE: ݎ VB^f "~vrL 5飇^!KK#fj64L{nЮ.LXqH |4`xgTj#LbJh#n6vLk-4z\Wb~L$%!ɦ3Jvvc+vȎct8b7 &ZF70ʌކTs gcp?26h]}"\}:kcNuNp}'<Т9\8!@ @H@L9a& (1DLPȈ%I!"!t1%3\: C&Yh1'N=sƁE(3C&h*@ {)AAfP͞EVZmݾW\uśW^}X`… F8.eL0q bsU%> f&4E3TFВ_HT1nUC]j/' yʍ`7dIxc.YAm<1AegԎ\fݍCf`Ռ Mt84֨</ӮKHL:^K7K02t <3:o CbP;`RH#D2I%dI'2J)Ұx FrZip-3J:S (!( 6 )3l L=Ǭsˉ$J .$%QC[BTρ#ҜMG i($JH QZIR" S)M7#8 iR4@PiQ Z>;B-J-$IbWR 5M6( v.((J}_8`&$? .Jࢌ/+Mn{#뢃)NM9*Hx); afd'nj 8[XQu4}nKʀh3{1Cv3 .\2n.Դ4)!7kue.8XD&p cB62^.ѩ^ `)l)ˊgL`M! qͦAC,_#^ W-xsWgu_=vgH*ݡ?^ / çwޥrew2c>z`J?|3)׻3OFE>&Df<tNw~DILBHAH|nz#f*CjBЅ/a e8C-AS~;!r^,: ID&-Hb[BpqQb\$ņ[DUJT[t%%xcFfQf *DC>яd 9%-/EdH%_<#h,H/u5+!&BBҔDe*UJVҕ:K^^igjM.$2e09LbӘD)0Ixɯ; XIf6Mnvӛ`c 1m;hi2)g<9Ozӏ4+_U3)g=:PԠEh`@ۑ2 /#$ԢhF5mУT̄ImdEiJURv<6+P&I1Hš.jP:T.7S /Q:UVժMCJ}jX:VլgEkZպVխok\:Wծwk^Wկl`;XְElbX6ֱld%;YVֲlf5YvֳmhE;ZҖִEmjUZֵֶmle;[ֶmnu[ַnp;\׸Enr\6׹υnt;]V׺nv]v׻ox;^׼Eozջ^׽o|;_׾o~_׿p<`Fp`7p% i[sDE|-*WysW[膔IE3쾺"{`..H!+RJv 8[ %&b>Ig:E|]aY="~Vc,J& ꗁ{`߂)=zd0;.5 ůE4 E~ BchϾ I Cк^V-_yCtE2f@Z>M=>B|~kVKmooQ?9Qj@H ӠcQ4=L =?Yқ4%sLy * əþr M 6 yA%y W\d/%ڝ;ȉLiCvcپSsғDD hQ[ÁEPCT8y> :{ =6H/5nJBøI\7=`*w:TrrO[M(^٘ 9TO͎PMr˺@^b̥5XK-J[FlLsͤG TC^LL׫, Kġeù]:Ne<8 ]I5ZEu-9>>;9>bJD7ll:GsOrJٽ!ͺ٬ΫLNtϺ5PleD6cr Y.]6^6e\-nDa$O]~!O$e5K$df3U{ lfH+\E=ErOlB 5cU;KC1 1MhfwSE19m;ĵdӼ ]w(5[h^s^n8yQ2ī#^5t&7SlYuɃݞ T%ĸRQVIT]YRjrTMۉiUL]ƧS6ETeH~HHQ͋/JefZ5UZZ`E˅--؂k>̓ſa_$j0%⡆ T,lJsZ`;AL959CvE) =9 Ѭ:4O+4LxL99HCu$MKDdCXBoala<@W9YRO&ʗaY.ڪ WAOZX"oTsϞoHBZ\`\!ifh '9gCtPI\n U%QT`[?j^ 5ٴ]‰-lW}FEj)^rSmt 5$Y h^ҭŰF:Ȥu)FAV$ 8_vS\F_6GI>4` 3K=g6!a`)Ȑ]aC*ɥ }O}jgnH[ȏH煉ơsDb9$Ve!obԖ%ޚD8ǃ9> 4.lb0qPZwJ1ᓻ r,XF%wvlZMO qnb>MY"dFk-\;MKno4ٜ ̸9~LMVp$j@EeV^Nyec6ו@fYFTNxᴐьgy*/hoO`&dfҽP]J5dcS<@H\D*7t߆5rGސ[ˎUߜ.y7͉5tR -U;OcFSOK7تlX[cLRφSP[˓8h*G?G"rO6{7%wL}lj.GC;zIVhkL}ձ&WzUSk#UJN~'EwM-pݐȻ,/\O{*9Г{EXEy7V'Yq4I u$e-'v $@@0dH_+  2`lI @Ð LLbVdƓ`$C3A+ h”r!C-4jSA &AJ2B&L)##@ L[H&Ւĩ K 7DLpUn9(,;>=p$͑!6(ZdiҌ;9(jנ&)cAq7ԍy5o,nkHsɡuu[s;Ǔ/o<|smo_>_XID@؄-B/Q@T@\KZ@ xG(R"dσ#D?(T-.Dx;! U,LuchcFUQbWJN-5bM 0J%Q8鵕a5atSEIaWAHD 'RV+aJH[΋UBf>iLaYJS4J$$iw>X\ Nhgc>5; L87l4KRJ%B[*lB[b;mbr{Φ~.;|6-kow UԎE+2)P@JKsєJh) I\nmepXX1X{LwRpN],mkN=M K%B+E n呞}q\A7-9wLL)1CtN*|W- MO=(a`I@RLsJQEB-LAC0ztͶ lϞx%AEYWI|m]qcܨSӖʩ:7u<Q;kJ:Ad|{Ϻ7^{XOw!0k=:|w^O߮MiʁIfRtIDfIP?_V_)oK]L%E&(O?aB#@ \o<PQyS0X nB'0"?A%\BHI8! 1Vyҟ  Dn-y%/adA* 9uN`gk\0LB )z٫2Y,F恆$(u,$=c<%4)HJ9&WJV򕶔s\\զ>q>Yc+ (BЅGZ]' 1Nm%;vǡC/*ґ&MeDGN dVK26)NY<9y~R@KҘӡdd)sDzD}*T*[{VKYn5xS*X_ʧ AXXJ/ *-jEuʄRv+^z͙β +`eX 6قB5,d#B )AyriA2]+׾e=.r2EnWK5}Jsr.x%r %o_[؆}/|+8hcMӦ,},>0Zn؛)Li` 03 sXŪU&V 0S6EzӋ[Ÿ0s>fr_f} M%3N~Kr_R? -s^2rMᮎo55n+>rsz߬=~ߑ_!zܹgkhώ~4#-2–0+GJ*4%/QZ˓>5S.b>3[7Bն>W](3,l;e3oBSm/ڿ6-bY>Ӎ)k~7Ӫձ7om};(q7./>xnp#{%8#.Sڄv/mG{xo(.򑓼!LQ>aQZ ~9c򔷼&9s.q~{q~o/]sv<׵{]e<3y|堦9=r^5=ճRͻawϾ=sOR?=z֓xCʏlSR;מk>ٓ叽a-"|˿[r-01W_=QI9ih}_52NZEgtLTJt`[D!ZIU`<\YܔJTi-u^ 魜GDcqNm߰/(C(͔i$/͓ZD9|uXz"==y Z,b#[F .&c322NYAF쉖 !vkѢT4MhЮ̡Do`̈́πg(IV:G׷ )KJQ e  D.dB6\I9 ,$(E16d$KHg(fp8Q*v KCd^fNdŏteL` IIٖEJt\ f& xl&!)x$"X$(!g_:%FbuNufr\LZͤ&(Qp Gl4ꍓ uJ"w`eMyLC S2 rFdAE]FEDjVqhhf\跞@=-e*ҭm‡}=3hf5ƈT/(EXF)Ј7nBQe#ICVTcpp-!ę.jq'ʩJٺp K>oHrZ$IRdR9R$däELTcZ4ExohIA-C %g*fO0R>&%RtAGebt6k9+ebǸj%Dj%80oNZ+ !&ZФʼn$Vd5zQkCffB"H,O Hbfȸ, 2϶+lφҬIL*Ħ2'ABR&v4%-nѯ԰a!'v"!ugYp KoxN~҄5UXF҈2>QQ:Ɍ-pLBjZ@EH*'S8'@1M(n(3;-"n/ƈLƏ(sk/E3* !j2ި;(;}\r&gTL> e igf6ة. 4 jBY ̂(iL*N35iGTtDB1L^گs: Qkn*R{j"%V\%'rTڢEnUZ+CSXžP OSn (_̹GN쭲M_{p̊-YuW,_0V+ǰWpPf6go6aȡePPQZfMl&Xf$vJXf $6gP ݌QN nCq1fsuqvvf;7^eY"/"|ןKEl4D_y@6ED@r<}-֢[m7[ƭss6% .2.z1c8Pb_1Gps2S(61wa33apXk|Q;^|0\O[ {9J?Ŭ );oUw$L|;2׫ :})(A(@J-7I9Hca6/bl;h;+BzxE<|{W9sV;:跑J5zīwMl+{mw=wԝ9~|l@Ο|v-us[q@Zkէ=POmGApeENhrk$Q]PsM;T k[v0o.)/>\B&˄җyg#,ս+E t|M~wk>xa\Pˆp A ؒhbEēMzn$a eES-v-N8sob7"֮J(oScV0aS3q8ă39h旾o;:V:@80@ D!!F8bE1fԸcGAzH{Pg͛G!=)>,N* $)]ΝV7=U{^t(5(YdR,+TZfעuFjUQ\rw$Ⱦ{jWqax`SsgϟA=tiӧQݘu֯]dž:l۳oVwoߦJhU؈$b pV+2[H!c_Z 2֟a<Rwa/%{+{d g}Z!kO Lp"%bp*PBJp 8*" 3/ -A{DA*ñ\Đ'p@) aèbkG +#LR%l!}R (̒FJ- !,L$N )f"eF61):3_O(HбUϻ v$ $ϟ┪7'"QI:?<RHՉ^= ѱ4eԽB"TWrmUZJĞu2]cAnSV{`Ao Wq3B5ZL5u=Wv}W^wϭ ]{獗^}|%W`q;M}h!قPaNb? H@!lP?RHHH&a@e Ԗo+n6(:eח 9d$4!=BbjiLn蠠>hWN[nV:;n[na j.!@^P6"4M$ ljT$TЊ$ϽV5QkLKm6/h#1*^[}xgX? (:bwy_s|UBXķy_~n}?|WOp|+ ?&E jp ~Sla?%3PFDm婞xvd&COET!DPG)zփ0te87ћltB9< <+23T#[:N %iDJMgaьtG3qLdtyKjg;U֭o W%q\͵@>25鑩6QBQ?+HB`XxSvӡ0HV}}#)Q3(Fѧ~I+RA["uA "y8a47h5Q<A8-c$N1pO5AA pA&>Ex0{=K 3y(GM%a s$H?$IKeS<H Ϛޘ+XN_ܞSbV.7cZsHUK&0ycIOҸщwfӛt;;Jv }cVlBQJGM!%yK>7$M)~"\,'դhΆue尉 yV֘Te>˘S) +YtgdZAo4g\64ys>(Gp;qjUWNVOxZ)q_\ :hCwm$E;(:iw|KRwl,ɉ]žrEφX4oE`+۽ ɀEF[;&޻HGˮ耳M_e嘾]}' w[<hqLjWcƒ*#Lh|M'eĒ>'&(!R??$,Gd?j8*aؔtD>x?yC,CV "$=~ʼ>eJ$2BxOþ`f?]+oďv"^DdKNըE!LƐ?A)55*R*J#m($a $d@H.]gr# 2>}.&2i^kUB+c~:Zƒb=V!,C,1$*.G%մ+(Ƞ!Jgvs#v~'AhWӺ.]ZIWe B]ɥ_^{\_ь % fx5TvIx)11)V+A$D<"$>YSm$5*|r:)J̆nD$)S'MH<#'=,@$$ՌqaB'E,t Y!nZy®F|F3fQd0iA(Uݶgjܭݎ#:lM-ܴmJʨ b|P+jaHSs@J98p1o*ȣsz5ޣ*6.m""$[- &(,xBT+8q!Za'\b+V:MruRK$?d&0gs2d-R)֤%+b >g8CRv<[D7؇ywbt%|W+DN.KzBK|EDRGw{0|wQ#65rwaӃaLaUC>c\\8$\J l OZe?h) FCS|r܃ȺВoE$(1NlO/DcG 5Jy^P82848u81RIQ=uSx8l` ,#/*Gb.rsF!rs '_ɓʄWS[k;f.bd7W ̀B(& b2+*"n6U-'rj: 2 1l3h.fNZ9N!s`E[zmTkrӠ:jH2kىymp68p֧8x)2(8n;*W?bvHɾXO$ /ijܕruVQ6'dxv]_w+ӄ"j#sEɴ<#{g+otuD5|_β|WWIUZ=NDCQ۵a{e{)!lABf%J@=h$Lm:&xtrv ,)kc?T&rɝ `6L88ӡ&x F],I?>$)^,Ne [Jy1h82WL.lώP \BZdrAL$E؊ﻴ-~S/;SaQNv4#&:{}-;>S}O7%c[)ycCrm7Qw" i!?a!F9@ ēW@QxDzB|(lva~|> ۣހ>~#v-'qRa"_t2řTj%BT8D'B$'҃FAYz5*ѭ }X⺻Yv ^w2M;gΈ7N+O5g(OW5)ACqa"sݚuڠKVy&.wc`ӉN^=s W/"vbΓ|RseX+ĕMvJ^W>?2e B "MEhk 6@j$9^@ʋ*Kx2̘_r&Las;~ 9r̅k؊Ih^+) b(V;{I:M ljztɒ/ivmYmeԶ*(jI>#%nMI]0/cJ9yy RZn4䝉HjyG%_+͉FI}t) So'ePniV}[I8nnAшͤ"BQI4$dBIdF閅҈aMVHG1IU.ieXn]r)a!J"fjQ:%RyHU-eSdmO!hn{W Ytrv([&(XQA>(XYIjjfeS7S_Makʤqk+Vih{KoދoP o` #,0 7pOp_,cL~ r"LZr*rF)VX0ϜX͋,9L3} tBM4ctJ/tY1ecJ VWTk}5Y?5]atjJ%KirMwݥ7yw|w.xnxx?~S y_Skƞw:oN磛^zO.o殿{qN{κλν |/^?O6f=}bZofW/g/eO~柏~K,~?ۏk@y%},tCAJpu|-0jp? /x",!FB& \VAQe hEfR$6a pD,yo{K(BqJIUC$HLC} XM%yM2 I:~f,)SVAIR;)Q*/WeN)M(d!貌oIH3 $mB0_u$.laeVRpp$ $A$AX}%5/'.Ro}%1)LHr2^'!#hE!W(謨E/$RC,G1d#UqbII*ғC,2|fT"iME, [bCȑƌ?Q/@ 4ڢڒHFC4'f$$`rI !]dDi19EXmƐ0գՔOGѼuuGM֯` K&ְc_e,b4EK.jȧf)A)fU"h&y ? e4٦:dGDGē xnJIG%2Nr $Hw$_ Runj;TKXwM/{9v|')|$$`[HK)fu[|dP1y% hhN\USA|J "x=^$ m݃v $bB$ڃHxd#U%Cyciʅ L'>V/`urB@I@q|庱Gy1>Ev*/\Ѝ(#^Э5|&yӜIS)IuODRhlC"!QkLgf`YxT`5^:i+ D2r|ud$+ X3IvҾ]?qwz.!ߝx{7kEJ> KA0Ҷ0ORRAfIlTsN^/ O=1cOmkADWdDc^麟oo@ܖ/=K#}>#ӣR3~=v;yʈ=f0@>UT ,5XX xX؀HC4f1IUw1]Tg^pmffRoAju( ZYf*G7!a&18]hq"KX7?t@Q(tP8Cz'Lo2VZɴLvcPaѤv1Amqa.fVF5Q$xskcRW'n$ [KL4GexJN0MUQ2kE;Oumv]TZSE.@w_5;%؇qHsgatVl`6i8s8aSϸ2֧}iԨjwgSEV[rAb1Nv"E ;^&T&F gy!_ x!MD!'wfg\a''&1Lp%dthn nG   !soF#i%濾Ay!`w)=rY`z`7JIvDC!h%Aixudd$]@rSwkgHN!ki/U(TO(o@WbxLFaLF`PwɗxE`x'AIKxiV؅I)/مxLTswy-ԅ=_bɖw3 )i2È,)4ћt2bA%.di9(ҹ}8IXX I>ɀ쩞X)ipae|ɟ&qI j:>Yj CrHJ<O2#c%:!ʡ'%,3 4*57@$D:&F*KMEGڢP:Rڣhӟi٩إ\:}J>t^di:'ɦ)m )>QRpyz~ʧڧ* Jjʨڨ*!t:* Jj R@jQKeʫ *JjɊ˪ͺUa):ԊZתʭG*ߺ:[DRz ~jjz*گ+ʯ!a `* ;Yډ_Jkb$E$z蹫2ˬJΊ5;=7۳-EGKHFMJ QP;N+iUY[Wh LZ;T۵g˵.{#UȮwQQ0wwYXY Qp K{|˸븒۸ {bu{ o+:jK{ +Kb@;_!뻽u;K 90rÛbq>k<+ r9 :⋻䋾竾˾{h۾RA@'Qîcppq t zbj1(@ۧRbkqjJ, #!k-|+0%3l[.|4V;>?>qڦRZʛh«1ǚZK[ƾʽ2= Joq s,uLwlyo{ǁoS T|skaqQ[j)PKKɌK ʝ긎ɝl&l[ȳL˳l˫{˲˻dZ˼˺\躾R|D+Xż+Y1[b;K+bRb`k<뼿˻\s˻)лX ,XiཽZο[[ }]BČj9NZ a 0KKp,}%= ir`ic1 !%Gm,sX8BP{+12!P*GJZFGtMu=y-{-|]{=ׅ w׳@NTwjX֜YܫjY jϿZL! h`ٽ٫,p P)пiOũūk@Lq`WKLV^[9B~aʼͽ}Y,/.2@[gNKk|y4 /[M\ٸ00蟭 X` .N]^znꭞ3裥(p!"3ͧ1 ,lcR0& (P*'^n៬Gx  S@ˎHn؞,Ä}v׆_# ~Ą4 AqhܽŠ製Ϙ۫j/?Qܔ>>Zێkpj2o`Ƃ O/QOSoUW[oHߜL.n$-ž>,CӉ)%̼C~qr>n&k_x ,> i +"X?]No련/?U>+\ֺg+2<֌جlqoقNj 0;t4 oq" ۽̽4?үYNo8 A &<ؐÅ LXE5nG!-dA'LB JRIfA)QHE Z4HѢS rҀ2(5iR)š4( -ڕ(Obը2`ViIFY']TzYT8bBV٭d 4K)[PiTv$ӳ̘7K&R4hӥifjثcZmڸmu M7ȋ+?~ڹkљK|ɳ/.wŏ'_xбޜ{Oy7HP$ kH@ $H @Ѐ.+A 90hAb@c 1CK0јDPCC Ӑ :.sPgl'/$1B$RhE'|  @$KT3L3,5d9N<=O?=sP7<%>5DeTQDRtSN;0}RIGH!TUEMT1Au^Vpյ]oW_a}(~"c钋)ɤ0d#@8b 0loApoJAd ٭@( 14Ȣkpmq)"+rR]%BΊ{lX^c cK&MNWveUekmwgug&uiv~'_|W|w?}~im~OZ} Xd @ p4hk(R S"-li[9@cAAp0hA ARd\#CŐ+z١A8.  UX8p.U lL G*&";(F1h0QkG:юzR  IF>ҍ}d"HRS%dIL^R<İOaElP0nD Lꤐ1@d5 ¥&F )(utWf!C T$))S*4+6@9bFA!-!M3Tg;Nwg=yO{g?OhA19I&TeAP>Ԡ Gv5%P[Kf^xJQ6k*Krr Pr)GRt)vSb"EQ%J=*ST65OjUzUfu'hWjժZXT|U GGsR5tk_׹s^W+:ݺ?ﱒl[+;YRV,f={YnZM{Z󌶳]-i]D_ n{Q6 o[\6Uns-m#;?b׺nw]%x+VzRKUJ,2_W& \`DYo~_*XEn|a gUް[aXpIyo]xVqOL{8~z$jUtAuW]EҰ]ލnzju؎{9o?{w{o>o O@{|O|xO\[[Ev؂6ބ9&{|~y-*K>(BEO^@93fV~;q`/#'Ƈ||'>(o*R#?6QTcj?/dЙwF [6#?뒑xnFy{k$[@c@Zq@ <+ ,I+9>ق S)؂| d: P A-)B":&\B'A@)'TB(B), .;)H\q$ۻ87)c# X 75< 5|VȘH öCF;;;0āC7J[<;DBOJt@좳6 v  *hI[P-CrViL"ɠ܂4Ȕ=bl@|@d,M< L)؄$P\?)ɚI3@C'Tʁп_@ ğ$[NeD\_VM؜4A=AQΌ1=-ͱ)̘1T?RKR0|FԘʠ\ILⱇtL3L Ps%{E,l+d¤cMQm4J/»0ĽIl$;\ɖC*V`,E#R (mH .Mlቜ|ôÖ؂<'H HK<$%K7KI.2չTFTK?4QKD`%_=(QOVc%VQlV_ d  e>89SAh$BUP#NAT{LI-K5oE‚ND; G[H?)1$p|LPWL#tOӕ)0aؕT$΄n;M?o< N>l>[mZsڈ-LڣeڧڢEZ<?U\ RQ$bYcJ?|*ԢDԢ/mAI.ꘌC @I[}]kK9@$ N.EI|0I`OEr"UI}\EB[bI+5KA=ݕD\\\1qM^,/Z)$^=UE N WMeVAyr5HZeJo}5[LiK=IЄH0%*IBt=܄-``l $\L9]{x%' IsT[WIK*55BQ~vQ!޺!=bE"C꾡4aC͜; $UE@CJ -܄I[0K*=z]9a{Ll0aYo& _H@g}vbM2h }SoHH^i8an问i}ilMI3:U`ʥtIH] œ԰ܙ>iG~PbADDc]W\ݴeF[5S/86_ XΔ.]`KC>Iܼ|^Y5ՅI8Dll}^llޙl,5m0؇,-@ خaW SM`sXIO=f=߅#Q?o5>Nagu}J9팥UPfٳTcNQ o%$o$mop$/m`-fNvE @=:U=`A˕#M&>5`c@K]:  -(eHqϘj +tLH|㿾C`Lӭ?d>L%gkEER#[yW L_\I˄gqbi\!O[S)NOd[K-jb]SΗx3K ̕`N쏷tˎy4ld>F[Q? xKbۭg[~{͎~Hy/9Ц{%wjd @k|`ِ>Uќl_Pfp@O_o%=YKϷXx0$P۔|4 P_EN/֞# r߅Y˗'KN|AONŽLΗnMdN=7̳)IPNe~sf p'sw4 d\H"E)"R0 J 80%d)J^hPE nyі=2LP@&S"\a+{2<韽GO^ i$H)J2uҁITm&"+ jv(͓{F5PgDqe HVK͜jΗ='ެҤO3Ш[zٲamn!.|8Ə#O|9ΟCڸo[Ϯvޯ֞{<O~='/?>34_ F_~h !G"X `JAa] `?>-(I&"|!c!I:^)XdG$K*$O:%SJY%WZ){[r٥_^X9eyH٦mzEs~DgCvYw'(I( Z.T^D?$ N1ƨ: _JEJ:ꪮ뮷+",2+BKlNKhEbq-2藷~ d+n[.ۮFD?tJ!AJ!A\ KU3ܰ #F/[qS[k1\r'|+ܲ(),33\3-ܑܳy4E#-tRBJO;5SK]5W;+Vs]WM Pc}ڰ:6Olvs]7wc]6c%}}G߃]xC#nኃgx䓫Ǹ_8syo9wc>>n߮~9n:^RS;[$ #3Cl_=CO;붿.{{~Ϟ⃏~ᓞ4/G@oKPYҒ`*H F0Ԡ9 !KHz0#T YȪ0j kHІ!QgA(#qFL"D%BQd"(+VqV"E0bdE3r1_l:i1W8ڑz =s ?"2\!H?*cÙ%ta ^ܬ sue*Ta Su`e0iG; mBl؂WI$/5v?D?miH_:;z&ULҡtI8,+h .0 * Gi[ 3TB'aKzk|S,uOta uVXzƓ_Ca˦mvH`@K)e.ަP,+m37~{Lmp| O8&hAvsm(z k?Ĺh9giCpN4"l^,臍HO0AeLj\FDvirN,hP Sxz!ڨ} U ` `m` z`w`&[m ЯAuGP\)R_ xH^*|°J`؂U@Adn)֥lDPeMn!"oo1b""":$Bn=%R"&F"fb(n%")k"t"{Kٕt_לQAEx'ޗ>(N ^' s!@e5؄e[hH<$A'QI]ZG_SVpZW\}BvPE+@XPōFYIl?HBmO`\Jffjk+Fikg+*G#V"b=۲[۔H\!U5/ro¯hop'}ooo0o)an3nϮ;0C?Pc0{o + p p  0 ~pqo"q p$7>q"- opx1poqH1qw1q˱1DZ1ױ1 2r2!om">1 !/$3$G%/.'$t2'{2(s)r(2))r'*r*+2-(+r+2/-r-2,0021s132;2SsK5_#U5O5s3/G93::s<3=37:#$HBqsr9G^Gr+w?sql(u"@vBӌiEhSllNvi@+{kLy{# Gqj|rdCd5GH8DxD'5;DAJ'x'G8LxӴkxr$&4o8k8\GdGAC8gPc儶SOTM L39w79\q#^d'^@ld6HBnKUoK47+w'%}wnC꛾f'^{^~n +찾/(F $qpZ,,Rg)k5'sG^"Q˴L̈́b"AWFY+'ڝ:}Ԓ|v i}>|)OuaJ|nK sԽn;,^!=s) cG1aN3@m6} >,ű|3rXe1r:Z0px)M5s>p$91 " !QI,D$.oqX9P.b[E1FѥL鲭Ȱ8G?ʱIZBAqHBsJT!JBfB@g=MHY#V2PIU$7APEHL,v!@2B:' 0Ji!PD:)!b N3G~KJ"OR DL-UEK={2lJҸG@ bqHQ_'emԣؘML4z(Ȅ'Zv1jbv@;6R9%I16v4)LASԦ9NqӛƍAP/ӞFMOT>N*T:UYt4üiG\Z MD"RE o GN^i ;,T9아Y O|r;hI,9VFWyRJn [l򐰔. [ю*{I]Zc*r-)sϰ5QW#p? HAMMK  HFtI^@¹!tyދQJzFL!GaM~1?Q^R! {qI6hY qIshN:9~RFu J A1yAvꓳAE),oPf)$TqYB` DH/iξ)ōN*N|ClJ͖${}]l±4_doe|3y.s]y.-B<)O{kX+U{9{(Fs)~q1}!.Jw)} 8 R0avr nKw6 k3e_B l껭~i,iohty_!_?Wc"?U$vJ?]4Pj)JSȖC}M-VrZiM֦؁LW$ PKM,\ \5L*$ZΤ,TO B`b.H:jBL {բ܃f Fclx *2b/jʎF%[plccN6,r O -  P p B*GD+zR)FkpJ%`G 簼",lĮz,B$$+' 6"w+#y4b5Q,Q)tZ (`Pka@l$G0F#?P1N|hE qĢ3#ʫ/.fk]E6/:Y$_&:Cd2D3 ÿCc3(SlQ!!!w'R2!"#"72#92-#12$%"'RmNjO/Pl!ČiPoB(i[, QL,&%PUp ZF(?M6+%"`B|;L" Wr :b;"w$"<ynyn#}% u" EWj,8K{s!Shb+Fd.C䑅Vo8*^\na>I#&&ѶLe Eo"/'l~Za8^To,n.c7C43nn$E!.ZFⶐkn 7N8NTN/NN94NOTOt} ZV. h2)%)H*!aaL 2-QZ!L2):KTc a<$T-S'BIR-dXq%w5)hiX%*A̮h>Ie+2PÜZ0t():3Z!դײU0-*Ff>2J54\[㣬*]TE^153)ƸO[PcCcPKvk@vdUeQveEeSedw$A # -T"',#ᱪ $b3|"`A-AR,b .X$@ȁ( J–QM@n v*vv>"$!n")?h #AToJc VOBkˀ`:&.[pä<_GB_sCn0C9wo51BMT4F#qDLB h2\C*0~q$=f5rwymvfHĜzyf{Er{}z .H@e~iKY.TW,BWBWo~9~~!Q7/@/H̀XB7wHZPBRM3 R}`4.5 }-o/X3SmBut#DNBR`r`0x8w؉|8DX8ḸX8x]/,Pah_oh'tu4@sTKㆧӎ@O͇$3+.n{Q9/yC !.!Dhѓ&1 =q5ERgoZqoOsYCPYyu\6f=)WY}).LZiYřY,Ǚՙ9ٝ9Ge빚aIY:_VB  ڡ#f:{-z KGAep4A@ 27ZAz?ڥ{=gZkoZiCW{/:&&q2{ک{:ڨګj8bKX7S:m-cOj-Z:m:۰Zn#{%ۑ)ێ_ dBβ%YӳAYKOG;M{_9iO*x 0؎d[_{۷gm;($egxyFۓZ~,I~;[ۻ;[ۼ[۾۽{c\ٹX<'|#s(z;?C\GKOE\Żz_Wca|o\s3|i7ǿǁȅwyO8xɷ܋Xʡɕʙʝ<ʩ|˱̵|ʹü\|ʑdלϱ\F{ }=]w;)]'N/m$)\KGMO]S_}cKg[]YC]ǃȃƇȅ؋Ǖى؍هڧ}ٟ{}٩}۫]ڽ=۹&]ӝ]ufܡo]޵>;}~ ^>i>+}fes?^C=ASW^d7F^aO]Hsw^y}>^>>ڑہ~mȗܓ>>Fk<\˞>>~~sL3 D2-^/_-+?9;_4_DhcgSW_[]_]_k?mp?d^>_{3v_Û?W7߿_ß_˿f?C@?=_1 H p`ALx!Ç #*PbE1Zx#ǏCjQdI QLyR 0cʜI͛8sɳO+ڒQE%4)SPJMիX^-W]~زhϪl۴o׺ ܺxn߼ ˆ+pď; ʘ/kr̟7{ӨS^=tkѮcÞZvs޽ xqÃ'\W:rԯkΝ9vww͋_=Og|?|W ʗ} g`BxVweU]!2h!$'F8%"0ƨZW4zUX7civ(dDX$F&8٤O2#SJɣ?Fe[^e\饘`ifhIjbo'NY'kyyɧ{)h%㡈jg袎F褐Rjfz)vMS5%jTN:jjjꪯ*k:kjk뮿*l}j; ,l2+V)mݦm-ކ ۊ[.ߞn. lYkʛ/ocvN."b'-Bp?Lf3qo\!{,r <rt|m&ל8ۼr7Lo2}ڕDWYF'J7NG RWMVgZw^ bMfjBǽnMvZwU촃 ^7G8Kjw^痃Ny)mnzNp뭯znמ;{[.|>|L=7$Lg=oڃ}w/ҧ}˷O>wjߟ8(LDX@.0|HG1Ђ A NЃ ?85q`IB/l! g(%t#vC@uD"чFL"w&`EHE9qs;^.f #8F1zьd<#ix2QpLkF/~w<~cHH<rD!>6fut#IIFVr$&]ɠz %(G)RL%*WV򕮌:>Y.F^ 0IbsyRTb9f:Ќ4IjZ߸8ɦ8IrLg-/? rL&yu̧>O{na@ρ!І:D'&Ĩ35jz HG Qpz&IҖ0ISyd'N'Ӟ@ PuˢFM*Ԧ:PuiFQ2VծzU*MXQJ.~hMZXt;#jӜΓxͫ^C)M( ;X1Q_:)ĪU9J ͬf7հճ' aҚ[*WRKDn^ pKLVM.PQ:Ѝ>;VV_v ][ [ʶte-YLKڷE@ X1\"Rօ0L [n}y7^ע#GLoַM1f{- ,ch81W%|8]:SHVo&;oeZYzet"_s2q׌Ƞpsϫdө+LBZb<ѐ4ofJ.δ7m#ߙv8MRZ&U]e,d9gi'ы6 wkBҕu{MbӢ5?=2RMmA8smnZM`OXN3,&^Mz_x@raW/zop [fM'N[WW{y>W20.g8Bg9ɯg_},:G]Y;N·4Fzl;ޣrWO?4z7y{}:OҗskOL[ |EwķE1Şw'O~'3KʏO(Ͼ>Q6V{7yWkV| 0 }XmQp>{ u/ ! bQcwI_hA">X}#x(<}_.s~x6x1C_,[8؃>HQ/FxGHL,HMR(#GZ6X1o^0`8d!2WjQ UȆr8Wr0atz|؇~8Xx؈8Xx؉8Xx؊8Xx؋8XxȘʸ،8Xxؘڸ؍8Xx蘎긎؎8Xx؏9Yy ِ9Yyّ "9$Y&y(*,ْ.0294Y6y8ɐ; =?>IAYDy3aIƔ@B:DZFzHJLڤNPR:TZVzXZ\ڥ^`b:dZfzhjlڦnpr:tZvzxz|ڧ ̾ ,l⊮kW!\̅0t8; A<;jɈ ܰAɱ- }U 4#\m =E :J,| A{gj Oo9M}yHC TԭL:N[nʊMϮ*ٮɗ˹vrð^~Ȟʾ>^~؞ھ>^~>xG|4zn|.z8Ðׁ j o?W 99"_yԙ$TI 92_I7o>/??DFCqϛNߛOP?R_Tb!&[\`bQ~:1jln䩓A?Evwou|HBo/_)<d??wBpoO_{JoIMVƟȿ/O֟O/?COD(`ƒ &$ؐÈ:hbƊ/NƐMDʔ Yt$̙2;ҼisdΚ=yTЕ:)hң8"%H@TU^ŚUV]~VXe͞EVZmݾ ҵ^w`F|Xq`Ɔ'~dȔ%W|YsdΖ;ghФEF}Zuh֦[~lشe}[wh \pōG\ |~3ա_vٽox幟zݯ|ߏ~?@ :dA0B '*B2=C E ?4QAEC|Egcm3P@PB54QDUQFu4RH'RJ/4SL7մSN?4TPGTR3UUWeUW_k7buV[s[eյW^]%vXc}-cUYfMV6XZk7@v[po \ueum]x畷wl_uMlN FL>l\Xb/8c#xb6159ddQe9fg82ju~)睡9g.裃fzi}n꧍jVꬱZ뮹NFffmvh疻ng{o[o ?ܰfqo_z<_'\r+Ǽ3sC'tG?\Wgu+6i=em=q=xםߋGxc7_~ _zGTS׾{>||7?}W}E>~??7`?_@6q`P7N5hAf=AЄ''y,^ a8C- oҰ; q(D1.BaU iOؠ85-U׬HE'^\ܢ(E/`#˘/qoD#D:юJh@!~\  H>3D! D2r#%ђdpBAnre'EIR~r,e(SyJdҕYB 4-sKZr/] 1ҙτU7QӚ5Mnnӛ7hӜ# D3_;9Outg>sӟLT%*PҠD@Є ?%:Q*sE5 ̍1G;KfIҁQԥ/^8֔7iNhSt8iOTԨC=*T6UB L:U_zOZUWjXOg⨺V̡ [ Q>Ԯq+^:׽XZҔ#]a{hcYR`5jӳgEZҎִEiUMζֵ2XVUhmnc[ַk;\9U5nrܾ6˅sV׺,fXn׻.xwoz͛׽ISTƗu} B/+`8>p^7X.my+aO0p)aY;>W%&UlbŔ qeʮ轱du\cxUy ӛql69OvrD}d#A>tmEЇs_765iN#xӞEiPzԔF5IIںgVs~&UP,7W><2yon67~|3Y:_US՟|wt?PozЏ^7O~җ/8_Qq\l>|T̛t@>@> @{ s@d?4S=dA==ۃ|AABh!\@#d@$TB\##( ,-A/|\?1,A4C3̓&LB&D'$@,D@-?T:B-lD.GHYK6,C6TC2LCN5EOOLDE VAW\EXXE\]TEAA4a Cb$Ze\ ggBhiFihjFnknGo$q4GqDpTrLGv\s|vGwyGyxzG~{~H$ȁ4HDȀTȂLH\ȃ|ȆHwdƊT $4ɌDɍLɎ\ɏlɐ|ɑɒɓTɛdItIIIIɜ$ʝ,ʞ<ʟLʠ\ʡ4ʧDJTJdJtʨʩʪʫʯJʰ$˱4˚DKL˦\Kt˷˸˹˺˻˼˽˾˿$4DTdtDŽȔɤʴ$4DTdtׄؔ٤ڴ$4DTdt$4DTdt%5EUeu %5EUeu !%"5#E$U%e&u'()*+,-./01%253E4U5e6u789:;<=>?@A%B5CEDUEeFuGHIJKLMNOPQ%R5SETUUeVuWXYZ[\]^_`a%b5cEdUeefughijklmnopq%r5sEtUuevuwxyz{|}~؀؁%؂5؃E؄U؅e؆u؇؈؉؊؋،؍Eh6M{V(4emY؄%V(ٗ}V٘%)BuVYH8ZuV[ RXZ-_ $Yf{ {{ٮEVR(-hۤE[e{-hhc- _ v_ۿ=VMh\U֎$ h=ֻ h=[ ֎ 5]bMu؄ ۡm_ [u]R[$^}]-0^W!!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,hfO!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,{AH*\ȰÇ#JHŋ3jȱǏ CIɓ\ɲ˗0cʜI͛8sɳϟ@ JѣH*]ʴӧPJ0bJׯ`ÊKٳE$f[+n[k˷߿Ui_$T2ntL˘3kv@Ao8t]ӥ05 J&qݺƨOͻM0x\ǍghXm%K@q`ʼnP( x/niݸ˟OƺquPF]E ]tS h]L c4g~镄 fp fcLg CRw'4he*A明;Yu '@E`ωD׏ٵdTb ^ !egC9|yUe9ev7ixiahB-PƉA gM~ggh ʨ jTii%2ꡂ E|f窬(aܬz]N&$Ɨ ecGV&++JxSk )W.-+^eAp(*@A(>[N.{iJJ\ XG,q|=ɣ@Cy% $jvIrh<4M7ͪ},o'v@sFUaeJDOL7=SnPuj˅aU7jIS/\ԂU}IMP;ptvxO+r^^+N}?ҴoB9ߚ E7DުxAOWI|P+n -ޠSTZUmFb{Wɞ)za?yp'ČKgAx.\lj-}'c͵B藾]~C@Dc6ЁVNI?C_JA-eJt&ÿdMԝ.f8Pρthl\@4eON]HQg, %h PI4+S8{uUSUXԇ`] P`♘OTkVUy^@zN^to/ xaHxV3t[qpIR@F<Xzu,_g[}ղ{k [.Iyg 7p 8y%5_s4;E!n ‚#* Ѵk*$KA%\k%C]ABjTHmaDjk]Mz׫罬] HZoz➹NC9F"sn(kF !P6%&PO3t su iԷ⧄vؿ'Er Xז Y԰Q@ t\F*Q?j5ȉs х {'uplnB.u!z`Jt CU^?;`\.oDd9 p;B`TúwAs5<ߢy+t ԯM[XAفyd' Xg$%"#:I56C{ku84ijIM0I@^4+t:C]9 xkO :G LPWB Dpؽ"چl3}cPa }{Uܒ}WYnj$R8vxHaC ^q+V,/LD/,SDwOĽ;9~| oswۣ'u8͗9OTR P srbrS>a'"pR#&Сe0sUqtQfrjb\I)ENO>s#/dl)8*r (f0/hADe! =Wh~Z\(0Vf%|b]pbu+q1Dy1-pIeJV!ZSzV~v,ܒ[}ËRs̷+QyC;U\ QenZU陙 +i99#-c7=ImR2!DvRx..G0cM?ioA44N+t4UV AWOCSK5a6XS0B$sNeQ5Sf0By"J"edTVNT(raV6Ny$EBSRY29]5f/Tf%`l9+7lc%|Rx6Gl`bmH2L=G2"1%,8f8qYR0 0hv"UDh_ D{BBL6Лz`rTP "WM ~牁F=A`A+۹=PTP`Zap75:t:9h W5<"*5*Q#&HR2:4෕rWO/x֑ YwC{5w[Ʋ֗w&+B2_8!yUmJp 8}zgOJ(xW,wzs;rB']8jrJVZ;Ud9,r3{t5Ihؚ#RL۴̳/`>4Z .![`?`vOIa?tc* bY5GF 43 oBnpMT4g7_&P)xD&6WOf0d1$";vD: ʜqjBN +n*o#3692*ʢCs*qV2w&IUǷf`_2"CHKyMjg0Aljұ=r&'v)H=c8[ܛ $}fC}oe&Ith$)%/ZjXh;l'Qef5[Ӑޔm☠l6p QcIW-µASs  ١_MJ5c+wۨ*i6Y2Iٍ'R"(3xQbW$q )"7YYm\p !V^G3zCgN ,iVgbO7$0裖Ėczۉ1-ojep=QI|7ZC"ZS7U&"Ax{J׃^˧i%F$|Y)g$<-i)C&H ( ‚Rr Ct*!j)`E xZ?ْg#tQ z9ND]Ƹ۸J|W "STy?p/R)Čb9Z[ST d%ǝDߪJ|8miZb|:1yy~<Cjc2 ;jU C av.*N-|ffY1iCYwFIçqM43 <+ U(`~_7%s}1/9dDBj'+6j MB9}٘=m95qlQWy=!2:'MsS\ B 9f563:<E? e7Imz UI\mScS`$ʼn?RZL=^bg)E`nGs==9Rwۙ==z\Z>=ʊӕz7Gd |Y{%pWWc97V{<ɔHwG}jt70C{hljWQ:nBֻbʅZ12 `hLd)|:Hl#a^T^V~^haAng_0Uo~JtL[o`]T@TXmo "s 7HqAX=([m~,/F\X2mRKt'u OIre/7a[ ")#efX^[fl>2ihgv=vn؅hz;vfEvcu|UJJ]ݿ:[ d@֢&kMX:%*Uem'd fηnvJ!GvVTƥ?26Nmm}+I)pJ([SBHt&T)_RL23"ϣ=R;#}`@(FqܢZm.u[p63Z(Q{2VnN0˧X OU'`tVAͤJt5ա$CWK҅جu37Ζ4g9ɻltzcpo}`>~|\8ʵ7sjΗPYylUz/G1DUzc.X3IFp.,ŁX6o0!Nܧ4 !Fsty Qϼ]sk d3O*t(Rg0r>N>r9:ϱ儙'Μ`fJ)/<|XFDP(SgX@$@(Lry#.+ęSC9Б;ETRM>UTU^ŚUV]~VXPlh)eRF ڳi[&추@'^&ȅB]8 KiU hK) 2nl޸ *p\Heb6w&ܹKt1yNvk o*;L,*߹wÙ@e@vT@g"L+욠"D* &B /0C 7C?1 I"'+;~b%TDmzP )&P@K'!c'HBsFkZ4*E!]22H%uLJG\"yHKXx'xG>ygy矇>z駧z>{{?|'|G?}g}߇?~秿~?`8@ЀD`@6Ё`%8A VЂ`5AvaE8BЄ'Da UBЅ/a e8CІ7auCЇ?b8D"шGDbD&6щOb8E*VъWbE.vы_c8F2ьgDcոF6эoc8G:юwcG>яd 9HBҐDd"HF6ґd$%9IJVҒd&5INvғ E- )q ΢21ȲB[=@/})J hpғ6j=~K{3,i`i}@u򄝩H+BB% -@$"dhG+Ju"eJVƲeҞ48D*SC=IŠUB;OkZyD) ?uq(ԙVg ?ZQVcUl!\CkPM-[$[pې $$tּVmt*6jSN  ֒p$dPf+*v, B+dzME:$ҟE hv~H}PXaçZئH©$} _1[VvԕIQ5%!ZZ4햠dJm!tlZۗz'sPTT8!I~+!(2M.`SɚiѭpY̡%p{iP*2wbP`<$OMoeJ8puK[A$)fv{듵A5rmE^2u_z7mƕ6ĶPBO7a\cͶÅ~[_ V)덮cZ+M:P+gEh_ZL&x"NuXO @i6eB`[C ӶH)D$!O2ۣ$r s>f5jtN&p/Vs (l摙(M&:':;rOߴQ"N+-=R ->MNSL6YGZBFYvN?%̎fCo|+f~0@MpnY:r)OݛEHOڪr|u}ˆ봮:( '31)vگM7>h3er){ˮb3\CS;ky CSZ_Bs5۶-PD. +0z8[?ӂ jv90#ӿssx [. 3S2k# M U]/⦉ř-ҎH\곖,;C/=.q:&ϺEFaR4;*)ஆ%0";nzDa_P.7H-Ӑ+DC/mC'7d<+S1&;95l@X0uJ2@E{HŹ3%ǜ*.ĎHۖ:HxFD`l0 B>M;݂-NjB 5)P-I(#k4N<&ɂX9 `H Fn:3aZk4&Z2jr4\E+\qRv+Qaô+G~G=jyk)\ĵfK&S8`SHbsde&\.;GiSMݵ'$Hiu%"3Ws+6+VW>PDC).14ecʇ(7l1{œ:b><)AᲲ{6~ ς_J_[4bdh28~,^='%>4'ʥH&]/uʦY6pkM-J$ A`"|XmKZ'פKnQDt_Zm*M#3KG+EL֭M~NYy([8›dxs#4a6.s*3(=盀DeZ>dnJyxRKns ̪Є$16`\,KГʠx-Inq5މ*[fqSqk:g/V Du4uG^'7fve<0o`3CjnF*lA5:]'S뮴<ڐ``ƪVBӽ2L[ 8Lp rmR0+\2ݎ ՇPdkBxXc`*Y_(eٺ϶\*\r2xmZݗCTB^"G'd"0{Xw^ ~_5bBU-P=MdѭH)?`v cd17ܚ8VI.2RR()l Pjݒ2ǚA+5T7\. XLHw>(5ߛcɷ|v{5-,ۯWtQ9D\|O||T,MijJe}/S{ },}H>}? %h? 8 x * : J8!Zx!j!z!!8"%x")"-"18#5x#9#=#A 9$Ey$I*$M:$QJ9%UZy%Yj%]z%a9&ey&i&m&q9'uy'y'}' :(z(*(f9lAZФ:z)V U)Vb***ը:VHN(+kTD+*격J;F@J6fkn뭶܊"R{.&mnDH P®-BZA/[2*pw~-{+1*GH 1#(VUe!ɩZUSeHYQv0=lIJ Ѣ,)ZlӢDAHhR[/`pQәdroC]gu =If<7:1xcn{4A*عMB!lhAΑO6-G$ZhՊn_ )=:驕z4EϳiƋl$RC@@\=@ PQi;Vߐh=f9Lox8{P$bvBuCxn&+c8S Ey,* *j[酲/2*tA !nEU%3Z+0_hVk2$0,GW5!c Z"׸9HtP˻Pkwһ82 6$^\A,$"Eb (ZE6lݺ^(k3;7ѿzW֝`q|zA7~ sμ1<ʹe V5xp>LPR)ؤ` WWgcS ֒xEٙƟ Fَ _cp[ $~Є_6lw9f>:+2fխ̬j|8i\NRGbg""u[eF<{AY]WJ<9FvʝSakQ˖9}QU Z7v4;-Z1Wź(.ψ>\gͼ0 Yig rgD2zOϯSHH=PmeC 7AYhW%0  ŰK(2E࿉e`8X uqD<E: D ҋd)DrNj␍q ࡝ aDen `Qx|B,F %]0mD\^>ExAXFZxQiҰpe ]FDD,_Ifʣy`JZv!!rU%uG{!F\!\(wXI"'J"qJNK(rGhA$#4F*r4^#6NLH]Ud#8JlA] İ98;DU C;#?:\|רBIAJAB.tcI0$EB!K  6`,FRFz0VJ UH$hAL^L:4Dդ⨝N$P Lt?%A$RBd~刖H\@&eP^Sc^U5MdRe +u7b%[KIneKIF H¥99HD E`f$WţCbI*\M}`VfHeJU>\N76b MΡsvϰQN%i&Tx'Q2EPcV#xcBz|lr eOfΡݧgZ *UlΧK:2g^ՠGe ZJƠ_&9*r(JWfCrcdXcf4fƨdyelMMeU"%|AHepf|ʨ^NŤ~D()rDVˑV^)fn)v0~>lȅ\ @K Vc)靲機)ک ji"&*.**6j)J*^Z**n>jvꡎjV꩚jꪒꧺꫦ*j꭪檯*ڪ kj"&+.*+6k*J+^릢O$ $@++++Ϋ+ޫ++,,&,.6,>F,NV,^f,nv,~Ȇ,Ɏɖ,( tDAHAܬҬll- -m*ml:BmlR"mbj>m2׎ؖm֚m؞ڦ-rۂn-έ-ٮm-mmߢ ..*nm:BnmR"nbj>z2f.鞮n...ή~.ޒJ$ ̺/&/.6/>F/NV/^f/nv/JI p-lv.&./o/ʯ/o p0/Fp͒+pg0/{0 o S p00 3p#AѲG 0p qNp 01'q7q,1 ?1CӰ3w1q11q1D$̞q\+SpoDF31"KAHlH$&2$/>P2>r$sz2"2(3q)O2++¬ί&ln/l+ @;l.nn)],RH480:E+r7/30-!o-z&2<;ssNn-`s6A9Ғ]l3l*l?s3)l2DG=C323&EcGg4IS.l4HwmA+r=?4r@43ϴM4DMe&g̱R,JC+r öQ2Bk 3F)V3RS+SBJ^AWkXJ?[OsB+n(T&E)huY[$ EZS,S-lŒ?؂& *=lSKA;)P`˞rnvvHBj/kom6mlvkvo,)6w7rm+7nrsPpEvowt[Pskwj?vwsswq,)xw`?t's7xw{7usQ{7s7BmtB4{7~Sd~7?7&u~'}۬8_k8n'B;rG8Ox?x_76xl-seAvqxoyg8#x_5k7KyOx3x縔kvP?l`Q6r^ ruS hϫ؃cK2So^9+yGcK:Z'+W2H82-vCtvF@l<~ctAon&AsHm1߬0#.3@2:\&t4Һ:r\&2@2\iL 3\5$;Ho\}ths"AA@H4hͺ.3;:o<*SoBtnt{G߬#nN|tcJ?XSZ{lۺ]`:7@|.<;pn<ȳvsطE+,{/gHs,C{{Ƌ2)W=+&t.34.~E<>ۺ}{Ώ2o[;TIk~@C΂>yóSC<_AK 3/9n|Ec3NtMNSnP/Qgkи]^Js/`bkp4{!'AbӸ;9@$I @)"U8&ޯm) k%ZR 5`VQ`1'-!ϞQ8ҤikSI$8s΁[R"IQ"ETQ,M<$*V E5m_R$SpQ[!ŶUzfw"^{&f7JT%FJ Z)|YUzgG}d VFy]ɓ ©Kh)gH!G;=[DeǹmN^ה$Yû[PqV]S]W{ ]4yUډHVoaH Hh=(#$Bȗ2ʞ?"-#)Γ&qCBX !Q0 6DILQ.!3"/ / 1,3LMP7N8 L'AqҨA%4e)# $ÄG$Z8S):r)z4uy4Dmq(QOkaMFE".Ղ& !ScGV%ѹP)5N[Q Ԭ6yl:5W[\畊>[`݈IJEeM2)U $EHixuU4 cږ9{4ĉ/V9)JVZI8M 4bafl=t NqUKǟF0K љ0R9 T9!q M-DB Tݭj #ED[nޢ8)ixV I1sQc:DfuNB9l]+>fu!nTk92atnYI3vXa'o{ҕ-ߗkіÔ)i@Z!QgVHIy%LIXIZl; /.X-44 xnzDA:0A )I ` I3$jE|`$Hy A$P_3Rz3õe@K"u7_1n*)I >$BV#C(1EhȹY|2h0 IJ@D< 9dH rCGa*T܌90> 9_)0\l6aFЍ/:$ HB Cl#&$&_D2RL4<= Q!4,,/d8"(1,Yi#5&J(X%-|;9%鲓D0C&Ft#(bk"Ϧ@ȉ!E gvJm#bC7j ?`{W!'AFfT /ŠLts&xOTpNH֓;iOSlqj^p(emYca0k'x d #]^%o+@qf#%`o1ShlZr$Utj,~ GlfcR4EGdP6L8Xo+E@ .%{G4ppP&p JMl/dkKrİ Vb%RĖEr͸pFtp~P(p!%GO6f𯽴KC(r2sB£y-3D6(!+(":/!B*-4xcK,mXixmC(j'p.暌*"1#ibC%_$\9Ð.3I0 .` 7V"2U}VO,N&oԎH>vJ : !aNʡfyd-?'$ #A#`NbJՐ*ɄF*#JJ.1cDi -}H&al!HJj9. g 5'ƠRjd1b-!6M-R 3(r-0!LBps;򑨎 Ol:ujj! uFŒ+d&K=n 3,L< PؐR~PQ :(ƳTZ.@VgTd#X#|L  &RJ9EY /PMTxF ɋ}C$", ?O'"Σ>K^LC}Tg,`= r0g04+ѪB7C㋵X4?"*"E*54Î8%bύcHTV!MC} άzoIqc0UVȌLcf6PZGf@E갰nĢUpSƃHZ8*oh59+p}BZe,LqPcH بO`UyL%>SUJ3^'JguU^^5_t$<5(^Nn j^2/32Dу*s86ȒRB11"%E %|K.- MI7N(-草 &"%u !-6/aiM V3PsI;V i3VЈ@1"!k^<66?~@@v2z@k?#T0PV 1Ni.37U@!&rF%#&vBKr3n3>D/d36 $Vvir3+A/ vkxx7y"`6N$hqxO`f5d&孮OE6fz | /7^P%%>~f H.HX+}qR^yUQ^ h"(Jh؂w]ķE4^Wk j'&R8Q-1Cq7 i:pu8Qhpfo T.Ȁ rOXF{OQZ0CpФg (KT]p!n˄7"2hqQ!jF8b@ sLzwXQH~57ksb _in)tJ/XFaDD{z=>tJ7zBLiCRjGcE x599yWN1a9voCV4 XC$(rqRLT)>6*^n3 t"Rr%܄e'WlJtpbMbMbBJ$'hm~r V'&8..eӢtjB|  9qi7$B*V:Z,lA@+Q,~͝j(y m'J>8"G\),~Al2( ^w"IjRXR3197Y…-ӞVKB:H.45 !\vBznn.ds 7hR$aΖ0;wۺz>5#p&hH^U F)fD!cgTj%1[)~)"GJ`Ѯ&dh|0K,ZT3fU\^qD((ꔾQDV UԔƪWx5tB_rX8>LD"<[5d0t 8PJSHnSD. _OZOb0M o &X/y gb&n(P"W Ni|Mȶy -,Hk]k5wĹ:jBpɽTqu+˚YpY^ Bd砦ѴysuԙUgl[Ŝե QEȷ8ja :e[N= ]U׼uڳ^-ZNj`q͚:"LdԒ-rxY7570#c- v&DZ,eTO!͎sm}Trqo@ 7X"%:Y5-P6aB">yMFYbF`&w$U,҄D0>"&W(nr:#sC6(3<@\DRh1_H@PEv #HV:jإ/Ƶu[[_NAq~ 92ɨÒpRLG%DQ+d?dI>TJ]H܁mM醢CZ|?=a%$@QMmYu7*\;R !D!J{$EW#+avѤF%q/ 'JW]GQ}Rt"MPik iv1"-hh|ʨS.hNhZۥfTJjJ@'qfPQIJ묶U]k[{Kz[ l2{m2+kՆ-bk>{Lz;/j+{nϾ[*[og+kZoޛW«1N0x+\1 2*Kr!Crb7\m=q/ RGMVWZlj^ r al=Qljv=kU7y:w] x܂θG9ݔ_9Wy㏋9壟nZz|z˾sN:^z$>|^;|/=W65Bs}GSO>;<׏Ͽk$U^*3 Yk # /h bp ?p",! Ohpҙ _p2! oxpJ fym^pcD%D\"XD(:1T"H$)[bD06Qw##4Qh\3Ql#H7ыxG@ь,yAL!HH*|$!KVґܤ'5 Ed)'iJQL%+_XJLժZ*҆ߚ/w _ 31c*3̄ fJ3Ԝ5@mle;[W>pʏ+'9G2(} 'q,HCov=ݮ #"?l1Z ֺ8:av tNB1,n֪aI;32oWE>7>dbwG(]QzYZ 8y(%HYXVexrgr$!hrviRUQ5L!sZv+RP 4.10F,Qi'(q!+&+(?x6 O7A6h ?Q͒$6nWj32>Iq$@ = QP5aRuB 1co"&bss]-`Vv$(bhuQ2jiv!hjC83؄ZS7bdchsI`ŠP 1s bXcQt@38D,A+0\c}艴 ;h !u2'fxAȃ wQcxjQ&w1YnVȉHx=Jk\&#!#v8Hh5>1o![G!?&8j=1*!3!#p!#9rL2q!j8*b?"U!v8V|zdb1L7b:5)#K1q1K턔n#G&|Pn1h1iQsc%,pccІmX|%({7ƙ!Q$-enyZpfAe~S 'T&OQ !n |ŘpBeDT22 Ձn"I9 Ad$ 7MڡUa6gDsR*q#s)4 fBm%`IPWRzYc)qWq*7%@!g@ AIBh;Ah!;! NuQsha8!6F+*yBsSA2 Z"" ;Q# ajQS;ye2t2Qז88K!bywdWAh@q@:Z> l #iuiA+Kw=2ʦf0Z%6pZuej(wQCiafB6*5i:hGvGzfj&?ړ)0D*g 1"t"j+DZuAY:jw{Law82zzx+h$Gj&5i7"=[@2cjƫjWF"i[RcJmm]6IYSqMJpᬿ 'JZa$QKFG!"lPDZ% b݂!?QG4r͡$2 vrQ@b*S9@XR}*JH`PG juH2\a"Q+F5}#1fC37+EtGQ;H@s!r;u{n7r}~Vd4:Iy&'timA ;'z$q)RWN|]PH9uylbF7; Z+WQEpHnBaf3_97&rlf]{xz_!o$+KQm;+HܡH,i=SHI,˵+gl6gofgz/x+r|huIrtAtyRǡ0̰" K,ZrȬ\2Rf5| &&HRz謲2GG,,5l/hxRb1鏷$\=+@smZBLPc/w!:3r̝\HrrD+Oux,0hy5AXCm#ԅғ6Q)A&Q r¦r uy'B}t{ȶ75FŅ7:h:4 0dL}9V=u*v!ȻJ[06ш]>Ay즼1XQ8ӹ2)( @[QWrwj 3YO*Qd= }7fxcv)û8o!;ȱ{l^\"uu6Zoup]LB{"+j#iHpTz76yIAqR!q5TIy\ 5W檤,x2ҩݰ?\!_ɷҒU+JW+AAjRp .xA7U8_gK O8ͬ#.Muh@4*A+ n. IYLҽ%+8pRCD*Ԏ7h1')ɍinVgx[ܬ`Ψ9hZbsУRxE8PsnwN _Ƥ=dNm]!,lXW!foqD]P! uqF[~aQUA|!MN n;A);|8Nh{{YɅW?ޛbE|X}r%L #o#-Ojו;źml,Rmp| ۑ?bta_VvH0 tD @mf NZ&H 'ApgbQz"2Q(] 0ّ@Q YW*l‡ .x_ x ˻)bW tvl޳2#Z1[)[mlhI$$B TB6|("ć^h1"F;(A)@dKHr̉3f($HgO?ߖInkPJg/Ԩu:J *lvI9iװd unݠ~ s/RQڋThdp5BPm̓Կˏ=l0P-MoZC+TVJZ`3u '_NS=7>9tӕVŕF=g[{~7zғ;n -<%mgU$B+R=4 |Z2U<-Fe bBI|Qt/dF4hC?6c:2 j37zNWziF4}y٠]4w~ٗ2C*k6uu^ XYkukҵ6]llW=u3^~]5/us]wOwwo M3)n2 * (bKSu&lPv'(Z?ikpz<蚿Hb/N xB%E"bX,Qw 'C2 *B G5 r IeIqfԟ`!V# <P|")H;I@=Q8RR$n ~$#j&}Vj_'%Ib(`t9_ўSBD+;ه$DžIã NpeĬU\|Q=)d'9IODL5MfxGE@@ ȉ*+EXzkI襊Zm-Hͱd.*R%C`,U%i*VECK14Ho=U3,5XݔS2B p8ZV +.e%4 /tHq)&ˤ`V sQ!AqΉW5U8b**OmƳ5'1OF l9E6ZSRL ,m{eqF9ja/Jol M) ʈl DPMgqUty*P ?I=[pUaD YR=مS5H-JE֪dnW!M@ܐ0#9),*t2mM;[RhKYKcT.xHDE%&խL@G[R s/=]7,q=z_7/~^/{J&8&vR TيbA!% 3XN3Dx`=JN;oI\d\R$@nBX1GG=I!bgdaZIdޗI2KVajE&a/&-n ZߧzGl+0"W\ '$h!ÌE;vLZ BëC!gYK '"6ZȤ0ܕh 'G@.BYtVoN1eOo}"Y{g%d 5R!skCצqS(%UY5,AA1LA24̩ULn$숌MK ݟg@$t=RG?xF'0mt:cҢ$(K~r+W<4MnlL G&2(GvU/Z{\IM0N,Ṩi$s+:׹B)1%^N>C\ة]筈kǒ;9'Kv|}R[ SExt-$WE"-NviRoNEӾTzg= Ua=١íڱUTB͙[T9T:-TDTFMTIKLMIUMTJTS%UQPU=VEUVWURRi4U3U_ V`Va#GbMbUV*E1d]VhehViVaU\uTrSW0oW WsWr=uUr50q}WttWvvWwW~W®OWWzM؂E؅WWUX1:؇WeX،؈mXYُؑ5ْX؎l +VVm Nyқٚ]9 xȞY Z=ZEZMڞ}ٗ`әc+UXuUYZUZլmZMUڱ-[=X UE[m[]۷[l[ZZۻ۸۰[Bu+t78R]Z}J}\ʍ\˭\̽\\\NJܗJnQ=5QյՅ]֭] uܕ]ݝݍ]]%]-m^}]e^E^^^^^^_=5Rѕe0\_____ 0y3I69%\6\M`6[ɳ)3 ̽ \@-@-.` 44S.mza[aa .\a"b#Z[%~bPV7%5CPVM8%%HbHJbE/b6`7nc8~hV&ٖ=ٕ YQ=c.>6V~eVPUe[e\d]V].d_veZe`S5R9R8WĥCR`_(fVI/a?kRbb fNfuNgv^gwvfҝպީgœN{&{F~v }P\$L{Jk)`G9LÆQ9OIN .8T _ hTO=q &}vX8}igRb:ivinj$j9if~4ivjL`hjh| bb>~fkV"nkkA\^]_1D4 X-8E0=!E6jR 8܍_̴,v8c # /~!mήMKf}4Jl3̰11оHM䎂 M`Qǒ!*|VyDl xnEG? c.nPDO4!cF n InKMlv!Eklfo0nvvNw^popUbT2} һk+/ /&+@Lj\i,қ7都L:-)@뷚zx# (؂  ' $@ o(HG:HDӻy&,8AQkH,Ҡ7}D$=Р,ȱG 1!/6=b$=F2ts e7 hrǕ|tvrv`w1]so2\iO)-ge;xMXےϪ:sPن"~vcpwpz'Z%;԰ 'r>| H(uXBR7YCJA.4:Q!c~ijQ̉Dr>/  z"1a $qaɝ&{R*`߰KƗw&/I8={{#$lu?.~{Q% 1IplA_@nVP?s>{@B,fH+tMC"GMD;mXWG/v^o fwL-vl 7sm-XmVIufPrh6mJRD[?&0Jc :bugx}O"V[/4f-5vm_+UGo8C&+jIG}Կvib,\׳ h2 ./:[4z)A|ֿsotMgy]#Vr@4$ D3Qg?L;H M)QZ [00 kH08C|EK/(>y`+P),K+ÐÐ )E#٬+ DbC6H٢it>2$WN h R)H' ZHYISAV}$d@"Ѓ4DBZІB(E ZQsNԢEэz4 h0cKԜq6A:6fs\qXs>I+A >4. u莃r`CE@x]6_@ŋ&@ '.iG\jEۈp1Aotb jXCH.kF+I0e:_|aGd@HB0Yw:=<+> sTfT>ݠ{6pK7ȝ!JQ3шU;ݍf 8X2mWBWhg'ZFT]RP Sw8.6 qPS@ǍbU3Z ^Cft2m^6:$!0 '>O<4 txŘAɪ5cA,"lgL+S ^S e0_x_"F*=$a aF_7 2-a.=Ex|%&s1N՘bX. q C{ƊVr]]Gш~#MiIcҚ4!\iOgӄ57]jQ/ӣ6Q SúձN5emkZ׮Vg[uMl|7W ?Bi>qgI |fayt*o粝(Lwz<>h޽+{zEaZTh84=stv1ч&%]6xz,$`.Kl*_ F[иi$.WB:чn#JO:ol C]n v`F `za Z[Kiu[MN-lBAޔr,rhQFJF"khHg,QA=H)VmUH/ [ai5F+(Q;lAX]UcՅg!__H,f `0b8Xf$YbQ DXH_!!_"_EhPA$ol"ޔ(m"E𘾜Er"&vWPS[h)!`ԂndF8-]:6:c;;c<#U Z@taYx.,!z5F>HN+@6 |LFԗhDpxJ-Qm$C ?KvL,5F@n. j$w=J ICGؙǠB< b#d>YH4$Aj@KNa d,y$B8WҎRHe$Y~!mAZΝedM$mZʏy \s0W$bN^Y&K^fff fr&MhMgj&i~f݀hgfkfii&kƦkm&l=^Ių"u\hnQ=Gv_Đh4:0^p\SEԂ@p mu&UxڏBVa-Ex:O& XH贂| J/xH]tg'd@&H-H {HgVvhERyEV8uFiER+gH( #'H:$N`# -%Ε&~FnO-H暱} ) ʦ챭Z̒ډ٬l,m-^-Z-z~-fnmςْmؚmڢں-ղmܾm׶-2(X0&c}L nඐnTl2n:nBJniފŚzZt@Չ 6)Z^k&kB+Dnn24)-.֮C$.../ o."/춠 B\lFJ}]D(q/ʯ//oﭸo p#0+/p'0K07o$='O?0G)Љ/ p 0 p 0 p0Wp0q /q#q+K\#&HL@(a`C1q1I11q+[q1 q  2" +^"3r$C$/2%;YX$W&7&wr&2'{('r)))r*2**,+r---r.2㱕X XsX3X$2;sY˳>3?s>>t?? A4BtB3B;;74D?tG?}{~>k7  UWo=:t;|̛|}k7}Wz#;_'s{`#g}cO{spaB 6$h‚E5Z8p#1R[`˙7wztөW~@?nwI^^<#>ˡ? =߳+P%tФ ZSL@')? -.U\]|eF*, Cq\Q%e1Hc P4&yr('\Ɇȅv m@,wL-H 1RʞlHlj3Ld̡>@BkJǬ$Z駧`J"-:&D3<%+Ijә *T. S#FAUJQU &5ՠJMO ]f}h=IL2Js23w3iz3ͷRnjSmM/ds^*<Πf<sHm]u덪^3wJI…*Uڎ=E(/^m|7/~]% 3d*YMk^s.@Bm! &7qS:NwED(p>O|ړxc< ZP S2!fáC#:QV4 EBQ~!)\ {¤@ISR4]8:R9)Pf?jPEљȥhylmQTN /e)V՗tTHD PLXEq\JXxPD!YQ3Įܢljq%H)ɧC$ ̭Y~z))KIU6PkY4O5̈́8"HIpj_ \INh[\(HOC1T IVKPlR3$@B؄h|8ZFDj['Dq͆Mfo0}|]^\jS~=5kTW8`+cIȱJk$` ńC`cMd ݳFdƧIq&4jhצʲ2,k ذy;hB]5/= :1&^ Z."\sgamrWęom^Ǚ`(v5r\V2fEZۏ-xajLvF`[h%Q}g[`Ϛ.-a_yX+pg{p"7"l 8 D$\*nUKK$!)`,eJ *s<.I P+.3P9HNoR&0 |-!bl!xOOXnj@XfUr`M,yʎw*54 `jͤЫi.)JIV0n&[Х&m4Ȕ4/6>%9FQB:XB|.j)V߸h :é#m@e)Xz٠ 䒆'N:R,,2#ΐryDH%_̢p g UfK H!)'aTj" rr [ HO™o4^&,k+"4! P /$ì^Tȫ /,x[Sb>ReVӭ0#=h&in>t@,'t,K4AOU4_6csFk3֊0"Zr#zb7 s/#,Z29e94eΥ9: q-1;`T0 eIJiGiuV;gy52gUyvgXVggwfsh}fg>=ddjkQ2fhvzMYM>FN[eߖYȒ5[v[nooo1L!G-*,^&p!W$WLqDTQfhÙUbCxOڤSZ>x88pF\`I_ōG.v8Q,thh::$-tcW^ x˞ڏ YZQU'T9lTywf}|eAUʊvyBS9z"b }PGSOWŗ۰ V492Xc;77~,[6+1&MB օO[}Gm,#oÅ:cO8u4i+8ٞkWiY%-BQ؄Y؜hwغ.f[h-m;',;A3e[[DQS㻤wnrQ14XstsZ!Nrj|Tlz'|>ڪt-|71qw-x|y(˰,pGWXdRR:YQvgzG"kqGw:gY)ȅksٗkiyyɝ\ɣ;Te2^FT|qa%?tErzҊ\9q/KIoH|IgwUhvZ j׫ Dbқ=$=3]?d3=ӕkIT[=:[{c֯U]5\9<É=ā@כ=;ڧ]ګڃk0Y@ٽlSý܋Ñ؃؇}}m՝==]  ^)^>%~/3'^7AE~=>QM#?^_>Yk>mo^s]}~灾a~g>@ @ ^2C~=~^鷾~^K '>7{~~~>^> __Þ!#^3/1?_=5;Sg[)_ag_kCmocs}i?{__?_BN@@ _ $Hp*LpÆJHqŊ/jqǎ ? Irɒ(OLr˖0_ʌIs͚8osϞ@ JȀH*]h$L9uJ$Wbݪ+^Â+,ٳfӢ]-۷n+.ݻvݫ/߿~,0Æ#^1ǎ#C,2˖3c۴g(0uS.HLbP@]nk۳c}[lI~.w໑6.Σ'9ثS׿N>|荛_zտ?||yHw_ `F >xVH!ra~($X(6h)7@hŸTTMu %帙)eTX5)DEEjFb6wfϩ@J$&lifc"')]LܚhigY.h* 餒Vwbhn顠6JFJvZꨪz:z+k;l.* Vbln"@1>UX5ZSpE'et…) d֛^RV)plpp /p?,qseh[f jjZJL*چ\FƧ(EI2yJt 'K,,?<4E|D/tP#S5R_t\c_S]m6a[]6gvlX,#RQaa߀w^]HIPkWE0 LB@ծjAN褏nz騟z꬯zۍ] j{Yh-Q)fTp2ȫ6i| ~]'ro~l Uoҏ>Y?럿'@Dh:+`@o1hm ?(JЄ Q'a Yp2l! IxC氆8!}(DDDh$:.";Ϙ+o-a[\'J3(`8޾./t]6vBIh'bO SJwK,j*b^TxY&3Mz(RE`4є;/F d:Q' K+ŇL49W)00q*TiԘeqWw){5)Z\PE HՕ%pk_9wY4:૸_!;XPǵԣN[X_N~R&r Rl -AJvL4kT3rdIffqmktv44˲e!了 3۬9ϗ|B/ht AkQ9qO-gR޳7 O[XD]$P4# κ>O?M?Zߺ@.fmc-Fo"#'B=jm73 KTmbdAqY3nJ7DfZc{WnIZԄ'U]ݕԵłՂ02h46x*739X<C(?h=8X"{ ^^bISTQt /JSf /P8SE[/aGtI8LRBcAEe qdS|$/[tZQR+~~xueRx<"|GtiKщl;}]w`KlK2UlÊFx1yz8'zz،ϘzxXx }&{Qh0ӑ t>il!,?w#&">m#"ד$< AYCGF qI)LQٔRITyPiY[X9]gr\wEe8jٖlnub٘zɌ؈~ٗ|)w{Yiy٘I6ug{rpٙ~~9yK>LDJH yiYI9™9IƙPh9Yy֙i~X yɘ9)Yɞi )z֍:ZzCaY\ Zz :jbɡڡ$%:&(_ ,ڠ-zdyrfz8:ڣ<*ݙ1IiDENPZO*QzLMZIJQS'w>:bZdzfJ ^ nzpʜru9qvʧxڜwJ~JjZ{z[Йr㕦hZzuAJCYʥV: XzW*ڪ gß_aI jN1zɊ0ڬʬѺzj*:۪ :ʭ᪓3k5**:ѩ vZꪱJ;{* k K祼7Rkiզڧ*('۲ʲ/&4;6[8K:;<-b;[D{FKuXPNS۰T R [\OQchij۶iY\rՊJ uw+v۷z|;kۭ{۸ۺ֮h鶖˶{\j۵ab;Wk뵧 [Kegj[%[Q = 7>[ě[2 ϋKkً˼ۄۻ[{v+;;{WJkcV긇+ |,L<*F  <2\Ø˾੿ <+;>GERv64.v¬ё>´\©lµ³l˻˹¼}NB@lR ̭@ C ٜ͵ <Μ\5` `,(՜޼ s <$`d@ 4I@ = V` dz @ ,ɉъ =Ҕ%"݌N ^Pd@ 0 <`h! 7bϭZ֬$gZ [ 7# M]=϶l\Už%9@@ K`?1zwy[ ]c>Id* tbs,tLٖ Ɨ]l<٘ٚ ǙƜaǓzT=Bee!O}>CM$\ SA]^-ܛp#l``Ѵ1 M-ӬA,Nm@ ˍ/غ` ݶ cwC[  Լ =2|2]Qlʯq ` =6[ .=>A <|-߶ P$&('D>t㍘LQp?T}8k} XM .}0]IԿ^21 YU=Rݤ9[m[d1 7{^ 1Z0b=\A On2mۋQNýꪞkڤ@C[ c>Rс]cs @z GM[ڭ@lS.ZϿ&&@c=0[ spyge}!&`ь?Z0G=Э -mw]葥-N- \_!o#/,TD)w~LRYX}n]r}y҇w]ܜnZdaլdv!WA@a!_e^-t%~yu iOֶ}=},<,!&ܭܬ<Cao!?}!Hֿ0ԑbSW CR; 5HC=x7 =gyn0EIn /)>?BC+V- m}^~_eaZ\ _jan!Il#506闽- !Z߿VR<^DB_dTH-)G  H(e˦-ձPR6!*=!%0}J<}n: T[Dr:^C5Zmپu\u޵^}` 6bō?v\ƜYfΘ:)*HbN$jթ]lŴeԤD{u)8JxO'ڎ`$ƚrk$GN (աF]"U5V{")\$Q˃bj#|/>ģzۈ '7bݤHN<o2)` vZD .Z4PDsqEYF_̑m1H 4#\2I%G'&K.DK/Ԓ0͔4T33L8tsM9N=̓=%4ѨXl_$(#8(!"l*IAd&g/ n{N1>U#SjEVK%Yy҄_H9 *eXccjI ,h#\57]tU]vu7^x畷^z7_|MP9S< b<ɸO F8:iCڒ cVp-I0-b{Ѻ56ha7أp8bJcTN54gicYh}v2:᩠m 9zCƙ Ǔ龹nNo?Gnp|r#gq+r=/|t9/S'}u3We?}vigvs7w}^(_1 b31j3"e"r/ʊRRO[Mj(Ta")Df&{NZL 9|b\-0.$/ NЂ5AnЃEB,τhHs>.  ? &:p|Ê0&ZȐZ3d"!Zb"l;40J S3ZX 5SGa*!ƄD@j1"8IbEh>\+^,DJ Q7c!8IIV򅖌%5IN$(;yPrmiJQD+c KUre*)i\ʲܥ.W`/c.Tf49MfV5Mn&8pR @8E1}G{R'A>*ܓ89UC7m/GE Ql+?HS•*ZU+$L峟C IO+J~QѢ %eSԦ5MuSԧ=K:0^P"[@pM0 #C[.HfA7S R"G2u),p_g| A޶aU$aX㈱JdZZ5׼DI) mpcD6R&9P^פڎ]kwGֵmu;V-mw \6-.qsk UpJНnvkXFj)wf $iZ 4AHEŒ+ڈ#PdJ!u0@ܕ ijVsuPjY:u[}k&׻5-^{ؼ5}]lb';66-#ji'<xnrF՝nV3DXVC3>v;pzG{ ?8.qG xsxA>rS|=^ܽb]wk \eus=υtCfI ŹtrәN7tR:ӳ>uoQ/שvC}fzٵ]i?uw{w~Gx< s=3@XxŨ&+q凾yw|A?z!)gC=}}g~7ᗟG_߿w?@#4@ @\l@?@@ ?  <#;DTdt !$P*$T%d&t'()*+,-./01$243D4T5d6t789:;<=>?@A$B4CDDTEdFtGHIJKLMNOPQ$R4SDTTUdVtWXYZ[\]^_`a$b4cDdTedftghijklmnopq$r4sDtTudvtwxyz{|}~Ȁȁ$Ȃ4ȃDȄTȅdȆtȇȈȉȊȋȌȍȎȏ,#4BI(IDɔTIPHȂHhɘtIɖ|II$ɞE<$QQʢ 4JLʢMQ ͬ ҿ>A}On)r RϿjLts*fTǐmW+4`#wMɞWx6爝 o`g т̐H|)}&cg8 Vw`ymgpEGtʿH,n1q[qp_\}&O8RB(7yo/ۈK!WmWB'}sl;&Rgz{ 2I0$!Rh"ƌ7Q#HJbqB)!HR&p&C*GJ`L@>,j(ҤJ2m)ԨRRj*֬Zr+ذbǒ-klER5tZ%m51H[ TN ݒ#6 LDV@RI-Zɡ/%[Kam$:ROCIjK7‡/n8ʗ3oyVR$3kÑmZtB{`۳'aAb/Jofw~??}?ϭ[|'@;`h | #(?I`զ r?C(́$'s<$"[e#+h-ңQXęe/cYbS0yG #kQ2Td !E: 7r%IB\בe=zP))L5œCBMVx-&I+!)s6&i_JE:Y;vM)6t)zaKd; O'쨄3 L&ψR(F%HT5AȏX:;B4- TK ..YQ'2F~ěCȠ$ԥ.B2]lb*fLb4Vjr62@[DžwԑlOY Lh=Q4bˢ` IAʊօg&QxQ>qJo-Nd MO'fBYG"()Vlm4CRWI$jDN3"őj$E-]|:b%KKlVF/&a' +&IMCa/gV)-HamUP7#>sRE2YYbGf ) |4#+c%;\ {IĦ\b).L*F)m!*ZTEr !bV',iٓ1&b^C˚FUxEO}052-L"bl!qgT.(w ݖguZǑ֑LE`KDzCqRr eQ4Dȋ..Oϑ(5_3cqzX t`_C^Z*'[~jޖxpCO%@}Be\dl7kSvH"&e>77 "}khd$3-%m H6N"I w Қ1辸'L -@I/M 3HrL7y&$#zb$v%ƃ٣ D둛Lθ}"N4HfdZ_w-Ȧ"O:yz_-3x3٤HOZ aUkZ/d _]hu_AӨ^ivJO 'ǿ{\lsg.Ynnl?0!PZ~8\g?s&ES1N~ю1^:=m_d&lM(ItZ%s?BH/2wi_ o. !>)07xV ?BYMHF^Xl^=7QXr9FErY $Q ]!>^D\dl F  U] Q` r^.ᯌ >!JF!Vf6K3m~8!!nH?!as8!FYEb SDKa9 9qAeeFHR`EzNѝH#"jŘTLU^Eh]G_TQ"*H!)G$bZRS]tHb*b GQj[ N2RJ*B P%c^!/nÁdhߟK4 -Hd&$A ^[EHd)Fᨥ1H9VD(}#EE5" 3B36œu$%3%}\>%$^PZ3֣^(N-IZ"UFڶ b؄1 eq]&qxAN)4U@iN9"OMl,AEj"Es]{DTvtz xZ"'leSjmN.E$RlE|HA|Z$('^ HRaMBg$a\vYd:1e\yL NpF8v8YL1E|ߌ& [HTئFىLh~߂"F)(D˹ luBgb_![O,`gʹ$1҅{](yg4裔i &efiҩAg~DOWYL.QeRnP1fK2rD@%^I]נUJ^'Z_:SaQdMm;~ '+P?|c$)hfag_Q$xGR"NE[ij ȵ G,+ Ea䳍NHYuTH] OjHEb]U~))+ƾ QO2p!. ZX ꡂ[H]rILH~cgl1!D"g-h0^#*m1$R!2^znv~Xb\`ھ5-֭---..&..6>.FN.V^.fn.v~.膮.閮.ꦮ.붮.Ʈ.֮...//&./6>/FN/V^/fn/v~/////Ư/֯///00'/07?0GO0W_0go0w00 0 0 0 ǰ 0 װ 00011'/17?1GO1W_1go1w11111DZ1ױ111  2!!2"'"/2#7#?2$G$O2%W%_2&g&o2'w'2((2))2**2++2,Dz,2-ײ-2..2//20031132'2/3373?34G4O35W5_36g6o37w73883993::3;;3<dz<3=׳=3>>3??3@@4AA4B'B/4C7C?4DGDO4EWE_4FgFo4GwG4HH4II4JJ4KK4LǴL4M״M4NN4OO4P&P7QEh<-eIB؈SW:RS-gUD='Ż5V*RME+$M N^BJ0L7%ڻڃ%1jc;0? D[d"f_[D]8M0TO96kR[#6^Q65A>oRiG.D pQLNlWrc$[DWK6op5XW7%FZfCԊI˂l{nWD@!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,{DH*\ȰÇ#JHŋ3jȱǏ CI! O\ɲ˗0cʜI͛8sɳϟ@ AB*]ʴӧPJJիXE+ג^fKٳhӪ]goU|+0mL/_%WÈ+^̸}F Wʘkn(rd'LӨS^]0׮OʍѬwR_NȓwA^?+?re_ν'L忄qӓgOsQh 6 {6Z=8%al%񶛅 ($܉hW%*v!(V4h8{c^BNTAD>d]/-y\s %YBXFI@孧 u鐙O~%9p!HBI&DrfgCsR61Y's9I)(ܜȢen)f'0Jt)agq꫰m :m &!ZAFo#y蛄F`j: [!Fa6P:Y췱ݪfGeڭ(2֜6o( Z/ЙoX:kYlqňB]o(j).d@<-5k14oy!@d;S+'oߋ21G?cK)WFwo[/b/D>(z>ϯ?܏UG=u;5dG'0gir`gkDz`Z RBk{t$ !ԟ W^{3[YSG>JNZN1lmq qlu|3 IoUޢD` #Zf<OZcTqo26JX•@fߨdC'Ygsˈ.7.X|lXaݑ7{T/'NzP٘(M"U@%Q}(]ZN})޺3!%FDqdS0Q(Ij$6 "x@Mk a`aBhS$ q3|ZܙAZͺ'QTZ]i͂8Q YUWˏ8J`"DP$ "e ? ĕ5.37rQ4U)BgJӚR]q$$H:nRu) yjNR2jN-cǠ22\RB81uW3+BMJֲO,KӼd~[>zr_\ʐ[Z:#yAj:֠T;ؑJfR6۵e\8N L8AlIhmނp+upˎ7 )~.Nщ+AJę3iKcZ.T|iJJRߚkjy r3UXcXץGQ%oX7|| Ucҿ/ƥIQI0򠉩UQ,r#CfiLMٲz]ؔt+rv2dvJѪ=;8s˥mll4m3ISȕ,Z϶3gk-jp/vu봧n*_m L9(G\ f`NbS "^t Kl{E+.D)6X(H"K8X3@B9DYFyHJLٔNPR9TYVyXZ\ٕ^`b9dYfyhjlٖ €vhVE{iQwzo3mNgWv%y BE|Q%Uf D)'Gs)}f2TؕI XUve$X;lSD|Auvg.A <"w)&\ViUQ7Ea9auqI9s`Pt6Yewq iAT+$w C+7akY-8^:4djbDgn ʚ(y)ӣkngx*AoV䩑hC8iUK֖懱޶%F?1m!V,9(OhoUXI0#X<}og<۴W.HOgtzzjO__6geiAf>LUO{C5b|K\:eAlpڶ'DAz4C#d$P:N<҆Y2O$S =}}-iW ~g+03zh#=Kl$ҡj6zKKk%uAc];IEږEZvZyӹ鯼YFT!Iآl֙ĺv2ˬjc`믠3wKvjuښ|dk멝`ꟛ:`|Uʽ)s٫Ji_q1K_u{c惢T1֣7 !b:*&XzS۳&M<#tDā/>LD"|cYPŕ㷐;#3tx"375NJ{t|n3YMs{p{kv+Z[ŷ](f{,eY#P#i;ɍȔZuOˆoK%c-Sǖlb&Z#0k0ź"IlܼV|қ,:z̛\eF5h[LRxa+DjKd}[Xiܾ;-޻`֮Ϊ vs8 SgtpZW CϾS `IuϪޛ +k;)Yo=@"_̳ h6>k.&{nSa-Km jS4^6~8:<>@B>D^F~HJLNPR>T^V~XZ\^`bvzNyY#g"+n0I!h.2..9(!n^_(ޑy={L4Ix*-%[0V>@:2u9|ç&w<]Rv{&hLש{h&؟B긚)& >5j,7\պy68i eW.%;WU5;7 ml911G,1ji5U%% >f|M.ʩe4nם,rWU ʫengm-Dd{U / /*^]Hv3n ?~*v/>?Ań՞S=QO{Kao>(EeNRVvuJE?ZR(s/s圞QD-^ĘQFH"K%*MrK'_hs$I/kxSȞ+]-jrP;?,x2eɁGUV]~VXe͞EVZmݾ1L v]y:oAykX ;1D.x!7-Yrgs-OZj֭][lڵm߶=UQw|iQyzEF<2gt?#RоЏ.$Tpz \C?1DG$DA<*ETTq:dđFm$hy|qGyQHtGj\I<1J)J+2N#K. L3D3M5dͬS:M;3O=2@ t>; 3QEeQG=*m T RM7SO?,4cKR/U3PWeUW_5VYgV[o5W.C]6Xa%XcE6YeeYg6ZiZk6[m[o7\q%\sE7]ue]w߅7^y祷^{7_}_8`&`F8afa8b'b/8c7c?9dG&dOF9eWfe_9fgfo9gwg:h&hF:ifi:jFujƺ,q;FP>&m[vn[o&gqo6q/D"@4b!*L<E*:Pb)j\F2z`4cѸ1l<G:pc9걏|(7r2d"85T$$HJFd&1IIv$(9IR4e*QJQT%,YJZƲe.qKY%0yKb4f2Lu?Ϡ9MiVP5Mn٦7 o4g8ɉNhLg9۹NzӝO~sAP" B:QVE5QݨGE ҏ:#5iHIR*MiI[RԝZYQNYSԧ=OTըEEQT.թMSUNժUUUnի]WVլeEY՚VխmMjVx@֕w$^i׼uz_Xְ5 X ֱ}a#XVVe5z<0EZ*s--hQZնMke[ڲv-lq[o\vيKOZәڔ/)t VW]skr.x׼EwK^.|w z_wu;B۰9,0 C %OxVp+Ls!apUa(nq]|b5e\B򏅼 yȜ-2d&/F)?Y>Tl|e*sՈq \1g>.ǜf6fn3߬f9יw3g>vn&G_EtDZэ#hB;ғ4Kkӕ4CiI{zԧ6uAR8}k*kZֵuk^׽lbFl`[ iKF mhEۖv%K~$^u컟=o1nMon[`g/ #pW3fխ9C񑋚"G9Sn򏯜.GuU>s4ya~r>߹=`7.:glc'K1ԝtG}GՕcSþuRG;׿vo 1D}[N0޽Όk(2u׻fOG 1o=EГ~?}Qoz֯}ꕩ:s=u{}w~?~7ѿ~{5;[{o7 $3D@;@T#ͻk؋@|= @ A䃾S[l,>DAsdAAAt.s{;$TµS[B'd¶k³#$'B)B,/B/ C-B0,144C!K@c89\@:|:;C9C>C;@$s@ ACTdDlDDEtIGJDޢ= E!D" ONDR$STEdW4X\EYtEZ k?\]^_`$a4bDc>DgfFAFjgjFkmF++ĊqrDst$sdGuDutvyl!Y|G|QGT}{Ȅ$H[TH\HX.$*HTC4H1<ÎHȊȍIIDIrLǴLʄ̼LBTϺJ ѴJSUfMgSheViEhij9nop%q5rEsNEvUxXWw=z1z}W~ a Vbb-؃%؄؅5XU4]Vm;X?V؈؎lXؑM5ITMEٓTJeԖMYTUYeٗٙٝYHv}נס#~EZ-ڥMڣC[eXmةuXګZڪեc YgّXذXE۱ٶ5۲M[=>nUtۺۻۼ۽۾\hCUڦ%\EU\eumڮ\Z\͵νJ}۵=[5]e=]Em]Mݸe%Bٕ]ݜYޥݽ]%^ ^Mm ]\^Z\ ]E_-]]مؕ_ץ]__ҽۿ%6FVf`^lU+TO 6 eUN2veSneXbYm[^\]^_ebcJAdudH`Wudcfe~fiG#6DMOfQfPngm&*U~eseT.XFvfw^xc=gS ``f`nvꨆ꩖fhjV>ijNivOn6k>VNko&磖랦kF빶zVh|&~.h>|&lfl~ln섆Fkm~%kxkFm>mNmnծDnmn.msmn6nnNn>n^nnnFnnn&o.o6yn>VoFvonnoo~o7pVFo_g wp p p p p. $)9Wg'iqGqqqqwr ?!?"Gr#g%wr%(&)r"$r+'+*s2s.70W4gs,o1w89Gs9s:7=s;sM]*OERc9}J8!Zx!v_vwi8"%x")W7H`R٘cE#A 9$Ey)KFWyHJ9%UZy%Y\߂Z9&ey&q &q9'uڙQp͸'kw' :hQby(:(aV)j)z):*z***:+z+++ ;,{,*,:,J;-Z{-j-z-;.{.骻..;/{/// <0|0 +0 ;0K<1[|1k1{1!<2%|2ZZԥ-1S 2l37׼=3A Bl4GM'4Q3 R;m5WW]g5as b{m6gm6q rm7wLҚ9j}.F]~ 87^xOK+W:飛y騟Nꭳ:_>;~;{瞺Nn <7_|OK+W>㛟}O>_?}Iyi|#g('&Ѐc8R 1( "ԠЁ <S(^P&ta WB4 qp>aC0Du(%&ш,d8(RS"(-PJ%/Q:eɨ4nd9ʱ51~c>Rg4$H=t"HI>,$$ 9ILZR$(7)O)ITT+3 Yʲ-mU2"Gb/A1e*d3hRsք5mj7ps9ʼnud;ݙxs=}곟?πtAЅ*|YBԦJl2JыrTݨG; ґ~"5iHSJғT(]K[ ә2iLsJӛT8ݩO{ ԡB5jPJԣ2UH]S թ>RjTJի$JbXEjq_5˪Vkuk[+5p\JW5~_ `=,Z طvuu,_! Y'mgC ъ=iSժ}kc ʶms궷o =q*}s\a&nBrw+ /yk]{+·/}kVx檀\`8^ K p1La [8; >Jg3V,~#ɶ5o<X>1ec!E1ld%#yNn2m|d)3N .K-g][-{Ye噹lf0Ym&3g5u4y},1ͅEу6t@24iJoҝ4CiQzԦ.5?jRԭN5cz(uom\׾u-^6md3^-fիOrqmk;=k{u;6ouN= .-|/83|o8.S|8/>Zղ8_-&9S~|*o9Y.uGMi9ρ]wsvַ}o7ǯw~_3  & .6 MV`QZ`e nbj 9EDM ` LUݟ!a_*!F!N!JaR6aea虡a!ҡa¡aaaĤu  "."6!:b">$F"%2$ZF `v"(~(r(z"))b(*"i_Za,b֢]!.b.-"00b1"1X=6#3>3F#4N4V#5^5f#6n6Jcb"8Bb8^8#9c%c:8;#<&j+b+ޣ=c=+#>>@@T1&c/BB/>dC&DVC^$Eb$2ZdFvFjuHaIIbJJ I!LLMLdMMdOOP~:#;%RRcR2RSF%TNeS&gng: Q>eUhThFNiifk Et Ц&&UrЦm(niq]%F%ss%tPLPubuVvf'wrH @nFgsALAyps}}"B"[ƥ`flp &*(:(>l$ hhn(!hlfhvpza (~($PhL(MڨNhNOh{n(yn(g jhm:tgPF)!&|PJr~i)n!Vq&r)0(*蜎(if_&)ni.**y)E৤ҧfX{nr tAią&yg~^*\djgVꩾ*VH(*]¨^(+x@^j(v|W|S~vq'6l>:)7U7>~r]XR\DO(+ҧw{[?'?-8.GKh.}E{"lnƦyI7.F˩{5$4@ p1 /( hp)^Qcnj1rH&I,Re˔/Qd sL6iެSgϜ?q tPF-F:jUP̊UkW_l u,Xgɢ5Ze᪍v[wⵛ^u w_ #6bDž!+lpA 1OXgLPeg$|U`!h3O~4f3 bй[}Y k>%$J֫~}{v߽>;yŗl}{7ˏ~? L>T>t?'10 7P9I-DCEdDO,pE[q304Ͽp6+!R@R)Bh?)gD!!M/]TIҾw|a0^rx.pK=A-TOC@jZ*Ĥ7!m>if<AZxA0mL[:'$a:IhkO \;$#yIZ$&/L&r(CITNRY (*7Z-|g@!M ʲmr^/s+.ZBw&-TvRT%7`$9)Rsg: OtS=ݩvӒqT',{$!4 }CPF 5άh/(ƤlI 4egH@EK¾kSҨ$Ym!yKS#5m mMԢMSTF0*T՚UΟ_֯c5+ʊֳUu4'0M}[qe1s7P!A4GMX@{)߼g3bb=9ZkZZЎmhMƦykFٺ1]m(܂oq[" SJAfpCd$䚡lԨ[T!=MD`k!uMHdi@}C("7)@-qA"[p{v[x {[`x fpp!LK@*0,1 gpAa @<{V*v@Lx,Nf<OlY\^,iL<R Ip܅t όwj{fMa{nxⓟ|'.y*ܞ G!>pO/? 8=c\KNs\&wya|yeǜZT,ꔹ͌-:IR7Iҡt;Kzկnk[׽^Q6vglG]uݞ|W٣n~ .}6x7^|-Oo|9y'~|ʜ1^g]{^m{^muw\7>؋=_~|+ί~?}e?}=__g˿~IɃϼo 0Ppg z.iN4j:7FpL0?09Qp=EIPOm[pcpUP{qyc/ P 0  P / p ɰ 0 p ٰ 0p0pPƮ1q  1q!1%q)-115q9=A1EqIMQ1UqY]a1eqimq1uqy}1q1q1q1q1qɱ1qٱ1q1q2 r 2!r!!!!2"%r")"-"12#5r#9#=#A2$Er$I$M$Q2%Ur%Y%]%a2&er&i&m&q2'ur'y'}'2(r(((2)r)))2*r**+ߣ**+P7²,,q -ٲ--r.͟+//-b001- *N= ۣ#$2--S$¼2A34r,PPڌ=D4Y52K0 Sb0;7<6w5}7OR-b3cԪB2=*S22Ǯ8:#33ˢ*"պ;=B:<'.>%*@3]4/5cV=MDzг? sjrnS@gS@l0Y*L6*^?%tBr1ӫ18?13c>4.SF2*JBUtE1;h;1 d.<>>aE}G4>S*(>=+ٓ*lD?7?tJa:s@sFgs,n4,t]Lt3r9'<"@#M'$tO]rzL+;tT?qtP.Q!5S;CIr>S3QA&IRTNE5RQ5U}1@T 6KcATTVSVUUW}-TCQ3E t" TXӅ9#PLTYC/W5[IOcsQ˔-,B.B/.T[u]EQHHIFES4T=$$]ٵ_/=K3VQ\"G2OT5b%j,Y:3c8Cc9vc1vbId [M6eUv'ue]eUavfifq6geug}V*VJghU ABii6jvjjj6kvkkk6lvlɶll6mvmٶmm6nvnnn6ovooo7pwp p p7qwqqq!7r%wr)r-r17s5ws9s=sA7tE'vtMWtUWuu]Ruevuuvm7$AL1uds GvVQw[G1+y1>Lh?HScdycyWq6Ŕq3uei/||#SNwEcsyRBw7WwZ}7axeQ[8G9Xas;-UhyO4?0{IԄ3?/Q4IOIV ` uכUm\xG27X)b'p~[kߔeB5T#b 8L嘋 >wD4x*d+874յS6A?2SvIbiykHKThEW'ٓu9={w}O];q~'{U͵6']|YZ3Y-؁~x+׾M?>S-zHE#9䫥g>WW;5;|M˺}KY5@yQ/ ?}Um8?ߤr=u XIba;tɝ~^CS1tU>Iom-ިYU}QX=YM^Y=P@ҝ_t(7xɚKCٓ/F>k>ib <0… :|1ĉ+Z1ƍ;nL d#E2!ʓ \i2( lfƖ0kĩ&ˁ<0eO:} 5ԩTZ5֭\z 6رd'|Y'պ$6ogrFus;ût;1Be;~ 9ɔ+[9͜-xcN8aꅫNٴk۾;ݼ{6ċ?<̛;׭ԫ[=->˛?>ջ?)`bE'`` .xw >aNHaena~8apre^ b*\RHqFJA5zӍ,c>XaG]^M]CzN> eRbi`HdH홥eEZ.)PuSfjHf:FFHQ%kg~.h@%T:d(DuD3F4hJH&(D jalB fmeث fY`z'񊟬u59lgBV=buJe -j]dgV طJ}ikl e"ښW;}o͛p{ qOUQ$y*P4( ޥ&msYLYd`/ ߌs:?D=bajلk`,kA?K+ҺsV_ q[MXUJ{v׿v,KcMwv*mpy0tzٰpm yOrB-/Hk)ZqM]Ʌ\y]䮿OC'4{EztCd'|>R+y/| {MvJTV?v"nU:~xbpq{8Ӆ[Gٯw[@lf>IR0JS#BGֱee]21P#]O2U*l)zռz! {F,PD,snkপiz*ݕdĞ%щLV_u0qAܤpCcgF"8|(}J!ԠL֐@ln) SH9򏔬%Kjrt: PO)OJ0l++WrKV\r/ ` s,1d*sl3 hJsԬ5ljs7 ps,9ωtsl; xs=|s? Ѐ t-AЄ*t mC шJtE/ьjtG? Ґt$-IOҔt,mK_ Әt4MoӜtĞ[+__|G;?5q/JEϺO*V:@;ފ$|}@la.M&= !,'bH*\ȰÇHŋ3jȱǏ)Iɓ(S\ɲ˗0cʜI͛qɳI>w JѣH*]ʴӧP[J`@!,(!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J!,J;presenterm-0.15.1/docs/src/assets/example-footer.png000064400000000000000000002010221046102023000205470ustar 00000000000000PNG  IHDRQmsBIT|dtEXtSoftwaremate-screenshotȖJ IDATxy`;Bp7ȍxPEZjUkUzV}VkjţxU!r)!BG$f7$y晝g7 ˒ʢr !a{xcaY%˲d˪r˗/& *,1a~,&=`?`C'`7=؏%0c\]PS%guRjبf|"t, .tf}P{ehxwtA |+^߫t:+tfShs<_hL;V@IdjYb<;>Kѱuퟩ͕+I:Wiͷ[UR䭷z'1Ҝ4E*2a+ѐ%pVYtѠ6JKMQrF|:|pvo߬W,RάExW#,hԱiz^7*Jnh'ez)3PnjnXe[01ixxtZ^Q344:^M[dK5\9:"hԡiya/ri䅽^BiP€akHVPߩ|gfڨC~ꕕ$wt+ x_l/:֣u>_.{ʼ~=N4tlwޖW3#IT!*geoO>ьϣzԍ#/ՠǾШQ)Iڿ>z.UxtY6+Z*I[C=F8չ_-Nɴng+{ș֫3[+-%YMbTZ=i쫥UR.%WNj:Uɉre*.m ֟^עG7:N?gF&j5Z8C}8s?2+E=ƌy#sFc(o6m\D_;MN,[c+봑ܽffuZ6dVZÙXHTbG^Wwtoi1z5 PӺW6j"EI v*++4Ѽ\EusƌЀԪYfޢU?{h[]FlM~m-_WyVcSתG??Ȯv+/NYh=[jQ F|dSBǑ$Ƀ4[ 7?GxڭD;_w S}:4wѧO|NJz} O7!45s̊ެY.Vdth^C,gL)6|SOk[=m:4'1GF^u.h8\݆鍧*l~{w4e98(̛놩e1y)0etQC^#OMז#օ<'VzD-PjiZ^i!Ǒӯ\e=8®&MT_{:.I38C3f̐_#F(;;6I1~=~S+m$uw:+ϰBlm_Cq۪׈s4GsyZ Ԥ{oWodܿT:CkRV\r {iO 5x0:%ӵ`>y2o쥺g\IwF2zD]=Qʴgzo%TFT5쨞mjEȡzXSgiig^34;mnD+U&-K%_}oW?L嫮95X|yUw)1νuIQֲBm[ZZJ餑]ZG)E8.wv~[O륇/n&u#ӓ3˒S-ϾQWt{U|\lۡ=*IP3 '}d*^맶S9UiNr?wrW7zedHWIhrG#`=ztEh5kV})k{,K*:|(*(eZ2\nt1D`s**G{*?s}siWW^5X CU{/cDuɣ4dZ>=rbVVOjRXsL[ձS'=t9R3gΔi={v})l +EBs?Y+rkX;Ir{z^_Xa{} V־>%C Р_1ZS/^,N}$#udlZOʶٛdJ2Ա3iTz;dI2ߪ+=jS!Wzr3k]ލzZ_bh2HooB=$zt֊{dJr$TZLHOZOuuPƐj4$v͝Ee2θU~3OOϿ[]1,Kvj^ʮ.C\TT|SlK/z njHTHh#r@`Y$I5jT3~8~:3+gqC=ȷEsmYmtWl&O:ժc;̽Z)yl6YjRT9U՞3UZ|>|}gC_O*STQFceP ?d߫]{M))i35uH?[sv-)#]ށ\*2M{NŒ-d;1jw[g(%)NQVSW=~TWѱСm%'ru(;LJn2c34t%]>\gC_O0̒B[Ra lbǡneMJ ܮ^c|+pӚ)ҡݵ'Zun&O+I3U^]"R?'.#㩮ihkdq\x <,v3y=%t԰\cYÄ[k ull8[y_O_Q1+˧},GJ*WG-KʙRu;(",2s2B<8*RǡC攬uʙ;Ȟ(nq6S -p>:tNJ)oBMhkUݔ5\uV[2-|E*ߧݻpYS}'}#زv_ir{~vԧ+=u\#PrJ$˫ kا``JܪwClI&2_mb$q*yðY'd1>I.M\vIWOκWi令RG>;)ڐC9z5˾ھ^OuuE7Cks4oo5j^N|dM{arj~6-^UuՇc'EXcnԇ-kwk/B-Y:^˧}>@ev!o\9:ix\m\9__~l`JDZuے:Qأڴh8!_Iޮ8:poWG]ڵV&qrJCڿk,>Fk꯿;Midj,N.٪[oE\ ˕u\˯m_gǟz[yCSkG++tӽzh9hi=>#UInOŇu0ݮmjMa ?dS5\۶&D82wXUD#OX;}cX2e˾ggMzQ]!ߚt=jSx2F/ܬ1MYSh5Z)?|r. :jP#ڐZNz_G`5dhnP9+xμHIi-R f4[LퟟSk}H pa2$sU)>3v|r=>`7~ݘI`"`;1,z!C.QC%xl !;6D`nL;cY1M;v-U9h4:t z `;t!`CL Ӻ`;O`n A`nNqvl-؎i2S+#`7>`;<؏3؎av-!;6t8 ؍&`7ݘE`n~?)++#`7>zSXvl;2vg';6c1vD` `?,;v<ؐeX< !Lu"`;;"`Cvl AA`n'q9 ؎a ;c&v+#`7%;vp0("`3M2 ؍iZvƲ%1 ;t8؍!;D`f,Qiz)W}Ne\Ub}Ϧ~ǂ:* =1 [@s:/Z~0 %j>ud}χKJtݿJDpj kkc˺|;:]'P(l]?NEZ[e@ܟdgyiFJv qK<DоæU|8u1ǧe^XG% J"|z] PڽF*%b9l[4x[4n'ĢdJ:^Naxgg"%]6}=q3;94~G][P\!PRaLKڍ^}ZC.եMje(%!R_*Z:Pd*~-]XuL3W{A |RQ7XGkmhHԢS ц\C^W=>M]X,6#V@qkTJS1.iJʤEvkmOo/(Ӣ푫3 v%\Z/g^NcAJTYV3C_}_8?~Hߝ~mrUX=.CqujOzG=>Sf,4©KFSK\ΓuJqQRo -tt8K[~kٶ2}L_~\xbBԨUzX!}O$~llN$I^=NQneS-)EmF]xRFN)!کdd{tKw漒*/L9;ܭtX%i-\:;޵.ѠzvR4R-Ac?6|uuԪi;q$04GnsW #/Iu7Ʃmw

[ntTӓ[*()bmC%R uNswKmMt@,dxeZ@׺3?:f}3xtհ<)%oj2w¼c9tf7s}F ܋?mInRR{>Zi꣕^I^L3t-Jkv$Uܟ/4P}XVu7dvۏ ډ7ϴђ=qXXt]18F15͡A K-=yQլ{dGT{!aH ֔"",if}XrO>U*T뇺tY14{WnK7UR=hG؏ ۥ}JM |QĴ~Q>|>jN6's(<^;ͫ[ɨf]Pfz`O-mnzF>@$>3e~iw] "<؏ ]b'cЯ%jhx hmwfO G|W1?&skvA㥁۴n]1 @cWZ&=AdGK>\c3[ذ5 |ڷ6-E>iFo8ف{i->y}oKF2[[" }ocuҨ.QDMen=ia?6lIgX=A\3.ךJO 0{D;4zkvDY};.^[hw+4qp{Fh؏ WVx}9{s_%e->6 m"o=p:  :݆OkbRSS ;"6PnumE.~O6<B~l: iZ仚BK-B-UTqсB_OuZ$ؔ hi# Vn~Li=pQ\ew[3ݚ0(ZJ E؏ Sr)$+Ԭ=8\oX{jQ `c\RFnK}zoa0]Hqhlh}qW~>۬ are@4V ӺM$v^) )LJCKНcc~<5;}lMYea륍rI7Z+dž0rPZ>\Y!4Kpr|#Tn}`QoˏklLJ`WU!qTpq e1g,.$]5Щj©d<tjŽzo G_nuOcUz nehYn!:)6V"OA-N0S@}!laB,[S7d4uKK%jLga=_TF܏Y,)P'z v>`~"K⫯~Y.IkH^بw憴֫0 akMbuQ6 O`7IN.u|RiØhy~8 w.W,f0J<Ayࣲ'ўo P~ m x im Nn{v<ЩNn>t+B7[4#~+ fҭeAڑ1 0e T`diHS_v:DzM7ݲV5ƭ l;E>i2 t蛻T.QgFu; 8 ?g}q蜞rUӣ؂C5֬޽IP,q0lцFuҨ.QTZU/?3]ř5)ihe/8  j}ڑWnRKe GJ6ԪS:`8+auSZ7unQeOLP|皜p:tyeOw5k=tc`PZLC;Y>]2Ls1Ő`?1`?"`?؍I`,Fv, Yv"`Ovlƴavؐi2dvǠ["`3؎a%;6D`f,n 1"`Cs,ݘv I`nLpK<6dZX&=؎e1 %;6c1;6( ;ôn؏%vL Yb9lǴavؐeqK<D`n ;C;c<"`;~$`7eXϰ`;vl`? ;vD`fLi2O`n A`ol6m^mWjo(uꭷ_ֶ+mJwqۺmm_5kDdj HwP}p< HDyBj{nZ/gta(P3W{$iÆ zD1CccȮn 2ڷoÇI41CccȮnnW{Nzvr8ʟhرcK|#Rf۶mJIi&Iͭ Oc86ucP_$2 v+oW>6.$zh oz4hj hW=`5C}!^/`7O{cסc6[M6ԡCbJ=ZbSP˹jl89Nkǎ=;G?lmWw}OjФI%IS :Dqqq;M?"]ujݺLԦMOiƌץk>|$}~v8]~enj׮ݚ9sß.]3um{nj֬zu!_AS>dVPz5a¥ ꒋ'+Eqǭ:t"ߟe˖BIH\U7)z:>7ww>/ծ]5k89Z~HJJJԱC?Hu嗩]ljϞX1$ٿ±R>_麯6#[գGw(**JaDڶm͝gGHmp}me>i(Wpر]=Tn`?Nޘ5f}aU{rrFN:~YWΝOx->>^;wVΝ5j(Wi.>J7o\^;Yeee4ibbb*^ҥ~Arn˼[5y$ƪ]lkר{e\{~mJHvF;n"qnbnvoP C{~4i˖ʔ-[Jtwo?Zh!IڴivʕeIվ}{nu^?F8N_ѧK.!-+==]6m;ԭ[WpoaZl>~*&&F S򢢢tWvk޽ڰa|2iS$gz/h¥U,}]$jݺuڿ?Oqqر4i"ۭ .UIImtʼn`AAM^xQNΝ;3FK6mڤO?XʿU~CzJHWFFvuE; ]܇{#riҤx<ڷo֯ߠ2222%0ԡC{s{']N8!W8ۉ#jۮK$>#nII֭[ۊjRr82VޟLMh)HOةwZ$p:7KzDci`,{cl} wTk3ϐ$W-h',rc|BӦ}U=g=Tam۶׷-+޽{;ws/QQW#F?_U.KNS_=S\}$F'O$更=::Z4k,]_Tz?ޭ+ РA5ixqnBܻw}9gfSϞ=d.t>`.Y6gee)55US3ϼ\H&]So|x}HQQrr=}0Rq./vt܇{#by<闕?ܮI&pGO:`e8!W8 )a>\i8u] Qnݫɓ/tхWoyڻ|X6lT>a[}C{n]6?I _uNyemlSܹN&^ܢ) e:^m v>vF;."ynbN BdQߋںu[Oxυ^P̙hU']{~mʗsjE^^~ *~޼is筐T]uf͚}>𣊟;vpG&Kƍ>r9{Լy+~w}O9úuGgff|m> ENΜީ]vJmѤ.h4ϗ:\ ImԴv>vivs/?K"7fKsWGdeeUokޣ[7Cqqq߽Ҋw̭ZYY?!=!N(KJHTA@E$"wT)P^ !$!d~$$̙5yygu^&`_yŖkw~_77?jחf5_ypaKڱkn<@xT__/Yze>.Z|ӎRo\K}.UwIJuO3EG>lp,>=ylμ6Z>{zimЬ{譭5_+¦um)W$X,꥗%i4cvϯ ^/Xڒ^֏odYRƌg-t IDAT6x-#uWҘ1c6jAbM_{wA'_:M#&E˴}|?CQ*RUU~[tiΜ4{l=VWw^7fkѥo;y´cU*=$lbTL~ ԰`Ay]ka)w}T?MwEvo7,Z oߐ֬$˖5a5[[ە~+;hjӃrwwm9 uD[ze>w;~TfYs~y/=l9]XGk~cR}G>Ovv=$lbL~ aG;S[x)3{rmEQ[W8ct9g7T;w^}UMm{۔u꩓*^[ Ts/߸P  D;F߾*on&Ů}{ѥo i֓C #[w>VrG螩hLWghKioh1k٘5ϟ,&;S7זk;а5a޼e䛛x:KGi%56;W?}t}=eS'z='vH'|DӀsiڴ;+[ƖWOГˮkjIŴ.c:s/o4Slt fYwm3/|aM:Kӷ_w]yϜٹ}jM3Ͷmpb'I7.u=ؑF?^ȏ?SZ^=AO.'H|ҹJs/o4Slt ]/رQkunMMezէuvf65x`=[,}$5^\u mۺt6[gо4Mz76W_onРA:ۼ_py7~k=vcG߿{N7?{ Wh'4;5_WO<|~޷_gM6,ZH ,z_Jo4SlbpB=A&OV{r]vti4|e2Y% GjjbQwj9p^5MNfu~Ýr!󒤃:P>dא!Cs>}~ZM;-ܬD"}Zr4jԨf?z:v6y99rяoQ't.\l~L͘l{\$۶5d=GiZi} ;H7S;=۷b@^pFxOer%Vo멧5IING?NU^^::33C=1cFkҤS8˵{6['=<vi_]5G}i·~.jsȄ׉-} H6vK|{qMmVzz_JoM|hWlz fpތ:S#:BÆ See PCC/_?D=M{l-XPrt:BŋW^n}i7//zD"!f͞~Cz晗;aZϩNU9BՊbMݫ@&}EÆ Syy0TCC>SOYMdڳ姷Z ,gÇLBAK,k.˛SlegNҮFTJeP(hժUO?>KX})]L:ó) ]ϲlY#FO6O>۬1cmXNm|P0@VFnzst`۲ 0`( 0MD ;ĴntM6*?vL(Ȳ-vLc :qXVDiےՙ@亮Fܳi=`ua᪪R<8M6榛'j;)Mދ/=wܱ}.ӎ6{wsƙc$I={n~J|gu~H$:+WwߢV]zM0ML)t?E {3btK*++879眵뒴ԷoߦK,,–)cǶ"vl}՚5k l%cVSSt:l٬YKGE96,FǶ-e. ]$j؍vT.~vՕ#l />!CY,S,Syy:P=;4OI*ԩw7VÇ˾^ztwU &oNM5vv.no]JNJJM|uӀ:/Ըq;_~M8P\M}mam%F7w|Gx$iΜ9zgm6kUViy}Oo  f/nuqwo;*͙/^(#F(iկ~W:3dۛ=89`=}::6rpẮN8v@b[6ou}}nJ7Ljvw]ͫ>[I{쮾li]4~E'7ߪ[uA'|R`jٲeZtɔ ***K/";vLbQQut{ajɒ%ZdjkkU(N5|f5I_iK35l0Iʕ+H$Rc Yg߽:+L6 ~},K&"`7.1QkV\Y>{ޔ;窱,wܱi!ChZl,ٲFml|ؙAG{ƌgn={=q˪$?5[sNM7(λPoAyW&۶b [3vg-{7O={ۮ͖ʫy_u?y`j*ˁֆ+O$G5f̺`:"=#;76-3vn7nfzO_Ea_~=czIo>~) 9bM۞HM7OglYE S[oQ^|fZ?M Z:k-]p-)77.eZK~v~~o[l;o<1JkM?kq0߰c6ߓoouy6[ԼyZs ^yՍAc[G}f ]bժU:܋P%.FfKW^nRUJMܥEhLI҇kk ᠃jwy[n.ڕM$;uynǝo_q&ӛ4H$4ibΜϚƛAuk>]|UZq~|^zo-{ MM=K#?ΐoEM7;.Dޓ//luMZ|$ʫe˖5cۘîsWS,k쭷5`3Z,I8M\xV}ڒ7S7?jsZ[kҤkOO缫ygjkK/i48>5O ͖%f;*^lae lzm]J+V$e3fˋEau]ԴчիW)ߦ)^d ֬YCO{Lnt_uMOKV)6j׿~І}V7孷j󺥖N{Ҿ6a?j`Zj~Ԣzg_E~3T[[zWQ0.ǵ4yϼGQL m]E԰T6gݔ w}TԆ5&j-P[hqfEekoYʴJ[bKv{&Nouz\.t^bQ|6<?&L=Kɴ}L&;wJTRÎ}_S޳E|Ħim k6~GUJ- &+>@O 5k-Zl6 4x`rmTg_qKUR2m;EM}cA q86DŲAfc kPKV)e2-g5d~+ڠpg9#Z}}L=K'#(;]4pZ{ŠZ{k9`ޱ_>ŲUif:F/b١ҦmO>yͯZk9 j-fBuᇷXv綸.^鵩v)}gY :ծJ+G[g^ON?mZ'6…+ի79jpӲ_uvmfZJ߾_)j͆MNkjjtW6 Ҝ9s-e]?Nλh~~Uo?1cGo;ڊ+[,kv|On|s; n.):eL=KVGc]wz󭗚~{;(5w3;jٶA餓OGu)_Ѹq;)Z}8)%^,K/-jvilQǏ{J+WTEEESOMleLkC/;޶m'7 o6W+o|ٶ>sUj|ro-]TLFmL}itN[o_{kW^~E'lY2lĻ>ſ4fw\Q\&t?K:뮻4[-}}G2%_rZfɖK=xeۥL#lM⻡Sii\t8t}YMwxolEu% &C~#G4Qiٲ5ѵܢ#A_~E't^S6ۚK/VGޘ|>յ[+ۢijkk[Ť|u;h(_W_v)}g[԰wKT}kȣаaTYYx< Р˗?s>i^<3M5vmvJ$ vL!; ('y D :q,Qq㸖MibCili0`(v EyDM0;&"`<qh"`Ȳ0 M0ӄ`D`FDy ` v1D ;F"`0԰e IDAT`(v D0eYXM05'RDi"$"`8QDxD԰`vCxD0%`vD0 `y0;"`@y0N$j0;E԰`vc8Lyl86;ƱiyA0c`q0cu 0Šs';"vL; a@}0N v48QH ;Ɖd`A0OĴnEI<框D"`8aHǒEi,#`ˢ#` vL`y0P`($Q0;"`0e`˲0M0` j0M0O$a80N%D;bxc1vcSy,y0ODq($`4`&iqh"$X;a9 a8!`H;hy($`4A0PD ;框E;Ʊ$vc`˲0 ;F"`8;iy Qy%$C`+"`HA0eQyآ8Ci\A0Cyb.M0N"#`4e;(/'`DyvL; ˆ; BF8>5QHi ybx 0:&hq|'`4\!Ӻ`K6;Ʊ,vL1J<D ;Ʊl`&&'0MD0eч#` v D0; `$v E'0;t3`>`HvE'Yq,`vDi%}/uu(ht]6yۡoydPųs)J@]hV&LlQOjcꬵ$劑w ]@[oo8I~+*<5,[^C:[M8?Ţ +dS>_<@?`3m+Y UUY-YO^Wzs:k*.Z#55˴tb=WMwum@\U$Kn,xeRqҚp.ׄ_Р˳ZpjW{jX]P!Ԓ_ڊAJ!W/+*a$6jUGuY~qadh:IRBrlj3˫lb]6"o˒@Ad+yr]K1Rfb%*wI?}%ZQUh'N*|r+Z:o~vL"'ǶؒDkaz띚wD*-[HAʎ%X5U*p#G锫B]Fg>Sr['/W,uc _Qd)xV<(VX>ѴqxMZtX bV U,z)U+ ~ūwxCRVNmv-IR&HAnX&ܱS]z-YT(+(=e3Y#qwcfr E9䇑lF+;(*%KQ5nLa(*兑I\N$4wA-<ܚdYn`||edCIR&[T}Sʪ6*JX*R3Y+3BK< پz;UTT'?WPU5l]\M}YٮB/P.S(KQ,%yR!T srÜŔ.R(y"Yl[A詐i\R2]!+Q&dq0 U^iˍي%r (1َBAqIvIH9 9T:iޞLʓ*$ipKjoPzP*UAsxznI,KKa0 {E%q#^AaAQPT,V2Y&? )*U{@Pq^n \2_|YTh}kq}Zҵ&IU)(dG &R` ƦnRXoe!'qYU,gEyza\}V~`I Mɱ%ɒl't:D̒rJüW>_c;'R@AMT lٶԸu7Q+UU]ewo[g=Dwr 򽢾u]/lFaはI#?ƚhۉ),żb^K}M{ }4@v'BՔ *dQe,'T"x,DLaBEE~^N,T<*gTf廁\ʒ_*L9*<9+vUlGœi١-EdBerkV+ng԰:'mER$9 VF찠X^B+ M$$;c$/R2R.Uµۚz͙BlN3g/IG>#ڶrZ5r@*ǥQ`%ER,Y&ߊ)bl>e+$u\{U!㪾n9N\)$*{rݸ(ŤDB *n:%*Z`| rUZ )Ro?㮒{ ~xO3؀eY=Ic HaFv%$UUW?毊&i! kȡ,҅ Bc2YJ+jv5JUVqbPOE_ݸl70łdJ%.PAPT<=e2Yrrlۖ#[j:.UkJVl9 G#׵UW)!)̬T1R_|TW+,հƏx}+ȶbؠX`ݔtNY![?԰Rm r܄b*%b1yAXZ*(+"يdyEaHSCL&b!^1Y+)GBNLe+d)+++IE~An,!+*%v'0//RTA)k.]W!P.+]lL /WbXz{mk:K_ԐruUm]&4H}*C\Kt/9 Xbj9nB\F,9a|.Њy2H>`7;n ޑA^ɘ2RkT(?{wl{vpι7#-5Fq+6RcqLcBLHH dCSȕ*;L䔋8N Cp@ %DK-i7{ {Z\:2}{s~޺n^ k-Y摔V{w 5(,Pr!f!|bCi\80SZ 9D 4]؝o[֘-R2q蹽=ͯrۿsw])ӢS/8nc$+t_s/}"wV-޷](G9PP蚖{ix#Oq(7}Sܸu<͜lGVsS>LP1NhmyDk0n8ZP!`,5i)wDk@AV ?w7 QUUUUUUUUU}&*a,WBC98s2vm7/Rwh sb,E O'7-xIz*g)3y[ "ʐEAZŪYPr3!8߱ o5^<m-;kPF#EY0R|™Ƥp(2kC]6c bhG(Xjt ȁ,JR\~&s>qBkE4|_bՁJƺ{yBH 2c*4#Ǻˌׂ@0.I|Hc1e㈩eUUUUUUUUUMgVtӡK؞fNx+0OP8E-0ŀ)c1O?o؜ Xb]C5O4O<S8k?w=_RBHA}Nb=:'5{iK4,ۄ[; IDATLƵHQ:YG: @C`]a0FO59j!N1 mQk|SUUUUUUUUU^"a~5,mn~vue;~n>ag˞eـG{Қ<R+3{ˎ1[o nIzܼE8[BA+r̴蚎B!cP(Ga bi!q}|wbD) &D2S*P zR֚y9qx?k^UUUUUUUU9DDGc_:$Jθm'NX8gۖ w'٧hGzy0YPJcB)#F[| o?ϔTHh P y-ɴmC0loYvMj5=%D5hTјf ibi3x&ui|Ounvq~0 mV/y6Gȥ0M34w |ϼWxy$ga; ,OasvhGĠkp>9҄?8)PZ2%>XI)`'5K/44VymP! w;mۓV6 5Xky{?__<0UUUUUUUUUUE+t3nJh) "SO=063)ƽ:^Z03G?$pt HȚ"elЮ 5C[佋HR @8@1S"jhc9CJ&ʼn,`"(䉄FK!KFKcHt=vA6T薅G_WUUUUUUUU}g۬hq]s/ꝗEsw&"c؎rr4yb)23I,:mV( h8p " 2 OG-W8pyM3)()S$i9b¨L.Bgrޭ=$gv ͚oAITUUUUUUUUU(Ari{\qcίo}#("^|zxM)jSJ!8TEvzHlsZ5{( Œp A=M`wpnwZKH39l󌔂6gyā0 9E¸%q0nOvz( _oTUUUUUUUUU}*O󀒂_4CR7oo #łqrwE2΁=\:wNb(y&(Efk C-28RL4MDzovSqX bwC$N1F6$9dy&%lL@ [FJ8֨pZA SUUUUUUUUUV ;%]oO_d aލf+%J),=v;Lۆ\y#vRPJSL4s qWBF g5ZiJR8'2sZ 1)Y! 'Ж 1%mપα"啟F횸H6EOs}l'ݎv!rx1\~Wsmpe5 GG8Y4Dd s"-ގ`ZRI9c Namg]i)BK*Ch$6h愔h%$ #L! Mcp0̑~u@ qrѝeȪν+?aGsUe0l#%[=]\ٮu-''sE<{+Oּ^K,Y4Sܼ~})yPCۀ21DB[Xv(%H 8H" cCVedqK.5PeC-JN%ry(n`Ƿmi&-Z[6VUUUUUUUUu"_˹Ϛ'?.i41 [nu6/ƓYs}-Ӵd2 3è-4b`ѝOm2Ob]{Q Gۆf3a{xrx U"Md}6G+w-nq-∮GSUUUUUUUUչqC|s~w"-eXK9^ޒns14}?p74D½id3aX.[JX9 (c)" Dqڕ#(ڲ; gAB& 9 [`} )eK 9i%rȳKdaBk%RJtZCB023Q[UUUUUUUU+?a?^*K3EfѪaܜG<㘜38SϑRo0ϣ_v/J+i!LӮyLSd'(%|] MӠЊR49 8rQ MzW":pۍ/)9G*U8N(!`AkMNJoPADxOra:D^ ;5zF+6}f'4 Az30L˾aK]4|6}5"' ܞ w^:dF^)λ9е ɚR.Qź]÷APr1aҎ8]asmJhgR99ޢrF5m+XƯ|-_;/gVUUUUUUUUuީy bWziER(̓z[IĔޡ1E2_vYo'CLjEZv ׼..{t"G khZRsmQ q=MWRn~z7ɹÔDt<Xh6'(),W$ ]ȩP\(a :O>ːUUUUUUUUUo$~?hcQ(,(Yjq1Jvq<6b07/,n'R4]kobkhUKG\zHZ qRJ9hC8 XhJiy$FC(@IxkP ifZJNHpZỖx#=Yc`}z J=Rf,..`UUUUUUUUU9I瓄BrDkѻ^3ng{<=+{l,J)#|F)G%#:/X1b+Vm,%N~˯%#%Ỏ,4Oq, !@*Hj"(Q E9]aCx/71uk/d] }54ppгZ[h;6վ_\8Z4~`={vʁEv BJ{:ֆ\2Z>i4R"HFK<cTE'y;kvtVCqhƳ=I F;1HC7+⫪^nJ"[Hh %&0&A7 (5s-LJ8LcihUH)a(HhpvMkC3)'Ƿۿ@34=vkrf&٦b <9 ZYt8%DQc4Vd,zȻs<rJt]R SA\䴛4G) J R̘ӎ)<}&%V[V;#Ŋ6dqR@$r0#(^ꈬ=Raw~jr2* )D`͑^^ =%M(1SbAB錶 8}$H)q0c1`E\ o Zkb hmA E"J6 jRJx-8"h%Ys+RUyO}>}k_7}rVMk'yOWU@k?ǥKJ)|W={I^^{oޯWs)mPڐ R$ *bԤdvhSvg}CFa0Lc-M1" e<%Btm߶dEd )gk{d!k;&f582c,y&/?5gky; 9}|wQi+ɷ|#_o>wϟΩw#ĿgLp]pO_ƍ/9Yt")%߯:z?{z*{{{/^u+~Go? ?Ao/}7{ .4 9ga駟_cg}-E (m  # 08 @洛{۠L % H VPh I!ʐ d9#0ta&RdhѪ%0lZcZo`gEζhlgZb "߶֢L%0R8V^_ox×}.³>{~j΃~oy˗:|v;{3c~g9'`7]cn^W]ȣ? ^ӝjΧ-sϝ:E[S{y? O?~:?w߳:_7~  _P:{?}V|g8::=״ﳿ#<6Q~Y]g9'goo|7 k2C@A"A]Z(I1PJ&ѻ9}fAMC '5{fQ8h `]YF ށ& .HPR]Iy}k12n(жh\yƁg?꩗~}ßo|s{|+w^?߯:O>3?wŵߪ03B% !&k#r2d` 넯IcɈd &}dD0A#6LwWhivG]J+TLtsky4(03gUƍWf[kYx1H)6)>8xR!BzTU@k\M?nJsG0bD駟\{/Wuu-~IfԨ.<ӫ||35e:.  /-S%wcI4 HԒhM1B~@5X0Kb J()q18'K f Ђ"-&a^.@ 6l5Q'<"O!Zd2>J)hi8pؤHT,,FGxBL/,MqRy|}v~.H.g)ĉ'|}~w]~~r)|u>~O?mF\åL}g#o\q4h l(hjjϴ#:oʖ8?~ @&!c5k6wuwrvm{ь;$a޼q+h/-^UO^GBپ忳r?>TL'˥QD]]f摿=*񮻯g뭷ϕe籧fVPMc7{Ċdz0av|M[׋bi?oߖ)덠sJ%~bZ~& ]Ud<[9zQe"इw㭶foA|qc=O~{x]9bws[.hx>-I̢|3|~c,Ntpa|cש1}Z  .䭷y÷1q}qOrԑÎ[p7`?]W>C9z6CGiSJA>sZ<-v5;}5440qB-B?:Xx5j(b޼u]7wf'կnÀ<:^xE;l2~4-@ :c}ovwQˮܭ6[U fMVdD8eQ|'dJ 'Նj # W_dU5aH~eM7mjjj9r$&mޡc̚՚FSS555 00?ַv/[oUi{޼+=[{ ,v?nAi{\ve l1cFsY?SC堃3hw :~&5ԅM@R@l11ibc")7VhJ)9zXq6vhb)J)]oߣڽ?uWm<;Ž~pIsO% nלּКHOcG WH ׿zfϞH|783KCE,^cǎO>AHX?U{~(z??x(=,$ԱzИQ\d4+Қw~Ƣ|3UA&7W'mE$S/^_AIp3Z>XOp1 _8?}nþV{_2" X YgWσe~U\}Vk-se477dXo`'|a?rk9i$~;}]|ׂ A)Ş{iԹE>̛7ۿѣG?NZv̝wUv۱.m9F[L"G>CJuݕR|A+49t/.}2{СC;v,cƌ(wCWr0buFO[ώUB -GP RCe 1BXzsKK ApKKY`A)$qLBtb <-5}ri6B#qbNs=֖ ́XP~' F' B nV\3yTOIQ1555az뱪zM9;8~)*bzml)rn}da#3qKn ^ZdՓoN+ShmV2@uw{{FZ囹kq6-Ze8Kg}E?ho\O8OpIBX[;髰fk] ͷ\N;Tf̘eo~n}/dā#cz;+Kwi2;\wn"zO?q~{^{Yfm`&LhOqqp$'p\)jQq}3o.->|GeyK^ ]zjW9d9t/FL k-oˌuH>{O.㨣k79~8˅xR࠻oOb@ )_G^e}$MؔR#1*r15O+8%$SGg'>׼6׼agO]ߡٜ|ows-]*/駟ZF\yeoU1xpzy2˯v߭5su`8Bsg]o0^`?J\͝;}Rme'44*{uٳ:^MwiW_kENf&>|,X_J#C`i{lp*@"B`#SJ9rfi%"8|?)!B#|QoSDqN" b $cbKE7%$I5g-0H<g9e=`"&}H6qq5g R:ϬYJF U`)}[*$ 9{ugyN)GuliK,#+[iܿN?Um3gϞ͑Ao^eDڲV^kW]u5?:g̟_t)mvTzrWĚ~ޯ,mg͚wlT.o:4iRDvpBn{4y6hצ'I:e|TzUհFۭ3_X_5RONZk|nګW5]em-]G^hO?8bef}4L\ gBhkpV ~a:M$"I|#˒dd2iB$J"0y #!I[Rbb-<ŀl&~1뭿~*D'$^TU"XA8P5%5} z(t!B'$qL1L\,b"6) $'A`1Iĸea!`^ +[yR}#asu3u7v^z̀]ƃ7kUnkgg} xNM6]sڅ.MwUy\ky }Cܟ{5D{ȐlL=Fm귿si5.Զ{'IFXS>kn*mo~ 8g*U!̳`ʖ+U}#dɒ+{/ "k?Kצ^xqj+of?>nEg…k[7gvخ_RWsssb)Tx7;U+m/XcPayN:鄒3W^ꣁVN8}I1\s@G1:,k&,JyYzV4Yb"JsgVGXZXkP>xa/=0>J@h+PJ!U|?dZAT >coAII!JFda\,eBdjdBP>a&$BTQB Wzm$>%<8Ϙ&-7{d@+8v#Z\G]㓦Z*6ƍn7qyl/V}E9>f{G)?s%+JP~n#:VM7mH#kS䥗^^af HXmJ?Nf4/_nU 5o_zyDnL?fƌԫcZk=?8.j.LǙ@)T80:YMfCP0QS[ '_(-kUP#)(47|@&nbhj#X[hinDIuG&!!U1lhj "W5:* ѩn!n'nY)Qh\@e qb!1 |YN+~S(z{2":Vt|>-Q^ϿVww-EOXmcccm|PȱFk֨ƸI4=1§[ɾzGuQ3B=ݘϻ?_,-e2&Oނ!.+QJ_0OJۛ-s1GD0+5k[駿|w<>,ˣwεSo˄^;fy5 IDAT7Z8^ m9?(=;y-s.৥*:^kgONfx&8?TcsA`4bB*@bSUtJCRt. O"d|{X!Itg UU9mJ"}jS`M*V1QLU-N r5}}/7ףdhiMKCA*~JG( qT'Q#.扢.8 !Ay$F[%$AbIE%B2UGemQt߹ʹޓyPNgؓ}.sY ج9 >=܃ѣG1hРCJ6hC:H&MNkkwl6@G䓏(]x1]wcKluuu<+=i[ze੫leZs){oݿQZXd uu-tF[M_xBveg gJnN*?g%gʳ>GMQ"`>TD%{ga[=Sp(!@x'1B!1Th#QiI4kƤeҤXgPR"xGc>Ʀv&PxfY#28SexP(4:A$.PSS >OmFFRJ8k:s$1uZܺT6.1&Yw`FRO1QbTC8z,H! |Sdr8$IE)1|`Ra,|U>s}Qґo\DR1q6҈ӧj"EyY pZXH'I|_a53wvƏo pa-VV$gr2xHn˶:6xNޔ|+:vm'6FIKmVP(ZSoXa {`gLf֗/rd_=ɒBjuPVr]_f)+btӮ-&-yxՕ?cte?9R svk`y6٤*G=@^_+-T.Z mT?se?cfWV+C=$.nUXۮ3fHZnS^{=G՞ø4L:TH%E3YI4:|%RY eb x8R#9G y.F)b h>|HLoJR1l$.Rȷ " } <⨈bOd~#ZhnZX#+&@q&F!qL"~ ? }Y)(% B߃gB+˗Z^{up]j-<}{n7[r?vvݪC͸MUw.]^ѣGqoG}k6+{#5ۮ^5W IKa֚ϟ67fzMOdƬq:rNZ|тJIqK Q ^g,$f6m*Oi 7ؽ[ѽee<4_xi>Xaܽt_[parmm50:i;Trv8J) KoWIia7grG:@5`%icK-ZLln.YGa""-Sm4ΥZ388Gb8Ye |O:I'-Rdk5*O:R̙ouZb3iMS)HȑH]y۲̸?W\oI; oolM]~1C-(zBث#y8jAO/]g*Ԃd;1΅gP0H[k-hmj%]ziu*XI4W9߁3m8b {KJ%҄L* l:)iC(c"qQj5XFVe2 .I̵/ؓ}-gq.)1 N+ߜɡd 'kpm団7An[yϸxFf?> 7t}t+<6<5X i?~>'\qŗ+GE&LYhllT=zN8 ÐsdYq6{Gپ֢ kG|\x1?p)4[LW\Vλvk Z 3$H!Q jWN ȤZOt`htiIz!T./7`F'1SQ/AP~g&X'k-a/̀QR!\M-ι4WibLr8%&5~F!bk|?C6T8%/O@#A' / D2$⌦9BydsDDR/˩O=oW/0aX s_0y 6o*,n/`7{G00;|n5sx>Cjѧ9?]К䋅mƍcwO?e444 `Qkn\{u̞}?i5,7xc|e sk;/As=ϟ_nN<χ7o6}/>tqO2zYgT ;MJ ]|G;AzzWK:[n%>7{}R5 6؀2 a$ >ؽ ݂︮{[Aif]w-{/_|m(m?Souo9Gu-]orک?]5~n[.)"VYP62@(. m$"6 H I&c$6âmC@$#RQ6)&Q=~E)GHT)&kj6d2>-M! a# ZZdBB 0N =Ihih~>䛛(E* ('͠&l3HbZMJ\gFK`UX7[ԯկnC'89C:mgw~?\}5p[k;2er ?1E sg]z--y0`@,Yeuq&~ rTWW.`G4p LOkՆaخ43* [G8sԄBcaE5L\?ͮ{ST!SGy,efCa5}Vqs[V^O2mԒ1,dE pUti`/+n`}B'LشB#N;Ԓqwk +odM6)EDmцlцemnvǝx]H}t{^x>fv[Ah[c;AkJ7bĈz~Ӏ4*oz7ިl!hy:v֓L6S-&~wuLYPQ&uu]? qkd")IAܒjjR9^+<~|q6l(555xZ=|O<7aUF>pwu7wNkYyw3f4UUUq̼y{7cwv2n~b9#Gf̙3fnC+e۹9L뭷\IPWWܹcww]ǵ;nַo_|vUW?&Lؔnx=_߿:O̙;CgPU5'1(^YsVk_mQ_lϸ+URW׳I 70Kyki|̿}}~{f]wY|èQu+e12~*n:t'|7>>O~!Ryx^@TlA*Rw0Bax^J0e 0 `4=E4/)6$QGd9{ɪPB *TXkqG)Œ%Ku׽Vz;S)Z?m/;n7^E:/p.јΫ?f{5Q^TB-*f!j\^b0bzx^#̒$ Il.nb|#[x#N4Ίp\!8!G$$XAHCg@ " YpU8kM֚\.E{SC~# 0I8mIF IXL(/ Q,-'|E2w}'Dq.P |>CIG!iI@RY K]UPB *)cJ{_XWŦ "n{^*wܱ(^zbA{t*Dv U!xA8aEG-5$:z)bUc5a Yb3Bx`"PSD!%> 3O 4֨Im,js4xњ$Pd!lRJIRϹp$ 2O),0#G~@XY4\K 8IKՉ@)18 AyNsH%щctD\TDj B2,*TPB 8|̀مGr)Μ9~ l2~Cfk Q?b\CE熍ΟnO.#."cuaW1Q1k&Fy%ђA5%1J$BI+b!Rj~$0Nh#E=R YZ o&B'OJ$]p Ak(OM5Ÿ!C(>PbhmD`1I ^*4L BU-X@(BzLPB *T+'|"RJ1\}=5 ϟ/?J%ݢ(+֎ &O/'|NߡJ @}L.ڿVC,) 1q5 IL4HGA!߂1HfH!IH!q@ ,ڤa:m񔢥 J8dpQ 98q!2S29*455!Y1ؤ/E'1GƷ8* ؈lT=>v~R𔇔\6&r6M#Z [ RJ1|h:FS9WҙPB *T5^; „ ySO=-7呭lՖ9?"}eܸ#D|{뭷U +-7Ϩz&uqw-U9/<%N0inv& PJAz!$:1q3D z% UBkKsbZ5_M6 i:,J8 -BH 0&!PHc' \U@uX,EHDqDfpK1I%,ߧ <,3Yg-'"&+ -avkDEAΦ!aAHXm!!H**TPB k8V^[_裦h|rAп٦B[oVPBO_uB/Ɠ1J;b#=8$Ҡ<SM:OqTILՖcx &#&j gXRGUh\VA'E|''8KW“T9(O.&!JHCq䣈QAE PKuu -"EV/4bEL|cafF#U? OE,VCy>8ܛG *TPBE>sx? IDAT≊asͷ0qFAUUlb ;rUp+TFЋs?cyBWgRxa$x:w1]m]UKƱ͐(\  a4&A eh%0 !nQA,L(Vv}9}Ω}[6/Ijyvk7.N42LW+c?6 /%a] W(⽣:bƁ=v-YvggrKBdz|{<7hˑ'D⌡aRT;|(,Kr  Rc"]~zDP-+`#ƉÔ5˄͙~,8]G%c#Ǒi|κiؐ8&`0&1eCRJQXK'?-ll"ocZÏ~}&qFlDj4䭤9W}.yn1U0Rrv򄵆\Z=awd,K!8 ޘw==aJ=y-:rJe{ڦaydc-9&8д5W91z~KGHy>咘Fm臈3Ee"ZX hqD){6-mdk-c:Ouqh*aK0<3[J2`&N#>81 =łZúGJt݂4 5&5bޓCGImocɤR\\u 9WVSJK 5tMG#s8)+.\1P> 44?L,;G\*ͨn"""""" $ Ty%)c Lc&b`1VVjZƼ&0ىP'jp *1mZ GW~KӮqy„6m5%}.~$`hC?N,|lɵRe6xH)2J\h.73gI%a^1Գh x`YLh9t] aǢx"$CZ SK@p8NԺ;W,v+Kb0qV-4M4@m-j"<jE99vŒvѲ; IfEN=[:'UK-m3c7+Fcv-q{o7֏Bs~sM0~ن&b #]k0fސo܉=s Ii4w7S!y^8u@`ҔH:8(iB64pB-#rwv ƀ-%X0֓Ș 5]`m7KhhoОJN~O>Yc%H.v<1iJXk1b4?<T9&w<- """"""u/w]ý^k9ݵ1o}P@IjbKm.X8ߑjZ.h|1iC8&z=c]c W+/e D~K.Lg+i:qcl"w=90p m 9b!CtL-丣ij`*\PXjNTc,ebFrJT<؋.""""""_\ί7_OY9X,VrJT8Bqh-8HvC-:H>2+mq{LXSL;ŭ+0W.GR4*ЁY`l˸KSbj:cƹjж-8k9>9%ᨥMY]?kް;l9Z>0pP Ő<Kp< mK+~؃""""""\>>Rs1֒DC8XPKf?ۂssrnǁ'*#mӑklrTA{ʕ' ع Wi ̳D4 c#^fGxfI3maHX_N6#$ cwLX1Rk%Bg\ҘB- >xo){y/0X8ZX9i]Ѵ XbcLXal3?S2'vgwL1-X; SeZ#$ӑvbI0|uӏ9aCbqaA5tx1>0Lk*&q3k)'JxQJ$1nxrLuYEDDDDD>[lj1`yONaʆqkÀo[Bے<ݕMX)Ӷ )B=޷6rilX !cJJDJ-8p'0N[lmpiXsi<'8M![;\,8wiSM{BӒs"JX .J Ji XCΎ\,axTDDDDDDfƼ;e 3% k5kyאKa8L nu Nn\#B)o9췄-ddz뎡@%xOYQ}40hcbXLSƷTX,{bFXuC- }qJ0\hs0 dے"P+a9\I!B,q8NO5M6(iaė0D[.V,RhDpycbD'RBL`#RS0΃8qΐK&x b蚆ϳ֝#HJ#5o_?_}7 o/O$%9Mxg(zdҭv,k.jC!/~c<띏5zEVqGos7~v~tز9{?I4adG\X;1B1|KR p~TSepղ'R#5p}O}3$""""""&M~|w0~>S"߳G>mmW_yϿtƽCf|f8糥,FxE^}#>op1ۋs| -Tڶ#koyh75ø.n10eK-q4e-xgvd0.`'M#38z)y^\K7lNDDDDD2(:{9#03.X0 а9g:N)J-;q؜'_,hҬ4Xst|»dHa{pSŵGB4ҵ-Îj[1aȥKM?ܓ-""""""_mtkg?io|CiYa5r8L46B,'T{N='Wesqw,i!M+[Gie<Ϋlޣaˇ>?xj ?#3asuc>)0G_[?O~Q~x1?~H=ˏس)BDDDDDD~uOO~=^>Kop둫|_[c }Ebֱ?~g#|bL ޸smRأKkP+=O>ytx3/h@Ͻ:7_h0NoNjz}DLq̜߻˓O=NWw|_IMz_MDDDDDD~UsEDDDDDD.ȥc4]DDDDDD %vKH]DDDDDD(\:V+"""""""VEDDDDDD.!k?xEDDDDDDD3X\>U]DDDDDDҩ """""""N-(\6U%"""""""ed4]DDDDDD1FcDDDDDDD.vK?$YIENDB`presenterm-0.15.1/docs/src/assets/formula.png000064400000000000000000000273341046102023000173010ustar 00000000000000PNG  IHDR;Y$iCCPICC profile(}=H@_?R*"vP,8jP! :\MGbYWWAquqRtZxp܏wwT38e ![B > #(1S<=||,s%o2'2ݰ77->q$x̠ ?r]vsa?ό*uE^xwwgoi*ribKGD pHYs.#.#x?vtIME  ptEXtCommentCreated with GIMPW IDATxwU7>۳}SH J轣}Ŋ " "]QZD@"C $I}DWZvwu=3sf9gfXg)(bm׻"v" E(aP(P@;Bv" E(aP(P@;Bv" E(aP(P@;Bv" E(aP(P@;Bv" E( M?tz78@;Bv" EXn6-rL5_C+Ϯf̛Iοw4"g`+f<5ڭ)+cЃeM87I%U{2?mܚs4aix⾗w֏Ͱߑ^RQ?65yՊ4̛^޶z}ڭ{_ˊo(}5}T^4+S/I-O~jHx,oRTYqybBF R-̼{ɢGrv&_qZV?pu۟v˧=^ -v8"TYRyZ[Ҵ:a矲6x~{MI"-͍iY*ͫWfYܓY4{vPL_wtjڭ/N:}d3g_y/UTe'.J;?~guuGR\\^6Hy)5b|n+[hFެY4gB`n++HyyEʫR3xTm{̻'ɘH1:mREU*zM?xYO4}!cTJ̘ccȞotl#ۂ^킎&sPYe+2t﷤ϮoO4x5*_o MO ]>єWצv#zkmmIYt~qMʫjR3xT3xó잿K|װquVe>..HQ[gF~׊t>k꺷-_$i^n}KSc/&kVPÁ<*uSJYyEuS=`xVC7}M̒KFaJ֭te=)_?ת3hkԇ^Cz Vӿ5n"bK5ڷ=7Sg`ۺ||<YxNLUO^^5Gf\uދ]]aݚ%:/ܔU)//u5m(lv~I:yEeM?Ϩ}RNR;d _O}(X^aKgoI6NKM\=sVgWYOgyi^Ӑ4jz s?o3KgJ݈-i4e|lw?9^aJcsT.H -/fL֖pl~_ uxuq<34{Z_wtaG4q_ OIq42UvvxQYxy{JНt6e:tnu)fقQіAKyM?G{Inmin.*wX|$?EWY0Y nphۺa٦Kz6.[aH=&v%ڸbI3wmxj =C僆g,Cv׮0(mkxngH݈)u#֟n/NyM]k:XM=˟y C|cuGdO~; SQ?eE7-5O[¾iZ8dui$'Q;tL6?kY}immN1ۧ6\=ސ!.EӼriZ[[SY/Fn~sev|s0y?KUyeSʼ\s?jsBREUJ}&IV/^7)~<,~y}++g<ޣiWn֩uu-]ʶifקyuCe ܿ\MZ?JkKK?qgWٷr߶4Μ^+9 ݝ]߮ƚ jiZ4akm5Z&-|xۚݕY3ҫ~\**MinXyӲzdws6ɰ}ߖ!SN˚UY5FǬxɑIǞa T^yUl C3z-2tȠ~ǽ9 rea#kۮwkѷO\q:dPn2=>w.ox!LZ:g}annjE`o3mvw8!g~B.\|47!҆ŽR, :[I'1̩+<̟0OOn'qovU쟊2eZ\da7Nmmm>Sd1{\sMy;ޘyZ~$IUUe9>lH}S]SO~ג%o]c>xT zAw|+眔/j/?޹5?Txmwy \z/^c+m5=; g}n/Z$;KYxiۺ=Go1,\Ci|F@0}Ƭ7oA.ZҶ<`@\ӯ_Ƶ@!Gxzʴl͖/jO<.[l>:YӘ$=>~ ܷOlyzʳv#ֻۮ;f6ygxA5ˉrN;cmLuOKӘzԳ,MuMUޕ+:-WQQSOdu3鯷/+I2m<9耽R^^7SLYs_[[> 圯|;OO憀Nzg@q6'v@2s֜]}{T*eSSs7/}pjlj;̚=E&ӫWm!UYQpгM76G~pz֬?UO(O]]TUU2UU8F#eР)^#ͥ2VvM晩\SN:׮?.;ϚꌨcFg]wȞ{L̈aݧo>9ޅbe[57~W{K_gܒ=˹e6y{ޒ}-{ٳ{tQx_#eƎvKڳ47hWk4Ǟ;0Je}ru7aժn>sϤޚ|{#ڶSz[8oF4dۅtjI2wނ4d]wt{ښb\w-ݶ/^'=k4fR^*e\?,]܍D ;J^Mv$>v&Çÿ#gժy'u{=cyr{:3gyE۫Wm UW[ֽw6lp-^vw&CfeYje>4˗vЕ6vz^gea%wN/["5ku|ͯˡ&IZ[̳3rägҍuwd#jq_>ȇޝ#?8s-Ȝ96[*?osнHzX^s׾?]=igJE=;mٲ;wAN,_[Z[w3? ۍOKKK,X8CL>3jyӑeyr3]zr˭wKEEEsx7JmMuN_%"\wsnGd2s<лw]^s~9'Iy}lzeȐA9夏gzHN>$?$kM˾{}8~^}Lyfms7_,O AgFf=vNuu v\pя2zT}<{}pu?G吃εߜο8kִbʆ\ȣsyeذk;l߯o|MY!I2~˱mfΜ3F$I[h:ubrKKK&?55?th XaNCê| 5T%gvB>sYtY~ߑ9swt<3S;I9sm>x:se_뿮m\-6m/;ǠAҷO$ɜ9hllE߾,/͢K2m̞3/# v-M~jj+s§tArɧ[zJeՓظivooϖ$Y;kQo?2?Ro]-O}߿oƎ$yrvz=vߩ՜~ ߉WWkx @]5Kg퓟\vA 5k󑏟Z~[GSNxUs\?{?o;"Iϴ3;տ455y)9'{^q[O|a?Y7<@Wl,%M@wt|KLKK?ޫ*3>[A^ɨ繙s-Tgf#u֭^Kοo>|fIxt^sI斖Lֶ_?xnxev#\=oza9z|=xL:#zR6X~ Ԝٳfϝw._^j;$I,X_N1s w/JZ[[s+. IrIc=9szw/ر-?]6^ĉ۵}{vTUUT*O|ʤo3SMz9s+}w{˙_8Eի&{~0_.;wZ;^ǂ2eʴN,^4=Hd6'e!WX;ҙ\rWۦ\}斖|3gU"o~ 4 Iа*[g~܈A^_iOӫ~l,X@ؘ3\ï|R<턼C+ {[ڂ/K?.I6а*7r.&Gzvsۗ唓>aԓ?8puyR*Ғoǹֻ/0242f=֟u=?f:` ΃g|6iiiWWG@v?ȬLz§Qkyy)g <0---ѥO;7 ;VX3477w1K./D]O=eܒ|'vtB@cKJTj9ge=лsg59K_хXPw@Yِ^j$?߾+眔e˖c>β{SN?S_^o8}9R sʆȣ=ЅxC =-; tLn|xgtɲ2-2hЀ̜5Md|ID IDATwͨ76bzښ6tpǟʙ\5j|S|ٳ66[y re#>uz7&eoʆ<]L@v>"I2{μtYzMS}!k4Ϟ3fmpRv6'OuS+rj/$̧d,'zn,XԣTSSzZF5ݔ׾u-/R^^޽8yvJ,o^H@.xҒ֜yE_/ub&l7>Irz?snrv˛Q8{119Cmwn_Q,OʪTWeDr𾩩SMCê׸~ب$0_.Mq}9_NKKK!W*r[ZZsQ d%Ybe֬iLsss]ִ&IR*T*OEyy**S[[ -a=BUUevmASg]P#IZZZ׿Ŧ} 0=*I`tʹYba>z>IU92k\ @tk?޷&I[󾛇}RN'Nc)_fҍit;}%5GHv?wV|+uu8f[Wc9zDБ$o{l\w͂."[9Od$K.ˉ|%K-V[Ǿ+---57]D@qޑ#?(IfMcN3}qoUuYۧw{Lyf Et _>|;$---7~{{G.;o B$zut1嶟uN'f]Us܇ߓ׾怔=U᯷]H@T'Iv~ڭϹWmM޷]xcjkkmֻ|  ;R)~ c$fG>vJ,\-ί*Fge}v^{>}ֻϬYss9]L@8sm :oN;nIT*KuuU***+/OeUeFȨQ:dp۫)/ͅlryכ7ݺc]Y-M';pG3k\vɌ1,g~‹~='0,@567ʕ mSECê]`nB&sw䦛(aP(P@;!-&L= 5C2xl6zD۶}Y~5@7 ?5?awIt3Ho;47-z>2flt_Plr=~2rİld*5/1;ؤ;:Yzu.?\$=>~r>ijnNUee3鯷~5k5 $`_ȴ3sKL9ޘk _7/}m'>qH^eeê=}cޙR9Ͽx[..@%`w)\$ɠARW+I2z|+s7wدe奌="O<9CsGLʼ9.@&`w2<$oӦϮ}5ܒ%:-cc52Il"\wS~]m;m˯UZ[[;o]O,Z4ӐlMbŊYbe.׆˖ }뇶r=jD6g˸-$Iu8}>4E6c?xT5qʥdqckoʍ7#I.;䂯Îx_NEmo93+\&&?rFˌz&7}Ƭ.aUF`(aP(P@;Bv" E(aP(P@;Bv" E(aP(P@;Bv" BB_#c(aP(P@;Bv" E(aP(P@;Bv" E(aP(P@;Bv" E(aP(P@;Bv" E(aP(P@;Bv" E(aP(P@;Bv" E(aP(P@;Bv" E(aP(P@;Bv" E(aP(P@;Bv" E(aP(P@;BG1IENDB`presenterm-0.15.1/docs/src/assets/layouts.png000064400000000000000000010637421046102023000173400ustar 00000000000000PNG  IHDR5%EgiCCPICC profile(}=H@_SR[ ␡:Yq*BZu0 4$).kŪ "%/)=B4ktL%b&*^D?EHf1'IIt_.Ƴ:s՜H< & ޴ VUs1.Hu71өyXhcYԈSXY+WYuH`K BA%a#FN;\D.\%0r, Z /)_c|;N?Wz_3ZZ.[\Olʮ)}Sk^o}>i*y^= r~bKGD pHYs.#.#x?vtIME  ;mtEXtCommentCreated with GIMPW IDATxye?OOwϕ} ܊ ""x+ z"zzAT9DEX/#W2IfC&LLL*5/kdSSa'u*}tӵWf?rܧO8@@@@@@@@@@@@@@@@@@@@@@@@ ^ @@@@A|3H@@@@@@@@@@ ^ @@@@A|3H@ (veA(wUbiP$P_8 A(A(A(A(A(A(A(A(A(A(A(A(A(A(A(A(A(A(A(A(A(A(A(A(A(A(A(w7<~Rx@vU&2X-fdkoo;se^;<г_/:?8ܳe_oowȶ{Fھٛ[%ڰf7]+RA;=VRK_4{wIӲVKh'-ȡӖLKͭZe|ꗭ5G_$I~KT˥T˥Ur.o/퀜WKv\>m7TGޜt̍t C:?xP{Z+eZfs؞^~yy>U+RÆm91,YWv'<~JݿRPmfޢN޾P):lZ*8\35o]ʣdKs3sE $O,ϝ郥vtH5ͪȯ։$IR^PڷcGM T3\o sS'u.ܗ_)}{}v}zhpyҎ;Spi ۗ3BnogamOxD>) wurᵍzozw>^ȥ76r^`J뎑v|k3G+j:c/sy.{/y;?lA _==5ekyw/Wr鍍ߜz/V`eK!cܽhOġu>~25I[sU|F~sw K;wr_tx7XSveoy|G?˭s<=/Is0̛ţ= ,} }a6OL^n. Xy)}p?bJ?2sZgzM5o|j%cg;FyǦ}-뫿r+:=m঻[y֩$W~h3kv*yzFdz_N|Z%9NG?F Ir9J;izza4jcxIï_|}o G?}u/-$1߬z߻zuۮojt'_GOuţ(csdi@TJN:dZ6ZBzhn{}uݽ=Vz7A9HϜS}~P]##0%{s؞~r6ޗR*}IC/kM,7w[m륝;oYӒv'z;߻jyrX`1sv1m%Isw?:i _ndv?|wtOrًH~j7:Zw5ҾuN __2J_`2w_&wq~=y`l:uXJ eGLꄼ/3'=AR=5ޡֽ^{G3ۙ;4< [yaݞԷ~ 7a Mzq3~~;r6c^[k%}X-g2Q '.l<}i7*s7^;v|Iҿ'ZpOw7>CO1+vݯ)=!pN?n _FT]gGg1yNvz;?tX;.k67pmt/\[o^J2cU׬uu'֓WhաYٗ;m{<֟wFT诔Ӧ5[}5Zkw-?s̘7&u&ɬC;o>7,O;tρlSu7՞n+I&?}}+{;jo_#YA?m?jփ}m`=/Z{PVxC9V~umgP*%=Ow}g튂*jwҦJX)mEݻ+{Qؿ3ɧ~fOۭj^Z^# u|no,]W/['Z Zk*k*k@Znf Nnw/3zhgM{?vyӧ_heNTX.;% zWty춽{<=ہjwpyW7V3o]տN~,oghj)[o/`rw&uTK٬:~3kTm;>5>X-QT']s2uAϞnz鯔K^Ͽ|07՟om_wnz^ġk~ss֕XZjn/dˇ3_ʊ&6v3/cy.=DGn^#7wd 꼏@ؤȗ7q's>f&?:i;ɹWsFWi[qjn8A rᵍ^z,w{;'/ 64|y}AoUŗ:uGXgc-}C5}XnEys#/fNo=9Jrk+>rNw_qOn[W VoX7ʙV=/n\0GlVPF'7^P˷.ip5|iy!~+VګU 7[{<飋 NZ:=h'-Pj*x핦`jg9m2>@ 0x`Jˌ>JE X]6LN?b(I2_M+tFonZLAV);tz7yMX#Znxh.SȈLzn~;][A m#f'ܣbEL """""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""1AE 4nf~WqA0"Os/H(v=Ii)r_@ }+r˭Wk/ys˭W[̧?Bp}F?oM[x￟ ѷK\[Oᏼc}nBe`8fld~M4IZNZΟREW%(s1{_t,/W^37x@9g?@{h$VR*mNsל 򬣲_Q0uYggݫ_ *#vZєJtZ wl'iWIéT+)ƝwޙkvGo|6n{FW߭޶F[nƛl$375B]u1cFϺv!Rigg6ֳ}d)=3I^ԖUOHeVLZ52O_>3g|̕nߟ$~?yZqo?C75B=WMZwMJ\N|?_~~?|eJZzZ504l0sJ\fCP@X>%JrFzg<0M4\zm4n9n3Wu*wM{a>,r9###_?vrU[\}{y{@ o<6zdM200RZ[nɅ^SOpvuۜpg7`FFFFrV~ V|9#$7&IOzRMyG?q>SW??G}dzt:p >~U '< nmMr&)/y!9d̙3388V oߙ/5G(/x2{L6-F#wqG9|>[k_T}G) IDAT-/{KחfOr>/jUg> HN_74jIR$^k&i&I.JmEU'h쟝w)G}Lo}s6`Iۆ3<<:OZVN׹׸:׾8'x¤3886,{w䤷}pud֬'^o9#l6mK>Ic=zr;C9|2{]aWfm=>%/| _̧Nmyg?{_re>D7)iWx]qo?R~o `vzmQ4 ʳ~E1`x׻-oy͛n)V;l3+[ned-6;Ǭ>5>w߾#Vkh42cƌlV5kVR*X~o?)Izk&s˴iӲ;f RVZ+=ެY[nȭޚv-lIr1KTW\EgPfOzj~ݷWrz6|X]??Ň/F#i93_Ymb 7X~kDupZZJFSLldzu4ӭ6h8`Jv1Y+z쪳_Rnvm>ӲT,`"`~{׿5m_SrA%Iklyǂ;cǃ:#zDGwޙcyC.Icsk_kVEy^ozqwyg7}ٛNɣJ~8gŒ6{kT*y+ߘ$yߒ#|D]>я}hqΜ9G>W=OxS>1hZ+ozcʔ~ݷ ^ח\rI>ꍙ?ĺM9ꨣ͵__rޛE)Jg?}k덫~}R}4M>>%@9IL5>%@_VRRNc4@ղbWM6rV}Qx;*/3xzO # $pR,I2>a<'/vD'o:WWyefOt:|'tD'7ݙ#̛7/Gꕞ_7>/̽{Ķ//&t0.W??{$… sq'NtL]>OOe?)Oy}'>a+ ,/:*SZ#zUM$WH~w[kOo@=r*CET+'mQodttQj{CFcnݮO`}s9媳_?miv+XfzNcqڣ ]=i,»3:-JX/pZFnK:_rb»^:z_~g+;䢋.x+By-^4|7M,I51cF{;l\򇿮}~[o5I&_oJkJe<{-=} ./0)(׾tU\{Oy _RZO}9_&XtSJ8f=tSepp ~6UGhS7R4R7-=BQ 6SkRsAJ!sdl]-+t;4»;xMm]IJ Y N0})?9s1%/=p^[qu5\}/pigO/kttuSlk6ǃjgM,:Ή=n);7m'/fÞJ7 5ni,7{R/O8ToȢQ_F}4I'v=YPלs\Y\o16vuciݓνmjS^onM( G1:F}q]4fmQ98 p?)m6W:KTS~>sdhh(3gnc97nu]K/lGKk\]5s̉nm}yЇ)J.ny~KZz8jKX߶ %6h<'O}~fk oUW]j?<ܟӧO\c͟?6מo֬YqS*&͞=~Wh9縴:J idZI3poE#ōu)۩7vR6$)=mF:iW[?k+hr:TȽijnZjnVnUG$K,UXe;TKi ,1)#JlVor?Rz.dk\]?0<22y &Ov-?CѷZ해ץ {;Nilّy#'tr5jn[kσ7mڴxы^v*ߧMPt}} MO_epҶz_e"$f7ŵtZʹZ4llԮn-k9.cRiFҮ/HsG10V3xMXm.Njctdt,^0 /h=;x ͈ɱ>1j2s̉3fdˮO|K\cAΫL5Z~=_ nO}PY`$˱Ǿ.;s-<mhW*}J_ZRO=i5iN91{Io<M:XO;ivI\u30.ett4ihp4)?di7WR^_F4YX9p@uF21o);=T[%u7ݙ{G&^9Y}'m7>Fm9W/qky5 6XgouJ鏗_vrr/̟xi z= AguKN;>m@___vq*GXrbyFFFw+gy]_Ӳ6}"GmF64*yOmii[[J5}$lpֽÿWҗ1m֢4.NqOJSt?sk6k,]nfF}r:V*?i6iԚ_F;F;#{2h1ZZVccYJet$c瞑v9oRxNx{ho@j5c׸-,}|v\ wyǔM74|U믟Xs=qVg=~'m:[uE5>[T~=v<Ǐ|s˱w^`qyb@$TS5S﫤RMw`>Kzjii,}u9ǥjV*ir%u$cDP{uJ6ޖJCC=c56R: fL:>Quע:_M,o9Zg:ˏy_{AOywjo~|mJk?^1'~=w뽙7oi60)m==!\rWzFQ|-ܭjToT9TojQk5:?хiXٻ`ҳ}.=JcPH%JDC ("LR1$#T= d`,a0dMiB =}彭 3=nOU{ۺ={JӱexBzw7N!Һ@\R1tYUUvt9pV-εBܑ$ mz7>~C~72?ͧ>h cK;ysoy%y۶[z|[%R77|?s{7xo~~wW}?o:_W??~n[npeϿL/. 5_2z? c?=K^oxʿ/^3._KDB!B!n-1K hb>p}Z]Z >'݀]yIk{|Dt9T]CVNmC.ֽg2m]mӂ6+@ l9} <vce:'GAEqG2r xj|ٗ63?s}9o{/)$/K^_xO}o0 .?Gð3^ܯP%w}7?3?͇?ׯ?r>B;[O|+1r2s.Jx;~1Wm^җ}cʕ+<Ϧ,K? |˷|3__#:{xsKeܸq/ޖ>'Jm|+a?xJ^^ay{~}|#ݚs?6߿}6~g _e/o> !B!V= 17+gk-CQ ѭ)LSa փf1ZaʜuCR52x)дEm(|sxcP.#A!"_/廿;9/z yы^xfo>SdV\r+WZYId׽WKueY_W<{/;ѧo_0> ׮]{JO; ??Η|ɗҥK{[~M?ΥKx+R\|˗/<}{.\AK8<[oxy׻s>sWnG!B!*D{Ҙqa,U;-o~;_Z苾˗,Ksя~Q|9૿ysEQB`Zq5z#ʯ_=e)_u+kگy!eYB͛|=5?%|7|=}sY,Xx䑇~'y/moo?'>|}-sU~?ȏysOW^oB!B;C`;2@H ;NeZ<Л .E 'iw!W`]K`;,ȲS/juGqV{u8i=uN }\!'˟_A9KB!B!B!sybYEV~Gὥo7ktm 12OS 2(P_F/Y|K~RNm$:tDpS9{BB Bl*:=e9pbMVsy\/!n _#B!B!B!xj ZZ з 'Ɓ cEwf1ZBw:kz!@n CVs;@*O+֟ lږnҞ<[.!(JNB!B!B!Zj-̶ u;DҬk;>|ȩ~໾-l3Ym6kXi:KQ2*P;͓:ʋ\X Mw,j3|e<NV!B!B!V{\cLv^j<;d} RgB۔coCKt=Z9O5' [ǍuvQT{豜egUEVU;|{ L?*ۡy |ɅB1$ B!B!B[.c @iVa[nwe? ~a zx{ݯ =}z8U|@ !j"Pa~ж m{7m) дۥK^ωM!Kqǐ B!B!B!n 4ZiKM e שd1876?>{V7'cL˘D|k8J,C$ v:@pAƐۭy>=bo+TX\}&W./Y.˳ЭZV뛸X.a!B!B!VU^]28d}̳^Ӻqׂq rsJ_AS+G-}pxP| @ Z@9pZp|v}LM* G랛kn +s? ,#uzmίΟ ٴ?]EmnʅB#B!B!B!n y#FGp+l[5y |dSX` l2ZOLz+:O<^aBF'S wA>+\'~u4PxbPnM'4Dь!mp}w63ڇR6/yp6һagl@u*0֒ٞQCCo7GX_^ Oŭ` wtcm{82S{5:@kkXA-9umږ.3]`Q4&b耶[t]ÃrQwB!B!B!Ht=[D}*YZkNjQy[8}k=]{5:\GK~9/-LnTϢ%To&jiAyrn5Ta"f90mS&Դ%l簝cV6^.˝Mf0\X!mOF!B!B! et 733cJMIR')=&qV@Рhc0<%?%?7btRiC nhzGΓW"SP=q;s3>7}|i!ms]uۜB$ B!B!B[^#&c{Ѿ n]TT*BetsH@:vGIϠGt'55P'}֗8<@lХC`MjzQ:PCXiNv n[T rlX ,\P+#H@!B!B!D'=xR):ۣC'`a]Z=gCK@iZ }GpjA^R90g2V$vNV`]*w+8GZCSd`p~ TN'jYLFiwgtN(J 5CۦcH` lB;B!B!BqKdgvt`RT-b' mز=.`;еLW{#TvA{;`(|/IxxWc5`Q:#ҵ dvMspbFUGn@UiT9}K*}SesTٙ0--.a]o~vۥshjMBۗS B!B!B[AKUa,DUAQ@js=1e Ρ ;oOhؓkG=7}FYQ% wn;VrNv.;Tm01U;ֻ|7ແC;ݭжn? Z.&B!B!BqKnixh;,;ΏBHۦn2䕡<7ݙ)2]ƁOoj3>'sD T~ ]|7\ec]YCi8duMVU2 :O~P%_ѝ8N?m~عj*ΎB&mV-,˥\t!mMB!B!B!x}dİ& }f8Vw; +@Kkg%/%l.OmaStuUs^پ[T˒&,tsM| !nkB!B!B!ē^MtGfg~U L# }0yA :uꢢ>8mv*Lp>֐ ]>pqmѺ &vĐ0#@8 *S[mZTNR2OC 8%`ٵ;SDZ&uL ,,I@!B!B!O*c Ob4:lߵqg.ޒ ٞqUKI%j|fuaD%B)B!B!B!ēOȵf@9"YbTu62OE)PTAhoySEMpؾ}I^ [T@6+X .4l4h=b&`Lo|yʯ߃~ Gɍ@ #l9vbW9{wi7υxDqWO_f9\0Uޢ`-%&`;GQ,8 *cB$ B!B!B'UYE#z]yMy}Qsj+'n|{`X[ 8ۍ?SA1Xl(e"kŪ;v>Pc֗M9@OY(zNqϵgd!׾'}C#:Z6@^p<`\?V4|lYrbjǟU~uγ:NZ?_CQ-˹05`?=@ikC((yvG3ߧMEB$ B!B!B'£TAtG zyp6ڰ;]?EMN༠=PEgO#2&У lb:c"f?@mL #4x +P&&\= i7x jϗ]׬:Z { g3 p" ns pD~S.RX`{j]N.9ߦJLQQb ;t9<\yl]zpuk -JK Mq{ńB!B!B?^IH3Kȋ<#l {s;)[n0T|6ȁ>cP@Лϵa0b ;uZy`lͽ(~; iRs /4Zf m{4vBSwLv' a:`ڣk˒a2yZ p)rs=t랊tNWuiiS``4pL -ʁϰrfEB$ B!B!B']] [Těd} ϩ^ɢҧb~p=T:gslМ_vfO1p:lS?:n%6f Tgal tOxmR3 X0c7z<پg %~ln{xN\+Q>gjhY stc:G>Hmv ݺYs#/-C``]  !nkB!B!B!ē[cr)SRW(1a=dˊ>˺:SǛe*\ P\ KSFp߶WN lo?o lOw98/p^ٙ0BEQm5s79(3R߆cʱ"ŀ Bmح` mǠ E[Pm::OQmBi4@ZWXϟ[<ҔGX\} t)WZdŒᤅ~s-8) !nsB!B!B!t. ;&ڎ (@3*R(TEM:/SP qEu$v.o T6 [ai<:3M{y1_(xvׁ68̹mC=ږ-qr9J%.Śn:gn]̿x5W_ 8R`iP }˱qz/)_`!mOB!B!B!xR}]ƍb=mV]RҧvU%:/fF-H]j uhN_eX.p@N+:R??y$=1SnO-ϜmKVmKH3>/'A!B!B!B< }jycS`l?[z?`wlƑ;s﷋mwv{MZ%ݞ 8sy9<݉`\?״g~8Xc]y츽̳3:(cdP( Lc4Mqgh[n%Y(@j_|nps`~fRh>5NmH!,e R8wiS`[t^ҭɋL8P630[] t 1>fTWTY{7лx^ S`ػLXq\v ` :?k07|ϚPUl:{˱('G{W[RodUХMF LV7qLN?yS01\_fsjCQMV-ȪetBۑB!B!B{_N5*黆 Ц 5SRY `YL ! t:>fc~`0%;6!K ?͊9 =HB.105PEMEsS%AW:l(tF0~f|.), k\8ܔaеpWORQ C΅:[éGV70Z 0C6 v,bh;Sdcg*xۘbLEE!wsH` xg Ag)t:^}c q6b146`y!E: r)ͱT_Ņx. .O LmS`[\] }:)rC3cv#A!B!B!B-Y9nM.<6O|&2txo'a|rNptSa(IAi ӵ9 !B!B!}˜ =auIghS`amjk| <C9ZBlfrۣuGؾIkR ;][ .# u(U*O#Cލ3v@l[TE^@i=urF#uH!a2 i @PTNoZDl<:㭂ÂKYyI7&:]\T$(\ڐUQ'kyP8;q 3!O&B!B!B!zW`rM(,>c0LP\gd6J r\sZK"8 RcSuk"9֦&lOHw )0?Pd,Td 0%eo=>MԶOK\m; uiȧnN#Uڗ\oVSBWPsnVowCP IDATS`2z͂zgWyV-mp@٤Ͻ1MBh;}B om߯Uma%?)BۚB!B!BqrjViUwz|f(2Fͅ-}36q]xɠR@FYT(? YH/J u> ]"g,X-PlCCQwPdy"/R[! s ׊x~QQ}c, J /6D+ Vp͞  8R8\lwDY;**[VUZ@jT˒nݳZ>6v{eXyҍ!d;0y"ץ8\zU,Άҟ3lEzS[9!})9B!B!B!xWkM5. -)`gt!COı0LVh0:Q =B11f.YBy η6x{۬7FMQoEPecTqfk~ 袂bSlߞCc ~":XPc @^.PQء@9 mcxzBt  r)+7?]hq[[sA}|5~*/幝(ɺ1Pd ߍШ=]\ 58auN`xB;!\ l C_ܺ~x.O!Ij9Fa ˷_B۞B!B!B;}9Niu{ nn+ *.;RǃM0@ s yܠZj?7z! OQUA ļ' >΁t(9uIYyf9˚MKv,c(]TWyE.htQc丰ʾ 1+;X,}TǮx)(K0 B+0%y)˒\+\}? | yYcL^̝ \Ȁ#uí,e]4 h9Tk >'|7Ǯ+ez `3xf{ujN7S+,*3 3H@!B!B!P@Y,Mm<^ ʲ Ot 0 L6h` N6D.$gz׼9uUZ bwC <zb d4h@=džzdC0aЄ=hlbM:u{̌/ƒȵUE[*|@(~L $`LEZFEQEQEQEQEQE!~90>2&oieeǕU ~HXe3yV2["ZG1Hu9&Nv+v I`^{W x^b?//'>_Qm8 q 0d( f0NuM3q>mՇ(_((((((_aw168D.z^.~ g> ftApe]7 ˲0_q̇ kĽJZ?n=| 7[{{1 X70&+-Fuض qo\_-c^9~G?Kb 31.覀?&ݑ7g#YX N wbcVp#xlGhi7/&0863͛N(|mP#(((((|Ew 昂⥢ϵ<&G?OCΗ>}D^ZAǷĒd nNaӈ~W7BT#LjT !ZqZl}],GvsE% iaBtƁt`7̓0f>>:O 8p:]&2`:"|%P`Fi{` G+!-1d13EQ6@QEQEQEQEQEQX . #Nj7pK݅~)=twHspG 5paBON 0wu!΁;V֜4ݶuNl4PJ%.Z8'YAU"Fr q2;8m'RL8{swh/]U<I=ݟH0[Ҿ!Z:}ZT@nHu`q^Htj#Z0J{J<9m9`\%K%^ޟ >'گGo/^~fs{o>i^_,l/oCOMF H 6f*60EQ6@QEQEQEQEQEQb|6kb]H<ϲG{U6V1ϡmHk]oPM" HTkNTU%SKe=GHӚI v]a@J&a@"Ț4>mpod?/x&g[էoqwGR$O,w"" C!r=5KBBރM*ºm[wxqv@ *'stE!8(0ƖnXWkRI{?c0 7Dϙ+2q8pa/F%[lbpMHk&ĉ4HZW8x77 ıCDzˀ8 yٸ?.sYޞȲ<no/kptRđXp#y`* C%=Ik`vZ m${{=jpN8>z~|\çs6Ɓ @cC B7w}fn'ZJ !x0x'3>`EZFEQEQEQEQEQEywK6aSZXüj ~?k>:_k8<?oxnf ݄hޯnti9Oox`7F qwFQjPEQEQEQEQEQw aڞf"jT [jC^y#g}!4Y2Go]̗0QG;ބgWԧua-dq`!eC BwV/&$Gyj|m{LlE&kSBM1vi-R~FdGu!c[Nk!~ϬC.\8ǓYׅX L<a`SC2z_݆;XE.u}]7 Cyi RG=xbt3@=>p/Wޜ *o'˯g9 ;گIj!=J7 y8Iz@TIw/iч(_;((((((0+uDq>|$L䄄Z 7]{~Ʌ"5HS# 'x{ro/Z)*u2ōHI@k"X Pv=w:>@}$R'1&g%abZWbk!6|fN@L1rf=FUG#UpH4+#!N@b[~qC]ʶVx(OוbGn`\OW^tc\HU ׬s#}7\<>էoys^)gtw<=@ Y9&Xn(Xw yǙ09`ޖ, UܤEQvX]EQEQEQEQEQEyw t `o} s %gJθ*xk=_6<"He#x #{r" 8~ݽ (_ՀPmBڣ9ڵq[g"L%кx6RD7nc$Hòfr[wxwS`'+8;a0|@D8c#ExMrRRqnnzFFwIvoaq~bhHJ^ `jYsfOaI m#8!`ZK.|94'-SL|i<peKD 6W﯉{wW@?χp惸o?xh9񐡬m4ք+VK=i͜]Ƌc` dT)< PEZFEQEQEQEQEQEyGଧVk #B7t/~~ z!Bs@e )U/ed6`4 RYyz|',Dp!"TÒ{oJk=R3eoPiHv5TAV$'ZGqo{zV g=RʹM@(%j,n^)ZĐ8Io?m#UC  XI-ioNWțI*]'כ#em?b>і|`d B=m솀ן q'87߻$agEQ@QEQEQEQEQEQQJ<)DW_s|tO۫;` IDATbHaE*覂$?saD+WEJx*sf)U͓J5q?kGt$'k#ZM䴋D#ko ` x'19طL0uCKRFZ~Fa&q5V8uu1:J*l`H 8.8nA'KN %o~ʻ例.ic3uaS*/Ro.B} |DJ.*W18X%@nT?bb^yV`RD[XSL~&Q5 FN ΒegYJw.PMւ{;_s@ݫЈ@u3U2v׮k-s >y)ޑ\`5ަ`б膍1,@{h!m L"]WE =͠Z9G\ zy|] {l@ض!L {YTpOLa`v |1Qhk75?}_?& `x0zL4-xܣ EQ:jPEQEQEQEQEQw6R{+#f̥BX|JuM`w3AUљ=rnNd ȶ[ R 5aƺW[v8?cLgm Hւ궲k+" `:v6@sWA$,㚉@%]qMbR r q6aDr!gG!P M !L!駜FO6w8"kHC`h]qeYIJk}37Ⱦ&"~9>FD0 8K DJj`M5+업E&^UkB i&f.H3f]%,i[~ /G.1߅fՄs,ښ>tEڠFEQEQEQEQEQEyGH)%)9r"jGO){߫$ OW1T#,f1C 0PERܸHKcsܹ\ ='jMH۫ӛǙš*oWHޟC'ec LVo1351Ĉs@7d<ց5 䍈s]$Qkȹe:K\E9m} iK>}G7r$1e=߻q߶~<>\ k+Ჯdjnl1 -"l @f I'1L m'i)$1c9t~//ߴwGnφXOo EQ@QEQEQEQEQEQ1~pzxMG'iD3r6.f(3>JWy#}0⦑3(vJ^N4,WZli(TS"uڙH%KJ!BjA$V2@uoWJ-%1!Y$xё]on"]{gqΓ{;=J0U({;kn{0! u!xpo`]>^Z8~/ o]#Du g,q:=}$w bIT;LyBn1xrKdpQ." DvCtȐVLJ4o)¾ϾY=Hkb83Ou0s9'=f\p .M>[w5B} I?o<-L8F MiЯ6GJG.1cZ߫$F)$7?TʊnRYwՁsԒRn# T(`KOp&S&<Ϲa @Ou⁖RGצxl1cf@ƂtqpIL'mK@$YEb NS=ع_!_D&@c &gn!i.2#3}?%[^h 47`et Rk(((((n5-$ix-a*!4/5?`|'㧑\%.@s)m(:yZg2HKK,oVL[#x\M=M ]LVނ$R([o /.wi$ҍ },6'a޾ l\M` Pk 'Ѕe," Hꆩ`zANR쮋C#햎lhM2J@az &r3 C_SRnoW\$^&OS1bhLZĀ`H8XY-!ZcRbҖ#7#i1q34u@@ׯKq M!Z,e=(_y((((((/)`.ޅH"3$ 㦽Q#. f)UIҏd$1=޿k!RXj<%7޼K /w R8ܽ蟷6hH^`Z@*¹JAJFWӫs@{okUK,21E!D_H0L 9Oo n,{DRӓ vzD*Ӗ`Pk!8z2]oiʊ';lJNR.tY{Ķ%JI {D*R ?]x[Y].`qΑEB$-0€ɼ2̞d9cHLb?T`_Ö&FB[=PJsC]H{0NZS2IJ~~;P嫍EQEQEQEQEQ"3g9:"2.1@Ż%S`j14*R)̀48KXĒ*.~3>3d'r}wD7|7oK=9gZ pЋ8HލB*oFM'j-X)@-5<]: #!:r-w8b] U= `#>n=qegmlAFhċ J{ǂ&<2FcByNBNBROIx?S!0pn p:=0 ]wYOP*o/"r3,v5@E05< #Is/》#1SO߮2fm7˱1jɤGFuIrdo2@T!t >7^L|P#(((((CX`4c wqΆN)BuӞ R :|c~g?P9[Ng6~wmnke-0F&2p{\~JR"$j*l`[JB\GRQc0} C@ 0 ~Ay Ro#"kW)ja6RX;+x iQI4 %lp`K0ؖ#oN9% ~G>?J w\pO&B4nR lR/fE$#-9$f!1\>_7Lka-7!)wQp pp6 |y 8-y @{@{)ovR3"= CQ嫆EQEQEQEQEQ " b"R[5|ynB8)öR5$ܨU(:3}r?o~ϲ{><,+?7 ~ooK]F|@ H!UC=64V">\*a_s@"uV@\ yx[gt8Wv"Hn*m7Լa#K:s<=PZbsn+{@yR s=(6<(%18ΟD_ 6Re{ʶ^ FJYI$ICv3$]79!qˀŁ,1 ӓbe˰ v?7Z 0sM #yH҈5p{5LL|P#(((((#{ߡaprg!PU=WE}xbX(vbt]+ҫ+R|DZKoGIyg}exwo?]7yηxieԳ6"P3Ƃ5 `ʈ+֍~]]:o~7`ˑa篒u2d%a_t*O>$|7?OY.M˲9t34 7w9r/`-#8R2`x&oHNb#K־uܮk/$3O"HHnǺ]@ ,cpY|>pSJ"/*+9^R&^۶'m`EZ"L<`krm䄧FC%`Բb}?m$Ƌ1}xiC}\z sYȝ_It=t6CZ3n#dJNRG\+b0nfrb(W 5((((((;Bb[]s2@]@qA.*gb:QL9]-LR s/~O3{[-~cnȷ'Oa޺FB& ,xg:"1Uz*@b( jbp8`)0cR$Үa;Ìs5Rra-c*| =>ϞON3O]gYjP<;2. [׫{{F4l^S`Rmm8J]D PRkl-h6bjBB= )[(H9!9pʐWA.-[Gw(}WۗғrOgy `(R 5 i;P+<֏2VҨ҈ℤ9m80 R*즀@ +6 ݰ5Wi_nPr":5֏pC]7(|UP#(((((@z<%`'eݵQXwI\FN0CNYgo3?_[_W'}~cO~Ñqac$93wFDfVUwWdwC-$JuX[ۆ<;1 ],v;{v3C0|),˲.J$}t}7?"2ݔ(J"YEQ<󎧝xxӛ1w|'SΜ>M5LJU4mD"s#!}NR@D) 1jAXjٿ=l ~i<)H"/L)EBH!.R(mj OSkbAI rBYgii#V$;hZfb``RW,Ԣ-a<SZ|pԮ.Y@hEZYЂ'.W8gإ !#kyZ@Rg(>e Ig,$RX?m:}?oϩL'2L&d2L&d2L&ZP[b@ Z(&NE^N&){ɉ=W4l5b4&FÒ]G>^# ,zm?aX6!-]z- %I%tޘMEJ}$H`mq!ђ&MpaA9=11Bƹvf xp}:\ ^!@It:ƂKPnl1)U ie?OZ2"!DRGDe! >OEb=/ FHtRjR(D.uw";V5;PiRL( P( HLZ\ ;@lWHQ,ISRhHDhC •dj d2L&d2L&d2mJz{YBF1 c vS^HJHWb$'1ubbYY=8yqƓ|<'8}B|2eYN<ŝ6W@_e'{WbR;74$ Ė$`Ebޠ4Qhkj JuV[QpQ9 PBHO;d:q[Ix|<1E-a-L}~j >i}CtO H.9әbWKUR ZEy[+ӢbzqXʾ'Et$R/bѲ]w\@!_,+Tٻ[--IEc2mĎOK 0 ڈ.uxb ȄO-D"JL9L&s"OA&d2L&d2L&d2fAN,kkcPz 36Z^tu}|zX93Ztݝ"!rY!"8)( P=D&\d#@&d2L&d2L&d23A$i%>_+FHڹLLQY vYVݜ=s~0,[ ,D}`:]xPU pt)ؿoHaJ""ckrŠzhtm<DlQ⒠HQ"G$>,+CpPaL〶iMaQC'* uS"ľ Afl:QM.paI?&k@2H[1#4\%E}R9Bw5m%XZ*k0 uCKԸ6`D*Hm }Qݹ咠 #EQ.H0/t6hei9GٛT>u!L16v-*u4-Hӎ=F0y&s`Ph(Ӻs҈u04H~1_2UM6d2L&d2L&d2L&<z c Q+zg;/Gg׺Lgq; s<8kk)F댆lan x*sC5V4++Flm@@=h]."\(UX5yj/}F& `07BaRj]*Bjx#'?5$piq f`.sSFÊՁ*n`v<`@N(J5 "[]"xH0O"JU'bMRYֆG eY@ <)QiC_-$o0s(=%AL?H|ˆH"(tܭi]A[tLZaN ?sәg @i}{k0#Dbr/\L'2L&d2L&d2L&yl^YC W`/F4R*sw:Y/BUFJ/ ٓIJBT:U`'{b*TDOa618[bp=楋ԓ)xY9/WuʀѰb!^8d:ABQCj!x iRoP mJ5ZUHQXxnк{, m& V$fKu/泘6/#I0\pWث >D !sO x"mg;# . @-Ұ>ǽL[q4t5 v-y5d*q|d2W=d2L&d2L&d2DJVۉIe>:KO  Т:B8xhxR.=0"c|BKA$  Ai|\-66<~,=$]dk,2pnسCkxэH5\jIQ#<]*@ ^#T+mmt}@teRm-:-na@#eͶRH"6mfFA$M E'l4P;$/eZx23mgn h44)hӵJ\).|d2W5d2L&d2L&d2@\f QꈣЉIٙCsHZ:vǬ2ZY}l'A'f5UU.T:΁+"!cH}@5RHiS>Āwc” 6gBNPERKQXyUP$nňa M( CSeƓؼCS|-fbdyaXqtArpwp;-_{붓ܥeV % %v`%E E|(V]B'DiS g5"J/'V. 3m #(ޜ!e. rʄ!@!friŰ &vv_ S,K)Z$ZV!" L&sUL&d2L&d2L&<$; K` E,( 8"! @!Į}Ą :X[[ae`۫-5*9p7[eמV-mQMa' 5J -ZZEsQxۜIKkcFJAw"#Ô@6z4/%xrhݸUU}ѰǓ<%dcT~_̼ R>^z-寧*H222n%<'v xMX]9Smp2 `;V5Yڝ'xav-tf:S MOA `P P;O=o"ʖ 46{3W]o cD؂B픣$)mA!{@:tJnZzFк0 *͠LgK~nX],$ hŬmOe2ld2ߴ##(dyu֣r5R`%Hjo}?ve{7c/!v waM7֬qv,w>t'koE5qV];lV_0g6k6j^M7a(Umt 0UIQ!b ɴ(Jt"v;pP-)M[2H>Yע!*QB0cDi)EBmb$-VGTvKnKXSXFVfxעM6Pȸ}4 0vcͿM-`}|d2W=diyk޽08çfL.'.>'k`0o~u]5պ]ym/[^>y;W{ؽ{?~o{??uהR Րa9[^>v7=ֆk&de`n}׿o}&wE|ac&i>92L&d2 @b)( )yS>S߲{]MBJ#(Ո"[9sꉅih` [gN.__8p7t/=5WҔB`;VeJOx_v!q!hz?=Wb !uR Df-m; H_kc&Ӏ+Vl9d<1/Or?č7#yKnf0l< Ǐ{8~&o}'>s7+|!`|eX[|3$VQ*cɌ&l1Xk >(|_-4C4]+] ͕ĸ|`P2Di >8t PE6ZX00폕"i>-)6> DL]Aג)zQsTh-бOChJ,lЙĠH'IXKEilfs1kL@Te2oJoYaMokڐ+3W/FA??+w:kǗ93WDI=a<òs 8.8>'/{vb\_bLBgenO|Ow:[?q+L&d2L&y!G -P2X.(AVx7!0Gܻal'n <Oh$t`ulPTiK_`:au7`Xŵś24 H%J[oKP{Qxw]E?Rח(Y;eZw#`eW&]G*D= R-COf;y [35];yKs Qf{[_XS;|׻{{b6DM@;}.֔&o8ݶ/[#$H-Q*8Z#cK E辊n9K0OPJ/L-D~ NukE{ m,(hhUHik+ }4i#X} m lLL@-޷O d2ld2ߔ}h쥳yٷ 7\C=Ï=|Oq~re-]*F~?pE#N?G)yݏ𦗿 )%rȏ7o}1p!d2L&|!tE' !qT(=@/;0-1;(u'oM]W.ȵ%tjK58xxvݗj8C9wD!n.Zۺ|VtZylH"f Q )CRe Ngؒ|g:Mq4l_N<%Lb΍7#Y[[ewOg/pkx9}nӪZ OMjz#n V ՞)Ybn)@*J|ḨO讒ߘ3ZޖPE:|."k9 aQHD1J\{P,1'$ lk+b[/LVuGnibqmAmҷe$v-ZLQ'"KX&yLN>|յ1c}{g1OxS\$UU2O[?7S5O+~ 2X(*KUxB.Jio| JK%3急 @|@ʀҺ׋d ]TD) t砖KRm IDATw ,Jkhi=^w!.K\3cPv*~ͩai&ykhƖx(KD|e2d)%G^{꽗33O;V~?+ ?▣:XE)x6澓[-..}O|f٘OOr\w2wm+o]:>x3k~koFK7yQϘ{i#/uWwiz\*o|%}G(GQBHAp t3p>obDY?~3u]}Y'.>c'Orسg7oя}ObkW5g~x:[9}k-{xf[T]lWw3>o@7y1Hb1?dL&d2L,'F=<xע ^+ƉZ:W0ZP%Uosex2c4X<b,ZktS`Hjؑ LE=lS @ |w MPJ#cKp-n FvꚭVΜp.EΝ?mc)٬753FUϰeg2L&d2טY ]*3M~gZa1o=t۫Gt%_YŬ } ̦NJ>Ŗ~^;zh b(iaҖ!)[|nBR%<u?EW ^fMgP20>,zҫ~ 1pm u*tFYA8gNl?5AUqp:F xR1v1TcPU\d7 \ Йfݧyp`_]='-ÕS$A UC!xHr{Ty2(Wb 268Y,'i {|C@bvJi.7mCLSF`e$&`-HӅXo<.xVe/dL'27Q]zyK^s']=7q){1ОC[޵(Ԇc^ԛ:(){1{-w4/\qp ?-\BۊՃ04.o9?l>;mrzu0ǮGkG3Y18t1d^Dxu߮|][oyKϏ|N, /DRn=zGzwE>W]wMu{kCeGJNUWeS[d2L&d2o\)˙ zvuDUZb_I]%u$?rFipiŠZسd}WFgm@D*[o~F:5xQeѺ3(Y ]ԥ뛢'T}@ mS0J$#Ҳ:NA⻛fkKB8žjYac `dƹ-WxrAv}{KcƓَfX {v3؅i`;UU0f5(w@cbhb|B֎Q;6HiGx܍Pʠif;L⹈-<}{ERU n&Xx8ّD }vi>Y ݲt5`JM=+ j `- JI`P1o‚4/z/ Z&jFL&MS#K/|+0G{_'oy=^w+VYT*^;sߧy9!{9{l>ry7FEy/S_xxy#j*^G};pG ;ƹyۚF_?u +̙sBuݮ|]k_x|_}pu]S%wk^pwy+^5"`0pկ?>} 8vnk5t7|^FM~[7[oy1+++!8rZw}|CsY=BuZkTݧ*8f0Owm/Rw+)F@ 0kZ\|`8 |({0(-Stg;97a*Й.lRbtassɓOvwn+ g7m>`:бk!k#EUhX@w&$QY苓dhʒTclr\T(ЄU;+烴QY*VзcpI ##. H$3eK ]uiJkhYP &xO!H"?(򹵥esڝvi]GM/*#9ʠͲElkHaLC6d2W'xeq?^?}?uze}k7!gO0ys -,׾;u8'uQV\<9u]s^--&׬}sk}&d2L&d2ߐ$Ty6ܼ2?R0{v]v^o!kkxK<+U׋0댪^}CwY_[o{kSݿ]O?/?EwCÏ^Jo^Q2OXz4&B#p:7!p)u3]@kwaڴE;/,'J-Oߍ[O"8"cbLޛʒuFd޼zصUc16e,# fFB3iF#MKB B4M# iW6U(ckWUoݛ73#6ȼڨ*U|yD|7}|Pҥx@h!pҎs6\9=pّG l>g:)TIā ƀHЋ{_}1&LNwh5o1k J׈2hr}'ևP"sfο#Rjdn,UYXvL UbHkIj !ޜ4خe@&A(_s 8\E hT t2x\9޵hcC <$[Q ^G "L0fZA¿ƻUo|ݿ]> UI1 ?3d~m7/3*zGsgܦm3ݸ`Ǔay~pO|R!PFKz|qތJ=6߭eeCxϟ:s\s/? w^󵬍u_ws_?G#8ϼg8y??y͝~|K}v\o~cL&n~M~k>P( BP(^IJ][a IM{{[ +H)+۽s`@);Ѐ !v\ {Y5,|BzYTזA%έoQ{]?gc  I v&V:4?^, b삭i' >>Gc]>ԧ?NP( BPx!DbhYD^Go휧eI{(R#MC»h #~>V @I^1_ CR"4,Rh鯴wn Z(m Ek=$T+ꓛᒦ_B|,{]%w|/z[ж#"qGcNoɁӜO]>sx\\3Y}#V;^3ݙ3S'Mod?t7G8vQ&h4ʕ8F!!"PE66Jm<RHMO_ mq6$eD**O~w3"FONhnZ04,!p=3ݥY(mHL 1m`nWj `Q i`dQRS> UK1 BwL{=!:z\zkOeo)n|_SlG//f7[Ix%ѧ~kLv=S_xEgG-%wdn |\Ǵu]"&b BP( · d/* =-wiyKCF%ԝ(IHq! ߻ `]|wOC,dqGZ>?fz5 A(2#*@)NTD L;"$(E$ka$4vaA6]ټS+g+c2Lvf  }X F:ސ $O׹9p(&PY %Z*LC_I+參 [\ۂ%OK@۴rR5*.ƁxZ ( |q+Ϗzݿ#c d=!xb0J"|6VP (i ]V W-`Hev B B^znWZ@(wӗ #_͎iRץ/Ջ53]w~;C:;빶u|S箞?^'Ξ-%ߐ8pYn:~齿&Zkn8o{zx[!o_CP( BP(Hb|k{UſZ\i<$~4(uE=*yB'j@tu_kNzbm% mMjBjVL `8.? M]HJ[Ȃr)urW/:!6H)PJUxuX0Z2_,'$Ɉ1dž,Km`\a{4:k9uܮ">`mdA_g370Y_gt{0\{9i%p mzo;Ç[҃ߛ1 -QdЛ:|$"CkU=ˡw(%|H}aD%#|ƴ5j키k_T b?udw9P{H-m UL* WR7ҜiOzMm_>}\!Q ݿP&̟uYוBP( B.#r@oR:sI4|@Hԫ=|ZCt(mQd(] IDAT3\ڕ!]@$! ],7ژx_rW.Ot~AZ6-VY8$5@3jLەŵ#Uh09M\sP8L-2vGMg-_`6"**o? &Js!*#B+$$ks,'@lXP#"J!fcApFD*=@,'A BBcCZۏ@D b;gyaXv-c+*rZFz*Wto&;h.[P)RB ɧ1lV.o 'ټUgM˞KϞW⽅똔_TRJ4 dȍk/d\/|=sFUUnG{|eyus׻mr}O {%k}54G׏rz;~Xn;~8}'Uu~G?OO0Fz˫ʇ{P( BPx|Cjm4U nyo؊|NQmRYCZlvWE?$ Ƙ \}'N]2]J^؏I|C 'x ։Ի<(T xaJ!EާI "T RjT,0޳3CHtFD[DhԪIK#jF w/~矺s\sw~z֮{;O{vBY'aَ_%t!7G>OJ| BP( ˞ 3 ·+bjƀ; ųAFJY')EUkxÜE\瑂_IaH4 FubsD)|*Z9@w1F%+|*L >HIZ$֎9p@4-;bh1jh.[Z T5F]|ΜɼV pImNط9[o[ouzm/{}!7lvXNųu`\[Sf)kI@1"E)̽'Gts lciP &G*ћhqa5:$ʵPJ6ZAKRQQ9NEn{oBpQB d~C׻{ˏsN,LS~NO|S39t!$}mY~)5#n{/e:9Y?~^R!?N}9ιq2|w_=}?a~q6׼'9w=z!z) r 6ߎ込?ē4#_/]5zc\w:w?+Io8~Y?˜>W '9?;9yϛ| b|O^k?9ܵx?:MFJ5) BP( Wm G@W@fh6W,f;1aGkz?޵^J|K 4EEn¿2br"kh@ը_d^;R, [ A~Wk9]Dm 9|E\a_}U,R3oZcs?S[O0ƃ ӆb:ᾯ^ܟik"9ƾk;X2Zb ZR 1xBC0_I C{Zk6 )̓t I715] wI 5rR~>WZmw,%Z+W_o4!/k k6 6\h:cEvy$LX&֨V(ο; +9ˏl^l#r^q}} -S3[^DUc6݄-OB ]}I~>W=ỚW>[o{+Zi66r-OoGHn:z7~{ٚm5T]҉M?Ⱦ# pJm՟g=-޼R( BP( /gR] -@[NjS\p^%2dXW՚HJg5CS ?x26Bv,k dRbXkJ_tS32c%Nk2_xx)y|z .^5rnFIvq?#># F2%)e@&$ح̑ ј1tg (<@=AXB3'tViM6bx֯:=tMZ|PΨ@2(G}Fnnx PW@ |FccDnu,J1jc<û~**(.3r׽CˌBP(FBPx{>̫ǩ7RolOɯrG~[3z -_.xKs#/՘yel2}ѻyo!Tj^ u <^F2z9y4^sÇ^އ/޿W>]o+7mEwV !\dKK[>ONԕh$bE4:0 YMm$=i%tQ6T(rh[4͐ BDl-CO\iNdV A+CZAEH50]A;lv>r `k8rx#Uk;3Լ2802$(<$E@l] M6w&csL?uO ::iLd_~By%3/.M@)M" W+w*~%R)b$>*!!BQ$JA9?}KJf#IPB*m_2Ai[> UI1 +3_+rݛbuh[\Y~u,_|uRM%O>(O!g_U{Y?r#v#CO=ĵo~7뇎1)Eb'8?셌륜/ ?.66r{x3g*,/!O^O8y4f_(]??q;[9( FvVk8}N>KO/ BP( @^+1Gʟ9֟ӾD,+3'ec.gˈHK7[/4ʦS{3yDxnƢiq]%˭R9- J*T !@XmiCb{X2$#)Ӊ U)Bp(R&Www-V I+ 0?sc0&þIMdL860`GaZ3_a`$JUߵY R~o.@*v[ͫX^V򣨪m<(CJnط Xm!B;eq>E'ce~E(=Z4W> 1@aeDrܶ@keML% ѥSmz-VٙBpxIUQ( W!~wz ke 3rוI fz>2) o .mQ_TswLn {G?7eR BP(d|(P(^R7@UHePJƓ]qRgC޴XIh<. `C쐂Cu) \0Y8c}y i( " i!b9-0RTh[aw'3R / *XV+c1 mBX/ .W*ǻD$D$fFt w:.D,Nz5>(p]Y릠 bL'w@8.l9; S1HeyYwb_dS@!?H#Hl޾ V6eӝ:7D9R]+S܂&hdgNrs Q ǵj#IQHk b@H >'t1xRlP`e$@ JІw-mhTE@M@Ŗ k`$J0k7&k僮P(|S۟oEi* Bm[>~>n8z3>p1zfyassLHP( BPx؋DUr\߱h$Z%U#I{lTr& ϾmX6 K=A(R(c?!rUݯ:*lkBƀdH̙ΦLX,(ƌŒQ W#D=bxjc!DkUy2YSU مӝmf9Ŝl4Zɡ_\ !H\wz1k25̺U@ޔB@s >bp 9hVMhZGO*`lV$HTJceTXU5kkmU5"ʪ;x`tZJ${eL=q展ZhvxSR> UGi P( ˀ??ov677x~z^{/ZkbWeB W=_{KP( BP(\I)&%A5/{^ ~J_p{e&i [0]m3cdwIolݲY>tYF*`hQYCUWUE!0Uֵ(EN|  ι jR(*o@{.~= / H Ϲ!a5AI)B@|ηO6"'cf;[$00Y67 F [Yb;-/L/4Ra@QJ >* e (R]pQvCBZ MRs)$D"!zx#r+i2bȔhhCi"*f5:.VO@WrVih)9ȉ#$Pvm0j D1 B2m[>'9v~fWtozW/|Lh+U?2<0锝$ BP(  xOJ}#Bͅ g1O=C{zvEvlkS.rney ɕӺyrn4.^>zD )kcپ|`Tf!KBh?h5!AV.}X CMBW(]TTJ54AbF5{|p飯,1M\vzA=, eh!BD"lE;Ƶ}TYDUXɨAh1z 5ω$%HP1Ⱦ%@C$*MYFԈ==ֻX-.p "PC6OvRH) ICI@H B.e)-Ac1$K䳩kж9AJR8clQʢD 0mkƯzҞy.(!!_(vB᪣ BPxa9dx_Wȗ$Gw˂}on!۹ P( BP(^6B紭2'\0e2wi}iSzl<'?t,ҥ-.]bkkm+0?EAa66&\kp:4*BĊI%qރ6hm ƨ_:QQkD`X,jPĮ?W;,Y4ҮaD ĀBQ$(Q!) Ǽ'2(+ABp,ZXj|F3 kWچesF7~lmDLjhmc!cK)FІ24aDIj)Akv f~y>Wz{Ok+xO̺0֢D)V յ{TBf (;3x?DRh!sZ A"'d3@5Ůkc]vh cxC7F[Dp, #@P )FBP(^Fw5яMˎ5~~;=0Gh BP( BWŌjms:~+…)\fʪ`k'q xiu3?;,_lWtxnk .M8vd?m H*"W-JI$ fPb)8EJǕŨ,[IG{j P /!b$U1L:MU;S܂{* }{l )WUM ^b&AkFJQWޢlE+$Ի3VM6z_AMKB&Tc*ݛ UG1 BP(JbiBP( B{~:'5({5__͸\!]^qo8u.\8 llLښrҔ}u/e@b;b߈ayyoӱ1M͔Tm$QOκ1+ 6 1DتjZ WAO 쪎72TA)Ciږ*$1hJ-Q(bB  Jckk-VeAlQ|pm3[VڠAhcE6,#-)"1J]AXB1@nI1 IDAT"21 mֻmyc{@॥TwP(^W?W&P( BP( B*"kP-z3 I-mMolo߱jxop6[. 0 m&Lۜ'9}^s-[66*0 4.DLYLN0-*Y06Fbqh~) ףq7?-\mDK;ByU-YHDf X(h!!y"@Y86]y-FKLZ2>{; +"tz>Ϗ vH,S .RB(W*h\C"MH`MmY2V DB4  b8n,@ E6Ė6(̢|/%;A4yV2 VX [Vb$!=eXZx,{徏3^*|B᪡ BP( BP( BPx ЂHG=O3ޘ0ۂ` qO+]D />^ݶ7 vgN|'7V 34B7͍ m3CIԕ4>$T1`! 8|Hfbb`T+&""@\҈4Z*| CybdA4tEhXb؉RYRYb8D IPiQ cc;#,hBȢ (bT9cEJT$N5!W= '0 \ӽf4lJjE?pflM[CeZI4!!RZ@ϫ=P^ ) I*z tƁ-mQdShĨ2Tİl '>EJŪQ7-JW(*P( BP( BP( X: =^T#8SS.Ɠ! `5 />~'amttiǻu=ƀm&a]L;_g}]c?jٙ9 67`uV!*B=$4R'iE"!ZBZ&,{jv.% k&E-u"3o\UNwh@6xFF1ofP,fySlH-<*]xe}@B잹 rbj4uŜL5acc񃁵y̲f6 뫼g!!B08aDXV @Uh%iۖ=jqIyЃ5TZg:Nl(Uq`c ڦů;H & m eBgC{#u= dWzՋlx v*mM* L1_tyAN,ui}F&ߵ|~=Ow@"uCsl{;HM&?lz^ $ >Hm | 僯P(\5#@P( BP( BP( /2a 7?fe峗vVӝs̹dЛg2Y5Ǹ~mQs[YPaG)ڡRk)mKkvpu! s܁V}lھϼm~_.OLWjF,K3yn$. RJH.l -(e0fiHRE024>|dldtB])AaMO , `d_R8$,h ޣT!>Uuxr{4\1v )zR~R0]k %,p6H<> 8$dߪ@D$7 hMHf]CH UA1 BP( BP( B"#}d޴,Z"Oth~$yx N\?A<ջ֝;QP0/8x`?eƀxh0n?{kYw{Yk9j6mch 8؎v."LlPB06R   $#H!f&8823̤/U]ٷ^TI{u{޻9^|PbyVz{籵u.a%Z7xM"hz:h[mMCvU*3t8ymzq3d5J'R6J3~h^e@զ@e?u]SǞ>VH!dCT5zI^k 2mۇTU2!;F}5q.rbUT(ﱶ˅aw8n8.c- * 9(`q `ƌfmf˘~7W@TW@@p4&!(RZ[ d /S#>&V5uP2؇p$P/a6JJE‚V4(/#      |"bZS%BVDKhFͿ𹈰c=hsȫk~'jgf9 `Gv87ce}#෿ fc⣨f-W6S (B4@#D*UiC@<!!Di UX"0;Jե<+@0D\upL]>ܥʠ0vb4&&TOB zv]iyvA ST]c4!/Oe"E ~?|ozol#񕯺Ϳ]?9/hea1^__',+   ·Pa-jk~fwpl.ڶel?x7v IGAٶS 5\mi׷d2`ͷח<{IoXE+*tE1NX]ckSebA~lrd5Y FRغ.  !r@S<-0CA,um Me|Cb֊MTX!~(CD,a!\%1 J!h0a;4:킆Í8 TqԔ cJޕZUGq>ru& P`K6T@*:wbA%ER5Z1S~ }qsƴO1 ߲{7) Z˭?;|f/-   ·4_/sEZ5 G 5Vc4;,W[lOڱ*?m7kp#Wfe> U|n`u^a M".Z[S=!ΞXY4E|ٰ43^!$f%hT1[7!6-eM~ΙJW]ak%!fltT!D0j q,)zrh]DJ(]S ȲP 4ѵ4v UhާP؜htSaj 0bSu)@Sr9r.ת:^T i$Hc M0Sv;^O ’E,0@ 1*5| #FA=sV۞R@bhCI8@?XC@3UϙHO7 j4jj[1xptǨ4M8Z65dØ|pW 44UXΎo| #FA=o|Rsom=owk_Z>+c|W3sw*2    |Y7~$]N*C'M`E"p99EcP|2<{>ߘƎbhx72\W.&3$[t݊ Z]nXm-@RuC#PMD-Zb$`],;Xzϧn0E1!TumӬ :'Č0@tk14?bTj.7,rNTO*NL#.TtU1B'37"XwrO'zZ:]Yhm*e)M>&bT6a킔܉ ňRQUMHZ@bxYt\ P[t:SŒT)RO?_ bA&^j}LO}ힺ%_K>q3$@AAAo]>q_ f=%WҴ+vvp4(snۼ|v_߈R? #۽ pdXug0{HAl-K"P02Y_&F+ QAzP$VAN1Pumf谣m[%u-4P1c z' ɖˑҐp@FZTO Pgav裢щQL% ǃbtQO^ caNU+rQ,_ |@Ay~-o?wϠU+?R    |KOt䬸 by?;p⼦ts2(sr5r{׏^_rP  |UK?~طE·~o+eAAAAKKhcPJQwwO-ayw0 1י mb; v=o=<5nYfq nmF4nYӭ5 WUQhP*h b&Gw40$۾ل)&sCUFAmQQm۩CG{"jX?9PviMiBEݔM&4~㊁x~J}T:BPɔSh&fH(kE@>%7]"TZiNTD1(Q`r@r8R c@sVxBaA료v݈ˡ2ŏ | "FA]m?U-g۱   Kt` 1&n;_M&(rg&CXpy`d1q/P0Zs]Ӫ7"F %6ӝ]cI 8y}1Ls_gR ??_ 3h1hvD!f|H*QU_JR b '܁P*R7$?B%GRqZt\Ij &>G3(AwO=,>f~xߎ/~wG?(K_   Pc"dJE7.֠|Uq7*;~P]l[-&vngٶOMps6F3x9.:;..Qh}c<&Q޻"Z e^XHQMk BWPD`"lA!w2ThiՖcZ^Ǘ"B߶Sy(Ǫ?ʣb^ipL\7\J=-:\ h@1 MWjmN&McZh@tX}eTub&VhktM lj0pp* A"P9B˜!2O0Gk5>W*#g8vzٿS|| MG S}/-XK\w IDAT9%   ·&_'}<=| @8 wPy-q_/y8(>3|+l6[6!'ƀQoōv?N[ ٍ}6]7]>y"[xi,V)歩'd[33k ph0FSѹZgݜD{O*eiDQ7UScJ{=* c`8zz="Ĉ˚ hkMDGFRLٖcJ@ f&)C*Q4;TJgqx)fMSuMV5ě) 1öcAo:bAnP+xX?;|3UOrAAAAt~EcS2vfҞ' +KBdfvvT?+_y+6~~tkv{x5[L]Oo65~0dSS ;gY/ѓsޑ>*Z0ݷky^\v^-4}4O]}b%B3C@Xu\O1RHUƜ8Іy0]&OhZiX)ERL@EH)c 8, FP39@5 HZ+:T'>CZBpcW Jc[p1 :  bAn'~Y?/l;Vu^E    }Cs ym6GߢA잞89Uu6@1meMj/VVhfظR@ (x'1{?o%hK4Tb1h$1Ae1Q 2PQS~u]xRl:n(H0gnxxy?I l<&e}Z @1ۜfGѿR>`heL5ǔdd"#99THޣ>PݶH@1=ƘrISܪ4bq7\5ۢ$'| FAن;]/{ 7AAAA}UZb 3~Uu#mx㘋 x?LFnٶ_!`LhZθuq~q~q`u'8 ࠫǞ3TڗjpK{618BILp|X3ib!*]nja1uSg$wlJ2 ,uή6nI_'}{ucn{;gL uUY>ɸP]w! `X,qf\>sR,B| #1)1C =$d E`8T`(Uc`ڒ܁=1zШ@H@v'z+c1K cѺ>;i uMm 1ea*Bv`]()A=.$wjeW>z$/   ·'@쯊]д Yw:m.~eױuѭ.4^OX1cӒFmy{|K/=٪}j읋l3e?OwS<}&v5޳I"Q`hc@֖vv>ia0X`q}^C 3u?Vvt.nU:m dU7IQ$ab1m I-mɹLpi`HLb0ndP֚rE,0fBC!ys98$\"j> e0ba *8GkY.j18Ȫ!TTɇ>?*_ |ӑDAa[|_;S"~|enAM|AAAA툇z$0 I2fWI%s.kݿfGx07<.w6pj ߻{ssy9[o>Rknw?tJB(jw4vR%~>EzִcAz*ݲX85劺iVsznq;SK8~w'㸧 X_ml5g*}~L*pL11Z.>]ĀT*kA{*jsnU8!pmyH4*5H 7 ^O1ͪJ21M1jhpè ]G'9P k@X0:@"'`s) `4@fe1hJ%t*_'~AA!FAa3'> ?g6\ǧw_}/e9{k_}C&^AAAoKV*sUrmK:n`s(֖~MfqЭl^}U./7tgg%`0lvnjpycv"vmcS Vyu66)epVvQ=E|@;4gR#B1kRN(_]cmϡ_Pr!W+[d X?3`W{W?.gc3ֳsME\Z'C34:g0 {R*kJX?7@18:VOq*a2'ih-0b&$ƈ"*fTYT.Q5Dt$]GHƇ<=JW3*!LLWx b 0&[eYҚ=HU||S6$_ |S# M:]g^21    |bwK PD'B3whLJ,KHcܺ^$1`:c8vq}[O-{V5@Ylw>}ח`=L憲}w^`n˪]r})ޱ~uqO T96U$"kJ(ɕ4O6Q ?7!L.f˟0n3 3~J`XhUщZ!Fht"dM PM9Bdܫ=JksS|]krtH`@+MLqJ(/aLʩ:T !RQ19x5erTtFB*9QEGφyjcҀ`*6h]%'Ɖ=@6KЕ|/ MC W_s܇o/Va?x\=UOwVR_S5pAAAA=x؁Wĺ7>q⼛_qz;c<=4f-B<iiWcK~uF٪=iЭړ4_|'ycejy+oqqq>ra`?trM;d/;B{\ܾ5%Ck4*ʄ0D+ )lVlhKk\>&|5ݯz7ݿ]$ Ӻl𵿑 0<5@gWhsڦaw,m{|/]_bcmC{&I m02*ÇVpf^1Eʤ$(F (9m$ðDsM-–tjΤ\n!tuF3ӹNsm`@?ÅbȪљ̐r|S?/ ;bA&_~+?2ϝ??SAAAAxT5ݪ&VE*{'Q6sq6ǏYvod$3M>cU?"Nk)hJbx~^[ƆW_y>6#4=e'ϸXd mɪ3wJЛޒ#%0 !@aan1pu%~ilns!\\?FfX،1[)61AJ)`ڷmv]g\q~{';OL Ut FkT1|D EMѕt4)@c-ZY14Jc/FA)@Axx|/ o?YL    ||b-~buo0iu$衅\}4`1o$μ>f4c>{bj0O&uK^x[Ф; ͋\,ˋsbAJ_.=nĀ5l58ʁJ1c{@ŀ 'hظ% i4vݍmGb4<`lX،щ>wc2( |b<8 )أs eNkƐSb14d@" 1LR+T QEIAcM )c@ Tž0p`‡t6k`Ni(&+F'GnmxOyz[ᛂA> /9C'^l   -G~OOiszm8oZ]Y>>g޻FCۨYhX/Re?&VK6=ݲ`?LeNmmekpݧǘ0\\OI뜟wחtJ}f9O*mmQC1MV+|z!B"`MCH Fe sH|4?i0F!1us2fJ{|2%9"8?ͳγ\tOǚɀt`*] `$*f*{Ƃvq!Uh.",pJHZd\+&3Gbjɑc5,P]s"$=se9x|(]3NEP 8]#U56%nH aL4m%ߓ |s# 𾱘c =mxdAAAA(}m8;^U?7NK+$=}Eqy(z=O?xmU;jt˚ޙw݊f{Fݔv aŗ^^oՂ]!_%gxXXcxmA>Ej[Gp+2*EÐ09Ә5~ДzBu˃ۙ6~x1it"=`|m ){W݉cn)s!{S+H1ﯽ2Hnj?@J rG$E!B]@Fu&FWY{D鼇s^n福剑`:ԃ=@1N>kq&K,L >YjsosrfHO/(˃ ^IPcj@19@ZOqR@|x4_C»0u%9ΥUP錱 rT*B"ZYrv8@c`? 6s*EZm߳ A7W_|E&KAAAO_/Q m]L6 ۰fx6?g436F06?o 02.,Hpߎn<9Ew`_{u^{ 3> S{(R-ˮ:-xSB`^ɮ*k߱{|XZd])CL|Dnp}O;Qx秔Qh'Q#c|Pahn tC\Ԧ̕ 46?E;0C}U)rN*F@ S%?GQ䏱ZML)R S5E$oO=V0\[¨1z\HTL8`kK=5(|HT(RkERu Ɣ S G6s|h#FAAAAAAOtMv4v{P$CZ@5hMp.P*a&H:Lj;n lD{O_{tggpqd9I[6ݒ-ܒfHK-FJ=}iNcnHѡuI}JzU!@LUS"kqҿ.Fb:U:9j1z87& ܩ!"ﮊ! mMI:A؃iĀ!{a3_i1RM;DTF㓥JZ`S1DG"qhQ0(ʹTQ8,r T}$'GH~L>0NJZ2E TJah3Pٗ $55Cmف5`?"4F뚦 hU.*9ibrap4X(ȪFҪ@kK=!rd"DE      NEG*C/T7/%@%dE z|9?︼sy :c\~8Ƥy~Z6=^sz`u%\ϋp~qqǿ_sSYݪJ|G}I9hT4mS:PZW(]]a%USy*l4.r AVCY+թbU(F3r?D^P2[xB`*{b76cąw=O6ZPR)`>8L C/ai~v>i0\Q)3 2 M}2S쇒BmG3@ޣ6 /@ IDAT0òp@Za\,hmc 񉩍 qJR7EC"VEȐ+C      KbC+BV'? c~(] \y\nHګþD34[^y8[D]|`2\gT?>3Dnr) `5Q??NZvbX/c-x+o ڻpg @{w'i{LhcߗD;TUk">i؝ROԖ)U1fBty%1ڿ]h-.TDw)U H>O8ɸvQx5Dc넶Q >i MF!೙Ƴ Ӕ0Z:RMkt.6X~0Qcu-a$@Ɩ΀I/> @p彠TZkPz8]- ’uFC<T$<9x1\+IT5R4m Vk ZW AAAAAPM~EwbLT9=ZC;pX k5GW<΁c1i pՒvg1Cnd9c jjS08Kk\v|r|@Zíʵ<sqi0dCc E_LFR"QM#J[:VW0 q}>ы Lga3h=嘣!@~&(qӼ@H屚 Eߑ}5U9U"NUJ[}PJat5Qf PQ "z=1NjqJ+}|*-hr@e?FRNZ(]S}iUah8NƂZ_Pz4`LXVAeJ:B~z4(N$Nz1]dA %S      {?S8Ǜ@?!Ini&mm Ϣʇ$[IXn&yˀi٪=I lwt4 `Gכ-+6WWl7WWuO>/y^y z+>೟o~vwݧ.ێ8M͇:K =MmV &c*UL#UkI9*nqYpIk(|<ߗ4.T')i`T?=iMm psR<ΕOհcz5e\UR,jl 01͂zt% Q4~$Ċ+R{biLcZp>Cߓ{R 8ʵ O˗        vA/hؤ[t'?ѯt ŝ-@Z&#̭<kW:we{,?M꿴31`I0h ud.k..^~jJkuNÔͯ$Yz;_4g#%^mBې`K o0(xa‚mhc+ /m”-Z61 6449=Ù[Ǔ8_^Uդ@Ȍ'"ѺC]~ymMiWa{] YG|(HrӠ`BDDL_33Ah5ӳ?u=!~!Sygq赶xgBksmRO,s 0@ g:Wq@ԏO` >Wk)DZ8wY86zUC;| B4:|ʜ) 6m&c HN A     a$rLCiipVQMU}sACGw-?m|]l;6YRS87ՙ`IN̜~٬wO>1`n pyu~|ѓy,)՚7njKc"%R_z;;zE60a <%G3OK_9b=8.;n=_55}8ķMP'i` =>x85D0|{+B8qV3Dr:ȺvU+C 1hFs⁦pMK9N(Ӑ84vu?hO10@ so{,`Z\m-!frJ;7S *l1@mU}m,F9!x(e`t$&0o+ǨK*A9#FE9I<{zdfMwy43I)=DCBi1Au-ipL0.\_ F~$A!FAAAAAAxOZb  8v޲: Mhlf z֌+^<}T>sfsizgmwY2).|?0 #{f¸e`a?&¸e'.Ljb2qC,aTIZG#RmT'T@Α9RT٠1ըڣ;EUS1f|21&A5>Űͯ\CV3c-"W!~s1㫮\o2'2chCL-@%@,)L*39kj a"EmOB3CTΖ$Hcʅ1?MkgДy2MӘ3,i5SS@ -œJh C     '尤DC@*ekvuhcDl)7^`7@I,賥)o P)ycVNf|q9 ׻"`?=gw 7Y}ac*-{6tz>ny"={O [= c > ƇI4Xuu_굁PD_jZ@;'{!`l N_0Fs{ t pbT8Sh`KH,\,UPzӯ (&c;L581U*8m|Ȑ'kIcl[ `q A 6em92MI R1q["qhQ)SA3̆,ZYcq6=*if[Mw~7]c Ϣ~vXJ1|d<>e{(&<3!$y@T˸uK 9|;|<, % ޑce,k!„TP;MC Nմd$2@΂TWAbAAAAAǿ|򝿃T899 ి# ׮h"i]60nixm>6 v_pv==TOSf3zsmgxax)v>{\kÆƉi(PX2㡌_m[^{Yidwug1Sӊa )^4c@k V[c(I =i1Ҋ-f&A :SI @:Eȇ2жA@RCF>ьqArHb 80;s1m^% `yvSxb[5Ē2>cc0C"TGbjT1|LUE/s !K5cuIoxx9gfB `()N) s@ |%~oKǑׯo??ՍL    qiW|4/gwЭ.8!`E4 aՃ7/yz<v7[bW\{?VoVzl+=O>esQZl%`qUtxY\􁾿d8=>0Ӓ` w{..̫;ÁֹZ]qM!B@t]nwxbe;XcƉäEdӶq]ԲcX#nؐsď)&s$$6H"f'!\CN7F0uA61$- NMXTH1-">ڢ^)Mӄk2K@JN+Gr`b91!r\X)k1 S.d$-B,~јkN&M/٢!q$D5˴]3m!)ϿoϦ 1A)%zi^?O2A    4dVEX~r 8bJk-͞3O_7/7ڎY?*źS PN٬}>w5}`fbY\ãNϸ)}{L\,~wm:xH{g<Ɖ~5b0ƠU=9Fltiȼ2%vrΡMB [;ͱ?|41DLM!"R]?)kYuq1bi>jb(Fٔ`&d83YmH8TiP[f)gu|k NڸŌ0s$h(5Wa9]Ձ`pֱ|>e'1f6?IiHQs'^U3AcblԀ Jaud`;OEOǟ='K/1UB"cK?p rիWg۶]|}KӸiZ\c}:J>DD&ڮg}ymCڮZCJ>% $S_)ڦAՉ9R(O:׃Iej/ 5ݹmוREW \ӒR"D#sg9' D U)ErNXcBX0hV hqhUف055]ePSiPG+PZsJkmYQR Zi@Mcے(,P ƨ.gQmJ>D6We5Z9>DrdBIHJ(c!ͪ@ctSSjV4c@kReA4 )NAX֥hNdebZc\W -Z)')#៉{~$w 7? AAAA 9*BȺT]+i:s7H m%@۽U `IK),3^,m$֛ 5w~sŃ{5WruYϸ(sR߶en5-פc4진ǿc'Lb%kZF' sfWa!SS뗯h1pnB|f̓ndX w% @G'Lrk̼Ҥ{ܼ=( Á'iCKrMq{dS ?{'HiOI1mSDL!\[i{&Bri$"8hb4-D6 1c(W@at&ƺ)WJS'1 FRӶ<(FB@k) eQD)B}6Z[BAv=!DHϟ}|>_+Ζ6Ep5FϣC,WGKNG) dP *,!fL΃ʜih;x8xf lzܽ|_`49pyuq4$~1g8i:XeTeak=Gض!zkJEϓ":$Hk2@CbR ֠&b1oYckiqϷ -5l5l{ulU@XZhokմmGם?{1NX,W:Nv8]4mE!.ڞi猳 "#      P*Q4 ւO떊St~ZlS`v>\Roն`/`x9Λfn`jhгc՗) vkjBO#p| 6_:fœ(0x6*|4hCJ)jt& ]^C9fJ'H( !bOfG!Ҳ D,PYkMc OLUfK )Y7,b~S1*0GQ]5i$[@y2X3ĉKI:NB$9LהypT;&jMM?@)/ g55gֆ-  OETxc~W~S~,A FAAAAAAx̢kN*Ob}%iWἳXkYu I;j\ݿ>wPmh_5ԾYݰf &Lkb xQYRLbɌQLBL cL--#8b ^3 ||[o}aE`a# ׯyGpr- /4O$eT"p6C4@Gۖ40\KZ|V11wX[Lt?1si-&6vI/YSTO"}r<-c~ܪ l^KOH"_%!*M+R91zjrme%b1guiT.xjƽ1ׁ I48 #=M XkbćwG/x # _Yr9AAAA?w~Ӯqp{]!Yֽi3c8=Ҵ+\=|[}v#P.rw(zMfX3GS)X^,M`x,C~|X9Xk_=xHjX (5 dvcj4a{w{9? Y\o }[Ǘ"H_]t+6?xurnZzn?%ŀsU{-Fq0 Dе:>fPIҖF緪ce/DMx&ҘDT[ 4İ0TC&qMl 5ܑwT6X O@f<6 305]bC01LhҴeX-4+"FA ÁZ/?IAAAA?|gZbVX*hR ð>S>x\ad>_/cOSfCiZT`I8|^7 ^f}__}ȷU7Gr}7n@mEܟ_o?\7m$AAAAFx=1xQhsl '閔"y"~)1FLhM\\j mg$'(fvp*voQݰvU|=x1 ÁtG|~t?\=7| ϯo;CI9(UO_i+n"OXxSRa䢿7m`E)uʡiP9caU.3K m%q'\o֡C)OY9pb8M֠tƨAA5E?W6=1 JeѠ{|J    Ÿ7J*@IQ4ƄRUP56ܪa0S1x'fcXG2Pc٧qW%`r_;~E* ʐ% &Mg]x}߱{vO<i׼+fuvx|:1-|KYR'hi9maIQ7W\ai0 e~¸ !F+KeZmdB4Al1IX6$`>1j!+i'U )f{#!R`&5Ŝ6qEOsC"R45@g:L Q"Er=5j1 8@ )RH8T+1F\k&Xʵ>ai1<ʾцjxu~ѫFͲ}8rn ojۈX4Ҹ)!Ci% n Wç(d2AAAAƻ3Zvc-8 /1oLK )]+ۥEY߰ ڎgO<fYUej(c lw19dͲN_b|Aa^97"ܲڳĂ˕-}^5`\?m=>{pcQz39,] 3&?81qH+: fu1IhO0 *PRH (.HAkQXϫ9Sчsj2h@RIc8khVrBH`24 ZhqQ5plRqɣ \-6Mi6:%Pi0'{ͪWG ?  WWUQJCw~a AAAA\,f71gR mc1L?@<1.pk Ӂ DØ}?|wY_f,AcY`߿}7=i# |~Z@%.+`&.Wq vvYB͑(gS}P]dzkZuEx~8M>bu1t9B FsKu|S3!DN v<kec"4D8 C8|h mJ6 "*k1Rm12~Sߓi).ׯ(0H|*d)f":i6-1KzN[bƲ2֖fu~v-1ZW J Q R!%L}=\oJpSFHHͩm,)RD ƈәT1Fe* f"Dnww.ny1E Xhf ,q)$5#&D] ض pi"DCR k5!4% WCCM):&q I _ WG.dBAAAA9)N)+q״ryk/Kx`g7'b̌>uǦ'ᗳ`h,Tx[f o?% # _Yg0%$"    DOZ3b1?t'˳(}YsfbʦFH4OK (0SϳAW #kv v6|fsy폱SBݐㄪ?QKXN:b8VXP*;Lݿ!*AUUGzo6U/7Q' `mZ^" /|~vv~sz\WF^Z3"6뾴X4nfbYI~B٬q*t/KU҂ 38 \7Tb{ jcWHR<7^ gGF.UD[b,c4c$Xй"(s.TM")E0mPvU*C%ծqgU$4|l5.4b:(LC&DZhsg!MĨ).s?gf=UUg~%XȖ#OHx0xH ciDDr0D%Igl mqӽ"Zm{ys~{ZWuץ 3Ib EᾮԼ :Si:B+wze^:*{s0Y%X0R6J!qpi~nV$21j8 8bcħ3p)$|*8-|.au1#a?83Vaaaa?$w(kuc!]Uؿwnkk&!0qT0evB4| |ooϞiۇott~=?װ\~!~oSoiͿ@lcm ? o42߼߇=[}w㖏nkqN4:\Y| ~h}CaOSf0ж>HӲOtI,sb?:?s҄C߮mVPFw<hStˋ罭ŖдHTɵ]nJ;JtDd-Y$ZhZ2c9K LQ_b/7za݌!ZQH>[ @2\.ǸDteOk0aF0 0 0 0 0 QYx=އ-(1!ޮu$RTt ]3p{ƒ"!|I\o\[kyo׏r}H}VZ^+~B?* IDATnen+_}߼y?-m` ߼t=9~<;_x:bn/2|#ʟ9/O_I迿aMga2k&∬o<@;EiZMWH4&=( yh8=ݐhJ9j!V|惩#! g%@92#~y?z{|~d3#:` VUqw R ͟'7pNVi٨ۈwT&Ne;:2b}k'&K .>~}ۇm L࿽_n_,C.m))zsy~u9hpxG{ȟߍӺ]v$ p}GxmukD`&/8p>с; %N.W446c<>V<Ȟ$f0 @d{ uʜ]pARr.01 ! (p>>͏TiVvv){uE@!: ՙPv>±w/ gr.?cDU0]gHt3t+s:@ć60YYBW0 a1#a? z 0 0 0 0 _5Z+~*q7.TOPzF JG+u!ށ@NL*"M>'+|x))n ^ qy|[c ~?5)yO[Ǘ\<ܿ?7(OO·iQ/tty D /g=dX1-r}"O7鈺*]+N>[)j F,~8!@4L؎/a>yExqtx`h4 {DӠД{ W;R-DAȅ(:;{@ V{`7lƁK >DZd6D+cMzVs^!DG>^e> |K'|~8 kaF0~"O`*0 0 0 0 G/Ow D.K95N}A_XG!dG_8^ > h*>޽O{ćcJy${r|ҁ~Jؾᛗ3낫_{^NCwߎX/o=:#߳\.v_}䧾x.S=%|ϼ}ӻ+\TK"% n|N% ^">:<>k>E{6 ٷi1 _;1 {Ow3)0CUk6;jMh8'BBJӂx."}us ¬ExL^wHO)v@NU na8"LtJmm7Ges!,zh-c2)8!a)k إ6'n,PF@oōZ6_;xK0 㯇 oW_m baaaaH齁O4-Ca(\ =3f V#8ڈi1xJh<]J/v7=Ƿ~<;פ'Iiw.cwxz[?q8Dx z@ 10  _T.D W˨}O!дR[azy|RPևlm{ i ] 9?Bǐ|ZI1B"f_!>=i >]aP3}ul8x&[wx(49Y1LGkޅ\'kUTDQ]$dm!aZ'd\NQX톀|yRʥ"^컜&Y (|x)\]"Bzv>ïڏa?4f0 '-Ob daaaaȩZjB5{䕢( /^(|dZ=M\m%ڸ^S9k{o%~=ק7, Г_w)gX)[' z|%uG.yO !I7d>E |w!qQP?Mߒ1?D +unizyu Yϱ2 u\thaף]5xZT)4x9_4 ZՑח[k?[EmƾCWҁԪ ZiJH:Sk!0i(?~DgDRSYK$HbBZ& #9CJ#`=}^gdp0H1 ú~jM{UAULCf^hs]I I^;}wA333a%`OW_/?OoaaaaƏ_?<{B8=>Q;aDi=HCU$>|w, N>;M%([p9Zw߲\.#g ;b繙hy:ѭ -C.򚉁=I͛W};[Jn8?^ Ap~9{'aLܻ1G3Ts O6OGmqL!L_@H 9t UQkc[F:Eo.!ܸ P'+q].ok!M\W$mԶ^`sm82SV:^%j7c]rƭ:M_7,NƥĪtUnkRJVb>y^3@`O0d|rtQ-,LH~@ 1#a?׿c=Jaaaaw/Ow޵Zm$}9IPoQN1AЦSp[ <ŅBmC>cuY.O܆1|.t`N(蝒WҒ蚏8tDC7$eOC4?pI3}FM 2Ok? |zNpP%$鏨F<܌6|Mh @덼*7B\i=ߔ[KpfpJ>ߟ 'CP{om4 /^Bn u\;+$L D|fJe7 xIxIDa(Cl o+#N3Z<1kjmTXZ/²*g+y$>|S/[܀Սl,g@NU7 =?Gv]GNԁV'i{03aaaaaƏaoѴPВ.Gs$IyFBo^H(6U.{8 ҉?zraD:-<_*n %'vL 92z=W{oAHOJ$S1q4:!hԙWDwQf*Ai}j _X>z8`AU;U;Cx6/x@J0>YkR4D2~r=:suh5㽧Ռx e.0Zz='tY `rr~;z}Hie$ H `rm9^ ºys*3 yJn\ Xa[J/$чqH\T^ .vKHF$yIT'xܛ'i 0 0 0 0 0 /o/JGk' }ܪ'Hq[-@"6^to޿P9Y !ҞJnN،oxECt:xp'DDV`t$;Si7:l&킴`p'@"mcWU>H(e[9z;Ok=@Z%@k#}HSx,zANrii vJlOikw^x4 f\)y {Bo:gĞ ЕZQy׌ 5O?ʈoNu]?@s;gf~x4BBqBK'E0_yhXLS"{Aϣ ]"]T>ڈP 6A&$7SNݯքKlk'D(Tpf0 njaaaaa+ЗD*@ޏ ߯S ]k0q'h4DPL0lCn3Rn+PFL>>& pE+SȜ NoQIJ#~إ}9u.z9v=ƅ|O\N)J㼜G !oƀ?_Zé y\>ߓO"x#]M Nt?@kt>Y j-l ZEZpz>qrALiT2+*1!Ӥ~ PNyvXlƀ3)x6=>07:J.!`Ot\J< !PGO5d|Lī㨺P*xR`CcF0 0 0 0 0 7;T+ \kpԇJ- @\OA}Z*k{g h~^V.s2_<<H݅n'aR~[?~L!-!]aXTjU%F%7Z)sA8tέ7z|l4jyQr؇FmS6픺 ^wcfJUGrs//6S6A[#- M'Kڧymsz#/48'rC[D;%JL6ᾖB]o(ĔqgP"-Sk!7 xp!k_GZ>R\)H&[-{6pOi3Љ?t9R1W\<b-BHsb`F0 0 0 0 0 _ [B)/i_H+@Yo@Ik 2'&ۘ?RbCÇ!;8 4%zGi)~dAG8\[)m@u~D]<U;]_N1惸q TUa8/ać!{Fit(NHеR> I/qC7ω\݁'RꈾPZ鲢ki0CZuhM>  (Jt_ D!Nk>"1-'oHPВׅr[!r Uk#ֲ']lmˑIRԈHD KqXj>ư4D(oG`CcF0 0 0 0 0 o-CkFŒ̗sJ=`k:\>D9zQ<ȅ2"aCR!}z5:!vSBLJ<$wTH{ 8C4w#zmyn#*C VAzoJ|)5H>LxLkU0ďIqmCxz."rL9g,!PZ5 Dgۻ `Y6;3*!Zo|j7OIQЙ~Z yT;)<&S\N22"?SPc|?{ULӄ)ѳW^H{MAA k֦ӪoNWÌaaaaa5#mw8K1*}({iN'(6 ܎%z N㈾Mn0@6[u qvc2[#zsbG.c*1]/ q gZUI~;<JkD%!&UZ+rN:ǹ>FDCUB@Dq}3Z68} kĶ2Aq羛4KUz+ |J3zߕBKFHP}nYZNCCɍ)=G/wǍCֆ5?Dd9BoBqd,KuQ0f0 0 0 0 0 0w& Hm7l{JվocDt..nKgmyWm4~3 ځ@Y`3i jsCܟb ܵҩ&2څ.*;Q8=-; S)?EK_CwX@ ^WOf Go?}ipC"sBQK0VT+H")y$:tY$GtܼLKwVSDe%Q@ITz)fR@e_Z=* WI|[Ad6+ʖPܨtXdu6Ij B ~$>H"z Dh>RGNM%>E?~?7~0 0#aaaaaa|GȻ֊ ):y6oiJVB{,3|LPkz/@|'/þ6(Cgs#-gzkcz45(]35ĺb{߫4q$qC=~^3HKE|L'z? >ö=1`&6ωo'G}QTb|M>a뜆oA)T䙴 'x-mXOPkK͹ixHtiq R@k{:,=Z~`҅hz#S82^HWlo@u>D.apVe-q$&?R , h{CňY屽鵰*㹘pe4ID?Q%!j} IDATЦz7eI4 ƌaaaaa˿_/ #"`3W>'\;Uq(%q1<˵*h8rsݏVjMˈὴ>1頻,Aa{_D`Z}c<RZ?hG/iL`3--S<79u@wa`&z 1ZAH`Pm q킈1k!x|/qg-@)yP׹NQkQqX#^(:A;[9eٍ*^3E_Q+HHdJ! s@4"gR@X\Eȱ\ \(nV Mo"}^M5H#]d1#a?1yP3d،=?_Z&}T< 1AiXxx )4^Pj{W"PZz2 8 zMKDл⛒v%RtT!Lj;"VYu7tEiF Bi&rߏMiXf1[>k<WrUL^`50鿙&-9Lc.."Z tWw03aaaaaw&Wt\_S^dl8g7`y4mD#qb}P+8;Auǃ@+7DF]Oȃ@>G?iNAI  .Ke7DuD; {b~~3)qNXL5$Q 'Cm9a3H>$]JL l nTsG:XcL%xDQ*ss*]C#v UY">^C 5׉67NL8 O'Gd~RW'z8e$2īRk{Z2+ !OtuΏܸ ǽ{aCIsUGӌQ0s!H8UI Qh4om3b0#aaaaaa|?t2wFOL~9&x] }d?ʣz䡞 4 Tۘu7 e$ *N焮"QqoNƆ8x\>cq(mgXTUbNSBEJ"!Ø'^0#;{kFܿV ,;!y|L.wEu)a߷F.c]|>&6OӌP D{"0ץ!ޡPڙP󝑟0_$/ 1ØwpU?-Sc-6~ α~a[0QQ\ӚDg'͛ҫTOQQwo0~ڌaaaaa] mV/c?qg>M~m^P?{0uc5xWA]3x+68L h:E1!7qȨD1Q=GK0tc]n cl~v]ߦ+iOA~O!FwwRi%̚ K {oc*Z>inxz P ?%0u(COTu_'@R+V>Xyu 6'yҙ^ .Db-c>a[]@nO( ƺȃ`]_%<7E~7 @"@iv30DI^ övԄ@$T@k73a?0;3aaaaawBzGP2 1FfbZ UG/I2ާP~9;UNQT a"W^ߩͱ{ c.Sx*WJ LG虘>J.86p[!Bo$+08?c2*Wh1nq hC(t"evXs%.Zs~O<XKc-%* *kiDKܷW4-ĔiҦb{ D*H|XWBZ1 _J-*0 A+)LGj]GDmIt_N aD 6)B3b&7p;k-/({a*8c&[LJÀuFi !Dz?!H$Q 0% O#Nw03aaaaawjš\BcݵyGtEBUOB> ת\c`G/J.@!*J-YNZh~̾9ZYGg2@*.ZĹthsV.t%C+$е2g\k:T-t=@gJi5oa\xAcc_r0xt!b <}JY_X%zFʐyIv?Zb>#7k u/Q"a{psKӵIHڥeZki&p!"5󘠏|8& V k/i鼬p[j@LG7V/Ba:++ /q,2_}z~H@$Q@z )HWw03aaaaawUw1:B҇uFSηR>xpM q6@ pJ!&ת\/=ˣ;'4@!;a0#4tL |}LSZG㼓wW(Q {A?DoUa3Di>EۀhE|V} ׋Z ec A2UT˲Z+򁘘U3^^s4!z9z Ҵr%TZ5Kp>|"z- )""yM-q$)]k _Bhwz/Pji2^1U_oi-s?}P:.%zά)"(QM؜ih2ֿIBFoc]qg90"?{4 Œaaaaac? ]Kr\ۻ%%T. E)J%ѨpZF{Mm::$"FC?R3!ȃF/D\Oqx3 zw NQʌ"{W$-=]Z.$J= >Fb)ָrL3|<s4Ei^"1x񔜹|CZxq;^keY83%ܕ%zD"T`I|hr *aNW11֯*( +:STyPCDV@ M36JV^5vw\)`Wٯ6?Qtx+\"}VaiwVt-}E\)= H'Miwp-#Պ Qw)w~< 1#aaaaaaaJ|}<t\r/}EA.hfUCf9A~sJsuOj}c=ْ;a@q{cWi :'?&窤>jpl1#~f1] 3U6 >N!%_Z#neD2/3)γ,Z3^j"/Bo4i4%gzٔq>ϫU )`[CqN^!-O#emͅ:G2~Ж#!jIg?;G@+HDQ-$שn!r~c:ḃ8p"EܠB_fi6NM<]/kӞ@rau4J=*BztGkif OW9b\u~a?0ޖ0 0 0 0 0 0~KLtWЇ%E4IW]Q%?D0 ɏ C@mΨT,C9)Y'aZɧit>DH˘~_6Dt=!FF@ݦOSSĻ !<¨rA*¥zocD`Dd9s3Ӛ?9w9}MAhtk %!HBnU}Vqw]翧k l%#eC @m\}0e%} Hr< eʀTvu4>ybXwBmr_3`Iy6aYse%7|;eق"׸JmZ2[(LBҬq#w$vW5Xk&&lk%EzKtW88888gz_hVPURJȨxK} •AJ#ү0Mmd \b~p3*:K|,2=)$%9̦= hU锽scӴz ˆ4$TZ/PW2vb =2,O9 #2ޯ4f,U#%!I,!̱ø]ֈ6"0 eJ=>Ch]!$“#G['#$P3G"@G'ij1`tsv_樂QmSTNOFޡV؄w;\"^gyB)3q"ƐBNz\ v,>ֵm;e{'/sAe{C{Daˌ! ,s0[a@A#`?j888888!!K=]`veƠ0"ex> I「Zs?븧^zV(n%v6 Eu{ X&n1d3#ȩz?ADKLUSn4nڌ$ =q˙g2@y:g9訍)%%4@DJlwV?+ ٣2?d5Z2 ٠kHBNb"HH_f3= 7ćHhOq@[s:ˬp*BTR ̀ AR!%AfU~GQخK߶ yo~h';%EʱZߔMGba|q>D p&*Մ\ʜJT댧 @Y41M2I2e ~˿Pq~5npqqqqqߐ1fk,K6Ms}#f. (w@%D%"M@Zbתq%l=VR;u}EF 8Gr7&/1y h HMiG)?!#i%]t 9cʓX}yɼzgtmsqQucpQ+ 0FT[|Ie;@ר?[3ah"N. ~)");V5z/4k.bjew%"VJ9=1Co^4-G@,^9fNzj} \(2P $2ۄڈ}P wh2X-x;[m{Q)W$FH4<O쑘H $!=Ğ2wm("Pq~5npqqqqq߀ۿLg@R6U@.1MOܗSkI1g*)T$43֫ü'J4q0kސ1mHz'Fi]a!u=#*9? + vܩm}ˁ 2VؿɏJa9S{i{z2SȗgBL蝔"1H_{Ie^?ie&$ k#qqqqqqcz@W$Xi )Z}mLC'}2L=0XZ}Whs%wSi]{f2%y*h}\UqUÌp.LnjZ!Zd\|SWc4+6r%ڐ5O2}'ǀl>bLЭ?z!Z=]&ꔐ$0F ٶ)ĄȌo0|4 <1M%%|ֻ2L1 @}ML0 [zX)|Z#08HHļ1%L!fFJ3_AvǓ 9 :g.+`cH!|^}{̙Ɇ4̨QǨ/_dnWJm̎W1?\#ZZ&kޮs$uem!HJykQO-+@-RF#1F;m fLa[ ?eA|6[JA$6 _q_qqqqq7l#|b̰Hq\9c̮T69(m$Ԕ$eؤwc:-@폶Dyi 03 ^Xrk67Xv6H2])6f>f~'!*PT]k_Iw39@ui`KJJDuC@WB\"*mEK2a.a)7SP-_)6zaanC"FȧgѧB$2bfeqJj3x]?Iy^U:jsXM;:><ڪz9s/`ٶ"xt 0J5Qȉ rR k^"}4%TII8^*L&P#D%/lenZ@!>ҌD2K$vOU{< hLD1p;fnz݇4DAcImtǭwlRhc)0Iў\s5ڧ1 ]I)t. $gß4Bo3nLr, B)l2,dx2eHHoIPRm؁ u9D2",m<%  .cyS888888 ?! C 2ei6tm SJSU%3+ 5P"0 Hn qnrrQZ=_bA `MIh>EYinf%C5MCBĜјb~M䪶%Ϥֱ-gCo0Qwr #$'n-&u5hlI0rWR(j*m{Pāl󳜕a3ƿ+e!q'g9ЭS-{7wRGBWC: rڮ3|5m292׵5^#]]ۓql`w q@ ,$f"l}py qy uLJ&IDI/mNNZ'ɜFb"ֽ58npqqqqq߈AUa.mčwZ` )2;=hic_5n 0dL$-)%`IRȶDz3M?a;q4 UHlIm 䶎9ļl8ucs4q|փ 7z$ nh 3# lA},͌1&D`Ihlh s&Lk 0Z{fI!""䘰K@ІĖtcΔfIXZ۽9UIu؂BT nnS]]1ebtmW+MAX-{^l-P9=ˬ!l DhP2]42ygo]"^F"Ib $8Οj888888oKz ]g Cn hH yܗpR"h#YhXfJr>vGI$&;i@R|MHʋ20+O(igt_iW< 78bfhǖǵ`9̾'B Sп{Xb}QOªy;C+uUo{ ڕ y3UgX7U!D տ*S4+r?k^ ++"Mak{ 4AΙ*YL M2$r ܛGkB$qL~xyt0b9BH"qZE@ZЖ@LH7!"Q )eb 2m2.Zxz$R^ ͠+L͆Q)iioo'??Yqqqqqq~cvi-cqC*wB~/U!, taaƋ[r}B3~hOfK`e(?M' Ǔ?Nb XFi xP l|,X1aڐ%➑)]9t톤0 +: "7?g?DHa[|9@!glSD;g;fD "B>[g% {I3B_æX66$,39:)v}H"s_R }tp 粔BZT/a|zSNS#Dn(nuPTJ)60V@(B~_FΔX `߷+=-1HٟA̖%v: &c=]ύlOχH8[B&2ۅVq? 7888888oD"mȬ`Ԑ`wª]`tĎu`9>=I"f}6)XvJB nz46(14~VqG{,b@bCKgfxTj]R"9{!"Wd|NbXSMc%4m@JB O0\FÜa@wlAY uփ5oF')䜑(J-ց1!@$8@ -y*[J$(Pb`ư=X>RCyS?SζVRhmgk{6朤DBȋ \)BN)EiBBt=+cƲ1x:gFqqqqq_3)(ʣzXrZEbףһeL4Dz ;H^ b2@(qzۥ/})`pyT LidKߗ72%gJo/ig5Z1R^Ƃ=گL+zCWFL/}CsSBZIU{ʰOqd q F󵎶 maVkD3T]^LϤQՎu+wP*E5{0w%/ZG۝{m:mar;/aA[sdx_<6&܄ @BN 1zDR?%X}`\1VFJBN%Sj)f?Yqqqqqq~cL2mb' 13RJ32<4`UO#1)b( bVS1<H/Τk{Df*A:2s0Xݏo3]PS\.!2:AdYoߍS$Q1bK1֍hf-FA9Τ>^Fs~zk;5(\"F0j[w-35W~gyWJ@;c^:)~g3x24%ζ@w6 jm"4v쏵rp%khM> P|s33q1myKkϣ=|mO GJ# xzTRٳ\ƚzdJ Qr)=7l:gFqqqqqa3ķ'T G%Qf=5D%^ہΪ'^OӔMrB,oKdM@{U)4cXL > 3j(1A3 fh6}1욃YUZ}F[e=b*Gk/|wָ 5p w/'O; >?mo܊c-JH _Ɔ瘈y ;)DGIu 1њ]JOU;e9KϣQۘ\0NA[-rWR9_лd Cˠ!emޗ}Аz~@7RP %!ȩPJr}~'$0FLq,8888885KD%<2 `w}@QIy@wS>G"J]?@BQLK81h`EzŦaXL~|8Mr?ӏo?]@@>dX+=+?tE̜\-sIW`Pʆb8|GYSb蝣:s}cN_گq}$C0w`0Ld@݈1qjǽ*q#8ǟ-fT+?\i&)7F^jxG f!JNRK9S2@&y'&pVRH)l0vFWeFHFB^QKFZ@^?aw;^U:z%-Ֆ8L׎X RQ~Ԇ7~6 sC_mk.@{tY(I2M"4V?i@h(r0IIXF.Aƶ=ЀBeDhL^RV{Dk^vTfN+ HV =dZJ4[tco1oD:v#^Ai&*ٍ_D n$Fmڗm@JSn6ix&HU?0tRpϴ]11Io 9fU~ H6M Z1"p02lރ iYT`+f_ZK tv}`DHDNҶ_-mH.SłjCI.@2(sGrB̄䅕vHQq#F8!i'2) 4\C8ίƍ?/qqqq# c >]3 &S\*Ɗ  $M4$ ȱ#_$ } ҕޕ)wUuUr+6ֆDD6R,&ꪆ;)_ zQ`$La6X=䲑FkJksRlF784m6[)Trў9m}%Q %HZJdɈ,`V(a +DByLJ?! D *U8VK y ,FᣇYqk-c!$W?Qev K!=tHcq(ڡբ9Qݯiul[Sq~5npqqqqq0c L+l 0O3]3<?k4Q HLXו0U54m^3wYiJ^`S Ց)tW?Sz"hJDRΖ/,qG\Q_L)< '% e `O&$0b`TfJ(|M0̌" c-vHۍ׵77"ێ S_lN3 ~~q*9-Ӵ}ug_{V5[ۼz4|>U|{)~G1Fzc ɬ7$lZ3i L}c+sAYB06ֳ@"}4ĕ6p,j@QfLSپJڝfpGțœpI.9_ qqqqq^RNζ]\, +_f{4k6O4uDB F%,/q]"}J[tN#La\5]ĘȽ6>lqO BSdO1`˾2(}䯉(Ok]1{,?bbS- F~vzH?EgJ;MD3k{G)5 S=%^:= ֦"L&8+{H(gZh=A|mJh"MhĜ魑%bǁ@o>c&<v!`GO/󟶷W8d޸bӾ:okBʠJ 'a6B>!L?}LO, 9Mz@`PI_- s]2y׶s+~O !%ˆS^A2w?W8Οč88888g0F @TɇEReU# p?EЄ^[6HTjW74D2ff@_)#xTOAl1(Li=Y>4kCf+^z3R2 tx2R *1CHR ۶O)1+5b}٘D\݌3!HmL8[(j}pDԔ$wv& d6v_Rq~=npqqqqq0 [X܀`&21,$ 5N)6f0LJZ{\jnz֊@.y4tF/1?c Hht 12Q1pty+ Z! %D LcNH4~\۝BH;C?k!lK8unE1lF0NSD9%*ǧqw̌>:mM>U#L̙='M+%`t$un$);C$j_0c"3F}j0 > %E^A2OFEHSnJE7)p s/۟)qwFF0pUux2DI'J;`DN,8ߍ88888_ sU97}eI5<+)`5}dmT-> ؊HⰙ $2`%?ϯc-@ϗ 3U/LJ̈3u0V/[%m1U\ +% Z?DNN[WrꄳUv34cZYǰ#zOȫʞڟ403 b]4 J$${;-d N@ZPnHl9(WӬ'FLRYBԊ;ϱ>A[%@ Vj?(%'b@):L@ AGE;S__=VKVQҋq]A < Đl 3np888882. KeK 1[FZH.??h@J$*y&3>V3#{C2g#a pl*>>4ezZ8fS>ubfǂ^1*=DW'`~0Q.{r)֐q# -=z!;f(VM.1`BޞHDiS`'s1D% IDATHP:$ЃV;1 X "Ӵ%͊usVw-~wU;39ڔ~4nkgR%\sQI"V,y b`}*J+ Ʋy^1k+\ 1zeh3,$IzGV2[B+wNW <_[ߞ4x{e+s Hט>utdN֚sq.zsTZ)YtnҚSk#44]P|BG8oFa \n:+o۬9U||VCFRu޻^~̾GY1qc Ł0 6e:LK*)+z|^|^ߡ֕z(WBf"JARfK$<]<9:)\%bLK?VZz/_uR䜉yY:2<ߗ^Oo"ޡEe=AԎf$1@5\8p#88888ǿ7x7 kct^}-nil\UW}籖CHȩڿ`:+QYU;WBvoIv@?ؒp4RJ6Ls_J&Ƃ~`a#v^4OF.rOE2]@_Ǖ;C%B=~DNOo4 ;{o+Ki}fky>'V5LT$Zj "H=1hA(%!1 w-3fɪL'{{xxR/b/[BQ |(2InHoLҥvjT9Ɍ˷8PZOm$4n3CtF+=}}# ?Ƨ=U{Al%Ǐco'UH6+mmQ8vgL3p1v^CqGK{0kRLQT[T}n8oƍ88888;7wcX}  :!J` 6^E=2یz_=LiPA!i2ԧ0@RaB>`0$$Jjʃ||ooDODA?^+AƦ8c=SЯ4ommB"Y;FGiH_-`[>SF{tV' 7D6#–7 Anl;ヽṮtktkAQW{}9fq\v^*ܶ44@>::f̃H^q/Kߛ0$A |E@NR废s?f)ҺgE؏ލB_Eg1>^aW R r阛04kR&Jh3Ǔ[`1xkq~#npqqqqqQ3U)WP H%)>kn fNSG[SW\b !+lg++j/JR7oDZU5$ k{%nW?RW~T{Za y!H|?_^t/b]h`pe H)jٶvԲCnwd{{5Lyˉy'm?%)%4݌3N4c큔iY1?Iv{pKqxKAR[϶@oDИ)9cAt#l [<<噛2BB{9#i.FsfkMz\v'87褾*dM)Ψw9or<,St)EV$p T=DR/sp﯂7IC!|5||eWxySH7jP?q,a<#_5R(he а4dj.#ހBN;\YNo\s)%Je&;t轡V%~sK v{Xb} uXUR 幏d(j;yKiF]1ė" 2L2V}_b~Fd\B/}5 k%#Id\KOgB 朕E 1d}5Ŵnj:H b־{o:Fqqqqq#@/Vi#̨S #"#.֌JI4@% 0U@$"<,Q\1k@}JVK뚥I#<+G [5㈋"~ߗVo)JSԷp# 9 v։<@#5:/٪6[fs-AH$ hz:mVU흏JK_GOzmf<oͨQ[ZPRS(=6!3@AƚZ" kQTayFGE<ԑ0δ,~Uh0lz k" 6uPQi3 #i8~gA4˰5PژB1VŽ Ӏ$# ` %\lrZ4++_V.Q58 7888888_BOx1Ƨ2aQQD**oH >WQ5?xv?_-."j$i&)&fHN+*u)9 >̈'jx?~IOzCX&m٪I銨z# ߾K=r'ȼۚۄƀ޸k9^v/˺-ѭk@[Ig6UBĨX{gXDFҟE}f! yW;*3))*QiE)uV# }sZ#q/R&I W yl0kXT$YCDW5'}-Xz4׬.ȍG}BnpFqqqqq33Uf>+֩(w |@8CD?-hj(/ JX ZGC EzUZbh[ N+/UV+&7<}\J!'+}nPIk[uy˥U|W`:`m*D%&q#n쨾k59v'k4|2|48[R/[8?j}IK@ `th5T J]UA$d6=zj3mBAY#g 4 ʾTURknolYIi&(Pˋ ܑrfml;sshnKĪ81d0$llP"_м}3tV3Hi J`VRj8kYÆ $F}&BTl0%QAondp_Y1a∣g1_y8Í88888{E(>@RӝT?{ni|gtr.C=@HS` vgu &UkL!'핾:`U`&{K1rKf)]k!)1>n{`Fa}Rw/]@ٟql O?og}Sq GAEhfR`["꧔X)V-rf [/c^8b&ߍ֌Mg6Ͽvl;,ӅF;#JΤ>VzA'AJF!^Z'nlۗXB(Q)&Y'I$H昩2)5ٷt`& }l aPG'8lG3!9wz+P EA/:ּ]n^| e&<߫&888888!¥/F\@%F T! 5(-znq)"M<=4jTZ?x{o>V"X㋒itRϗJuqW5S(|?@$b7[ƶ*ԩ2 \%H{7W06(*kq;9%TA\랷;Q#w]F 1rƬS**V?glEQ"VZ}P`=ZJxbFnF)$.a۔Z 0Q}ZQ4 f}iFg@@2y3QKkt?v?ʎJxЯ&sH2)I 8[sr (q1\{7#'AEi֨(Q*=IUE7u7Fqqqqq "/"|ĸD0)R:Z(}_ $1Fk7%>sjK5T2RiLaEEc F[hRw?]['i<3a q#.q{!=fr*SHm_W|JNml# DR[~&"-GK7>vo@]=Yů"QM#IΉ(8z_Uq<je+vc_%`/n-S{CDΝI H{qj3 k4<F↓Ӷ1nۆSO2$Uf˾1}WX-tU󟆀 p6Fިi4sLG[y;-zCEsϹ=O4k$JR 7fCP ䷿:?qFqqqqq4ʳ=JAD¦ZSNS o0( bƁ(OTZ؟r` +4tfBXG?XY#$lہiS.ןs*#ɟjum :SN#t@S7Dg \6!QM1FMBιh6kiH3G%ǟ?;q֍nwԌ؜ng;cցAnId4y?Eҁ6[$hǶmC3mB#gDl T>ls[%P Ѣ!s&36m0頟s+2 c\r%3k#!lg7qQi00rJGFxFc_IixN6̄nJ̊Bׁ_V#87888888I} |#`atD_;_zD>4h =`X|ƫ8N^gRu1PBoX4R[?%8;{OuFj3 3>J[9o#h>kFD03Ds lde/ `B&JbmaZ}JpD.I(ݧ {1[Uqgc#>iBݨKc˫ŁuVv脘 T$r 9*uI,3Y, 2)D h#VbtQ &z_<O@J15k IDATI"ƧP$a lpR̴h64t [c1F'nu%xP:QADAْck0fj@ 酻*vy{!3qFqqqqqG썙 O8#)x>* D kq fzH~u؋M^IGeUK}Euw ԾVQv`gLA:V+:p'Ϥ04ƬCD])6Kz5ξop&#HF"Tb{b{I[U5nfXhY5 i F{wvQ~x~Vmg/_?uisBaH 0nUk[YA_8~!4o{ӄS`HHX_&Qr6؏<-O5Z_smIiih0=?hp\'[e 2זe,lPr:-=$mE$QExH(;ó-f㬠dTX}&Ve4Bx}-E$ ay= a}^3t b>oJbI~ݿP͸qqqqqq~gnip ?t#1ER (&"&ڦiu g\BV@h͖@g@Xa =gYN>v$ujHjvg{SB٩3)OAXBqTv "fl0FʳXV+r_hd(Oa[TT҃" o_V}-O۝(<z"EϱGIJàwDQuFwl9PLh(J;Pzyj"k!|2&kØ)7/`/: "XMXgm9άas^H#cY%a%'3GF}#KBO(mEL8D|õ (}˜*u)K,ba ƾ68Ry<1.JՊĈց'NNNm} PcK } -q:e63B~ipuaB4zHY"9X{̶v݀Q6fg~<mL[Tl9|qS k%emZ:10#)5b5յ>Ok(l$4(ͮu,\ϱT4? VV0ZG{^/oϖ'1` s^Vl!/{\6[qD@NQҎC/nkeVn 7R+0eR40$$Ru88888;4(p)O7)˪ޗFzIh)TSg[Ua0?_53޿- 0J]_BE5rMu?L9V_5%V#G+U @UM 2  zwx؍zY3"b3E *73 UԊc&!UTY?Hz+giT0{2 "0Z(H9~EJZ'YdaLVמf3/(aXCL$x>ҿHf,]-@ usX^c:duQlGFߑ3A Y!NA"J _ٸqqqqqq~GM:Ų﯆Om>cbfKEO3@Z㇛Z\7ֱy&JFi-3@) WGN) }:7888888hʃ)<+O2h)|&ZWG%5IKbl lzD &_# N5+cmZh Z<1KdNBN_+a 9wK ^^(L~!.?޿;ׯo4cy_>>4F%H>5[HyCDKe[1N\2?tD~S(㏴mps #\&FFX臡v DHR0AefigZ$99֌k*0\6gw 5~z׬`+i;j.&96#m& }F7Uv_ q? 7888888Acv> GZ]R>*4k4 ; #N~+Iv ޣfr~κ6@i˛1vAzۜuYzJ _Mx<;_ _7( @NF렚!T@`Dv:BD`uBo*FJ> N)MzBEu{;D=M) B5 *I V; )*ekD%+ @%l/ rI8|>f^MmsTzTtv LhemDh9L42^#/yO-_Yqqqqqq~N#0oi $} K~DL)g 0YH0ȍ`G"U6 P =cGu-JL 3 HUc~*` x7[443Sw~AYL%Jofs|J3} %RͿm\zc?Ȩ@ owF6B:1[Бj\tj!*Iu$㱳1bn6篿77e^k%jL_X7N`NȖH~(m :E{T"a?eL0s/@Ӑ"DQ־xq5|N aPƧ `E*J[; 5f6XIYTzdJ_Yqqqqqq~~ GQK_brư:QgLj; +O;̪:T$ +!XKUtK3T]m cN[+Xy)K,t+DZ}~mx|4ᗏJ0碠zGh^jm#H !J`V>Z)GI*Qn]$0Ӕ@7!HfX٠wЋ[`j3)aCB/ؑ0#IaWz힅eU_v-f{`)kf\ Yh( &v *fٶM{F+mF+f.a8Xc2@aCh2F_;oX/ lǚn@$cޠDF[ms{O i#$cV!~_οPq#88888N0hn=k:/ǯ0[KEp>f%wd(B7A ` #"4M3c Lkngt_&( UaD%KVz@Xu٩[@ė7}y[׼AZ+Lmܖ'_na_ݑ8D2fDHtDV.}N?tCc+{\9%Tʬu=?A0EfSUZy'%_UiGeQ=a45.V*32@~Qղ@֑pYoV D;mT^ j_78Ο >88888{0c0>zc򮋐O'MA2(mVV_M/W2P^tCl#` fzTOAfQocb诇M~TgOcQ0nfNAO4'LPcf"ZRbfsFSBl$3: .wDSJ3_U7$(>k?*@$SOJF\s)ߩo}f3@b- u0t&q{1e4f~mXJl=9k IJ)(/I Z+07=c0*X%h|M3Eg!V!}`T3L/qa"așpJ $OE_}>+98/Nь΀cihGbH~58V888888 +"臶cD&zG;>kg 6r#z i#u[ڪ7z -<(%֠?>Y3 ?0k"+ u =fr tϑ03ݡ]at x5H*޺MS=C1{ҋ(y{OkA_+gSb0S}D? wJBюYmo#uZvn0 mw e@ xbZOѿԺ|;Snꠉ(l0`3_D!QA&(#)aְ!Բq& 1LH ːAoȬGf^s+aU+!sig}'e[#cf|*&J$/ցF]m*$FBuƍ88888;0VaՂE>k31N['fǪj?a:y8vW5D^#J۬?̣R42)vjP2$.* zT>G`b?rQ*=u@ 6L+*Ht~^-!ow%_*aFѐ(~Τ-/xS1ҺQ^*Vas[Z+{ilY@k+jЗi`wD3ۗusY9%dj&ٞ :5A-ksvG#gDf P :k׺ 5`;0S$?X}) 3%3BX8Y:1BXT~⾕|=˵b!Bz&^eHifh^|HY%qcԎ dq#88888+4 q<>H)BeY2.3cu[!7zucU jk; 0YZZFC壊,)m~VV}k6q-Sw|>'K4.$CPoAhMSTh (uTi]-jKɯR/EREDĝ9'3Z{97|<13gk֞~F: |bJј,'13XޟV;DGfZҔHEO*J)V jG+4Fa]N< R깨efR&.ɯ$mc!zn>:y'T#c:Ô~)VW'Hec1LӢLm(>̺HeHdӃ.!T`Dg  p<ƴUݍ4C* -R 5]G P}$GXLV`HҦ{BE6#Et!e1JdDW~ YQ[9Dﱈ3<_|<1zTI Id؅AA$~V**JRT*JRT*JRav К]k{=1K#Df̂A)` )ĒӛDޅQooQE\jb$Qz)Gm/B~3/'V;f QIj0 9U@>ǼpnGB2n @;Mwu Z&hQbv!!!YKJB6$O* R3b IDATM ~j =G)*j&hՕDi ƐB@X,J`W[8L.* 4H?=B"T[1uC*iGXZE~G6%R􄐊дA-)gCz(2PCr(n@$$ 0/iGŜ1? d @h ؐZ&, 9T uMe.R>#*wJ #SGOP4)a;w~ Mۀ1nAOy΢h&6tbK1R? ,'A\Ԙŗz6KTCjPJAY6̯ ?%qDQuޝE3@ü<۟[Tjg8:?e|Omz͓d>OɫTuN:WW/]ϱRT*JRT*?s\|R]G1AklgTwϼ,&J*@9H,P[h@)#zG7M@6m.  \'ybb EyAefK ,FKвT-6} %p:Հ/#RY4b$4#|HѴϰ{; \7E*C~c*~ӎAaY~], Z2uέǀ,bukM=ۅiZ"c"91F, rH6-I$&;!T$ P'8u\wp\7D჆  !Po > Qm'JĊaH_~oa(,PZH(,DW `UE,i֢ù Ŕb714Jb14h(1BRPT*?!./-=vif4ɩܠ9裆4[sT*JRT*_ѶP VZM~wS0c|0R@^0td,h1Tv4,F?+ IېM!H.=PF茑Lh>M߹c:w*+JRT*Jr ?\=:J5 >LneD\`=\L i}KF0[ڿ7-A6%K?YN0z9=@6(Q落OHE)"bg.&of@ LF fɻ4-"DB7((( %\mb>d2AyLxsȢ{ -MS RRW"`0zh"+(AC@ ]WcJ3pLmbcD_cI6=]3BTv4F!!Hg>5@I:޴c}^+;)Ry=3Vܔ(ǃ!@)tsKz^}ŽRܫzES1тTt(t٦s v$6DD1H R/\&T**JRT*JRT*JR _pzxr@ckE;B梟 S&`ct5")4k ,VZ/B˝-D/zcb"2<2T1b<^iRYL r?#k5قvQ1 nF,KBBS[h0:&l#8P,`zWk@5ҋ>.1"Jž9H)QLJD"HC;kAR).L&%? `uuL` D_M_߀qRi]B} IsY:WkH)b֭;)XBRuפz?Cl 1 6uGEdYo%12NKb\3 _bD oYj1퓇>>@yGb]@ y -L`dTn'P+@5T*1܂g=w&oR;w/^+_q6_|^_o^3__k_@kl6iy({Cod2Z}.xKx'wo%=?nL۶!f\s5|S4rZ_^SR̫^=3O J))'>K_ruN{C9QGMoz0++f3Z.R|‎{#OSQGt-]tѿ{/x~v?8xrߞF#ܹK/ws\'8}3Mwݳo?tX7sT*JRT*OK.|j=Rnn8l7x(b[0̊lʱzFjFRz}"N闅^TCm-#OX0BSD m|$oqE% |. L)*Â0,$vqETǤY`J"u(-?ToFY[4 ֻ'H~P"y_l! F&) AJI9?IyTYVRo1 #G PJM 1GkmJB 1 Mw)%!)VfDDӹ9oF~)u#\1chUYU_DC"Zk x`XM`{(Ua"l hp%Y[ #xv) S|=`` RT#@r/݋q]z>9{,? ?/э5}1yCz!c8sQGx~&[nd2a2pߜ{^xy.!x y#NLox97o;~m7<ʱ˱w_gq}翝usq_vy?ӟu=)< H#8{`gN k]UY~H0s9 ʭ-fAcÈ0{_ǒ3/C|\ LEL4}E}["DK91c ,|+;݋,Bit m+z{o#tҘUڦA)C_XmIa `4GJLjyJ|yϘyuu qAϕ@ӎbpn)mD~̌Xm6-۽1_&a?Đ_#$2=$|h"CN>gugC;x2f4ɿ;58^jsEr_oږ4Bzu=yL8}1p.矙~M{qNW-.Ak:J"5E?ݞ[YMͦjeJ"ͨ~9W**/|Pj ~8f?Gz/WKx}n ~wyCvWr vGuꪫ9%l6K.kZ˖-[8ø-nrxʏc}m+_rÑRbu{={\o}yzh6GC?81pG7 9}VVV|رUnu[qC`5ha1gk Ts@ۘlr0c%aڅ1\CLsR"MJh vc+v4!GgOU P_ƈiGhm(rY[BH2Si# U g۶KKU QcH^PZz~2r*t/.*OlA)qPuaH"f0)5DQ#Tw6__$lh}hgKi * ]J[hڮE]TjThđGG>Qg6w/x wCqwlzWKNDuG:_}q?z0\y啜qS羼a㏿3cٹˏڌ9=a!:˟g=W`w^ ]_^!­o}k^{K8g]s5N袋8c^"N9!xЃ~m\rɷx̶mO}o1&+ދe}w#Bq%\UoZ?Wڵkú19V*JRT*J41vh#Es.>ljXXիBMFGNh˯ Kq1bXR91xRs!W4G xbJ#$9! &kܾ< X FĘbH9\ sH=ݐemfQH̫F 3R Wh-( .)qN(UB7&1sB6HQR!2tֈ,<s!BjdoJDdb`!>c4"B E=lΡ$4<ZM6c-]%@H=/EOS߳Uqhё#;ȅK _jQX',>d _+R9 F3' wِU!CnI7ht=([4(ӀhlRPT*B)T.>iK>ر3`OX?aS&.s|_]X7"y͋L{^M>78;YL}S`WU?ssy#O}^?9/rKh)x>}G 5|3~ikO}y凇ȫ^u W\;\}@ncǝVgRT*JRT*? _8Q!nT"df,xbOwf$1c{ &YJ,BD ,W4)R<2-vʺiLQ"B\Vޒna\9h)P" mv`\F%ߒa]48&<"̘vfnf0Ck5$pH`̈F4#Kוkql6DLMrm,ϗ` kvR9 R9>nښ_w/u׻°o_RX6sVnofn!18^_#?g{7 /7K[}E:Cݶp: IDATa_*nz+Ow\]JRT*JR|GѶ SVG%v~%1LLq}'&2&DI1b+f&n^- ^u ,R)JE-M;F)E Si{+tE{%39H%i 02aA)L#m4B5Ec*++#F7au[v+M1A i R[ۆfTZHꑡ@z3o`<c']X۽PO+sӷ>QdCdjV )q‚yɷvҚtJ7M[Zm&e(^`R^zXC)za00Wݷ*}r[˵ݻٽk'vp^`$xOa .T0ە n0fiB,gB *0ߧB̦qcjs+ ۴f1ZbX$FkD i4Yo] I>IiwH ML;Z3@W*T ܎Օ͸{Vq>㪫g{|wNĻxn'/!y0B}9wuGqa߿b] .pO=6lY݂6^ZR0sYǛ'xo).) --..!EX"" ILD@v.e4:ť6Z)PcR':K[SI0"@D)K2o0#@Je0^i D_+Ej6Ndft2 qOD,18̺GnF T pΕT>b:BҨFH2ŕƵ,A4tAEh5F8 J,lBIljkp-i$@ ih!@bkdCp¿wa AVD_ܕJ倨FJe?q1~MPǵua^k٧x'x?1۶m啯|xӹ˹첯}~h [s1QZsGk:h.sΫ9W-+O~A'riv-ߘg3}?uk-[p9[q>G";o/}.JRT*JRT %C|`W,3ۛ^΢MCtӋۥDP&: :ºa:yCz aH 1ء;@ӧc? sa6$EL4f+#5w(2uX%FL#qQ bRԄ$Q"ϗi=^H  F'b0tvWJ/Fx0W1vL\NyNM;"InF,[ЧS"BIXuft݈LS 1Xf!H)'cn@@`!Zv]).jde߁ Z=ˉ^(S(&J'J$7G'b^k=@V2J hM";( ò!M6hoq/vcܧu>㢋׾\'m6vava<1??9?٫ràfJ$~f;=DӘ^Q _C{c1)M;wqo}jT*JRT*Je_\|xoR!R`.Xn/tUHt3,c=5 9 uЧ("} h< r!'> t"(M72A%KEʑDO,&c J*,Q\7ďB1YxoøcN^̓Dn{Ҷ-{s /bmm{|qI<яqW~s_K.W}Y<^c+օZT*JRT*^ʅO#)f.Ĵg},/o&= ˲1z}u}L$cPJ!sf(rEutkgySBnu= *` 1E<RHh YlPEUi1D%H3+Jd[*a.d.BѴcwnsV:)&BcnH %mi$'XuDM򺜭c< 07ё@0Ք߉ t9E?Oy-8#\7Mo[t.I !~=Z"b(_1{<#$ #1\6,n% qFDK%KNS*ȉCD_J倐u *],;w>{u۷O;/x˸ '?<~Dz}J0 )~ 1 ~x;1Mɓ$n#7+On00gkM& tN8~C-[ .\zߘW{1{}{W~}0c=c7?svav-Fn,,cRT*JR, FBbт*_.CkhAxC[l&üU ,cn1$"Aܿ_Wb)>3ܷ*߰Y",؅~> YTRߧa-֘EѨy1 E1߯Whu` 02 o/*HB#R~X0E0,1_1 eIDw>AaJ"c,ֻnt   itVlдcD1w6"1x)l-o;hhQ m R[ԘE$a4 R7 k&t3AZgfhGcj!"]*Z#l{3>3>lEl\DvQZ3)ht3E[`7of0,A "0*G̟0ۘѴ mV0.LD?Ywm|̖ZFԆVI0^Tv3N/wCmcwP}rȡۆ(]Av'4w/<?~K6 >0lwG_ǿyȥGy$?Wx ^ }"ɿ~Ҧs9^{ύ9g~dm/JRT*JR§ Z׷Xo (`&3c@t ImĐ\1OieT̶)`GL4 2ydZE8s^p1b0ħ ѕ/=sg*!Rh\vS_+UƹPbpM-ιr~0 `d@ >iXlHƹr0yYn'91~Բ27Y!z}!$BHV(>-K(!3xÇ9Q4B] v6 D-iXHL*nDFg $Bb bINe-ԛ]v:[+Q8`ǎ@?3>4s;x߼x/m!nu{';U(,Fş|ј_:wv;߉?|ɳ7o;.?E/z!|6=+謧?gui_{||fof2Yvgr{̫?nx߼R>_qo{9蠃)o;6e}u>餓}c4-!x:E=/U@GGtb*FOpc:β} [88󂵩ͦ".Shm0ZbdXJQ !%GM;hI]gLSchGVWV4\L2~d@Do> -U3<д ~|1!@k!HeUX }Flh'!4--MVX]2cz,_07>DO p! &EDgR̼ 7s%q ffkL]h%@@S MZoi cɍ$%|%>eD"x__Jeu *}Oۇvslx&=yOxBp;݉|K_fmm7vs=غ,uݽ>'1E#E2JJ!`2Ip)W +v\H* p8 6 !H""!"  !|gow^k}FߤJ3Jҿξk^̜yE/L1|Ws{n/})yMGp}pF2?3d6/[xBOky衇piiM??pXQ>Q>s __W~?o?[xn߾+_ +qΣLӎ&>ٿ5D|"xk_ϋ_b<'_.GuqʧRkx/yKiܜ}Cg~aRJ<9?M_u|wixcnݺ _³ ׽[w9gO??n./gOOkyoOs `0 ]o:*SOљ@fζ ty7 `KiXT|{¬5JD!/5cٴn\ȥfe}̴fJ1De;6ְJNº'98E IIBG,0_R2WbZȹL )T{Z<}5/E:4oc8D,L,կ{DKk#E~"+:—BʮUcIYIsa5#k{Rz yR4aųל1Uŷuz}s*xUR5) t͜;{؋shb6L;()J+6bύng*m!L\5tcL7q@-=c{9q 8_7LքMxzk 03(98߯Þ9ǃ~030U_w<䋿GDx3})޼ʛSz}Oy>O3o?|ٗ}Zxxe_'&~g_˳,=/w;~2;Ul^>H IDAToʇ>O8^g~+n߾ş2bLl9a˾&=rw~wx˾xk~g</LK^Y7+3?n?Vܟz6^|۷]...}֍!5|'9`0 y_N93q&$pO R|L;HYekX `$ ε쌥`!Z Ɲиŀ]*\ ǹm@9 RHv9L%5; 9ќ6"k}D݅9KWYtS x{F|zx`+ɒgKɧ7B<ې%)`sڴhǟ)VEĒh1V\3XF/mq>nԖ L;uku|#Gev5,E7{ںu[Z$m{87 `ˑ UG㺿RJ@P6߉;IK\ ׫hSLF-@2hi#j&Kf:䵕D-($gf@STIgX$Fb7,-C,0- Lb^?2F`+_o&_{󸸸`gw{㏯o+Է|5y=~1y|ŏtr|x//dۭlѿ(Uߎ=yӗ xo?翂 y5ԧB@UyGЇ>Ļ0o|xϽ~o};[}}Qſx???-k_U<֭[wn}2|g>LDJ}cG7+^wO]gΝ;}u{=??{~'G|Ǽ3^}݇v?8 `0 _o~9&ns1_ n/?_Rpw@xy<&m;%ƘWZqbVIHC:?WW3Kվ ܰV*H.,yH9v.6ZXִqNdyO($lZ8A( {-jܢuIIQJwas&0[T<_Ih%+~ԋqhQ E B1K29Yd=OU̟_#,pƴ..@06E(q. 1U &9Y#?r&ڟXMn_;_@&fC/\,'(X{iU`Vs6?LAŜUbYS X=Rp82s,g @ͬ %5/4RHԲD" 0KKc ]מ% O3^70/S[k=;c[~[C?1(`0 `0 ' $\&j _<pqv>PR$& p3\7PK¢ɑXX#תq2+U܋xȹPڳ&۷um;|rMM 8R)M["ˎi׮QJ9ߪOǝOӄәN"{SfB"0U9^^YE_zvhn)*o{L &RiOvcjDP6s;fh׫uzbk/|["| nOshK.4łuպIhάf ib25@\ǯRkIؚ~>oyF`Zř屵 zˁem=S$jo pL\za&#&V< PKǃ0^5g<򖷼u `0 `0 Io~9YwaXHuA9 & -U|V1~vθʠ7ϥJ_aEo +ZR/^ Y[tIJ8*3]♳!͇3t]Y#!r3:\N :-HDL0sdi/.8r$e >ĞF1QkY#DcEbx-PNbzJ&g)9ziAP4Rcq=^b5H38vbB 0hoMZr<O8/-Pg7ԃejƚ_=_iiFo2HhNxVm0 `0 `񞷼m鱛|f O@&W/SpR\- w"4Ha9őMt{JƂݭb5or$wxc&9}&Z}/{qU$oZ@o'?93n֝X|`чR &+tMTS.Z񤜰5po3,cħ^7Br uz}9-43ۼgZXL"R #YuYߎi.@s_.cKA1{tTOOBu&Gv#.:X"N-r$SZ2!Lh$Je5Xmd A e-8 jOMnXi .m)JnLlĜ_Y:KkˎiϹ0lˋHq 9b(@v! M9Fp`0 0 uC=? l>?۷ooۯ6 `0 `0xyכ `]GGnn'PvO;-ff0Lg-EROR 5ճwDj }T {Y8Ƚޝ갸7-Nu9KΎlWu5`jp0Bj~êE k,{J-Xux{z{7 ,;()9r:m݄ 5?mGL %WjH*˶f [[Qx~oq#) h&f|<2m{l$T63'cڍ#F挱n5gP@(tQ["N1BDe">oyIh؜ҷ 0>ւ3jdRRѢ q5۽A^Kɨ&D6m[yI)gKqP3Tj/DO K"%w(>!%@IkR le 9j0D#G$geV;)#:/gj@Kpz$/ҀF KoG/)ckZ]TdqH݈v(@ʂjVOi=X"erXZ ~ƠkTCKFbkWcaNJC?#kd8v8U! npAiawx3X+q& ݠr %v-ek^Dmˈ 9pb@R Vl߿E$`r3Xi1fs^M-{D `>@-=Dw 4;Q6&d0 >0 `0 `0 OE 0Ay:%F `C̽Od`Hu:\^[,ĵm4@KES0dju&@ĢjƉ[SfJT9$9Y{/ߥpNp&`]-jƙS3PmO:Őb`jq}X8`klhWk^E}f0?Wߡ[r3p`.kZYMʟ3  p4ap,vD%Jܪ7l[\|](ϸk-7.ӑ:(knd+ =m'ݰ˥-)$̜o@.'}V-e-PL`I5H=)co)^ Ǻ&/SGÇ!Rs4l/tD&淙i V~{w)ۗ %`@i"ZU.{RbL"K5|3)T#/:OZ"曵 ؚ jɤ33fz(P2I H-Jy\4Z,F @6IN~–8 shSx~l@|@3\ߦ$۔kb0<`0 `0 `0 O:ſI2jSj) W*pC-vnXo4hPܭxת*}0xBFj:el)`Mw?R8.gi%"'=߽o6 w9q͔\.Pڶ7޶-H_˱Hi\6q_zT*V ?DNmS83 RWrV,t7岦5s' s2 `^ x aů&6cb6Af(k-FT+"J+֝;|vC@R06D1Lʕ}L3#QCSaGj?e>iBÇ%e4F]Em0M}}-9+XA#Az!0zaZ>؂VN/q?\ܾo 0 `0 `0 IoFj> k&ҕx ZJ@ش𾉹LrMHu:꿉T'~xNXrJ$̤MADz/&m=V~w"Zm/dXLRԄ{M[<ɤ™ Wp=.xeo Rj!BOplVm!"Vp׊ŊtQyjpekVu2LaRNBy|`m+ՙ8 nqZ/|-c9%L9_ '5uɒ|: Şc<2Gyr1f4A(eAX睈if\TX-͙A+&ekl{cؽ IDAT 9s1{@ RQk> 301{ .af$B&I߶Q3ۯ_V/1u.\c )`0x #`0 `0 `0 <4a%BLk鏽 |oݒapyZ5xת7;lCg0z{v`rtj6ɹ|o U٬5x,)A>.fzqM,ojBm+_eo8ݥ'H7JT)=9k3d=k?9jH߈a{gn2VEOU dfJXM:N`R.؞sb9(y|X|I bN8^~ y> `i'ΗwF7K<qbWA>ΗX9(ks,R~22vRcҌEES+ 3dZ`cpZc,N "q[AwN"Y'֒s^́Ko &@.[SK>0ڞ56HKuD )S"Nbv՜%  UO:$Fn0Rv% 5,E,A~3E`p #`0 `0 `0 kߍ{ g ]q .ori,ˆ-\9qߋ RJΧw~Wk h'WQY"/b QM K:7JLJw⦓!##TTs.Z84MHlDVžR[- CkEm|ܘvxQ@,xgτ~r=k7b0ζm{RbVEs^E &Kpgdo&#E3n8X0%L@b]14ߊ'Ԅ95È ܾHC9f^ͽe'\icTkGs:3Rn(-1a9rF4Zt@v}o9Zآ!d1d-*m;I"g@idG3kRjТ!zm2dXIR$#_F`0 `0 `0<)4\ &pj!`Y(D.n/[ 4>Ŋ3zӔ!pmI.2%copD'-BfsE~U@4p% `pR~_hKޝD#g^mȥ˵h19|ڎ!.DW(U,Va{_zw` BSžωeyrNv}@7TNi J8жT+0&ፂ cD%. b@JY w'VYE{-Xֶ{ffl=S%k%ke ܍j]'m@!U*{$hƊ7_\u\ &o Qxc9vX)$Rj@bNJI43l-d6Mw} c1`0 `0 `0xy}z'fh-_\x̶K;=L8 RnX6/̐gi c3pg]oak X?;^0R-3T{WJƯb!溩 o,&Rq嚸umutum$b&1)ZD*[ X "4UvT3sj&ޝ ζ+ixNƏ׫K)kſjA }<1_bLkDkisɝb&5Y9R~x[};Uj 19|~s Ӽ2f!YGNGfs8[/x)ef `e EМIG؊*8iS2@AloHp|Ij . ^,ńq qmj!yr2ϗugMp^'-=Z*9ft@r.8w2vֱ'-7׸KCb$0޷V} Txy0 `0 `0 `0 O :A&-W2 b):&% ق ,} K޶~_SnofRXT'ZUb`w#`[zTC-ILM\.k$p^~vz2QXqגꩧz[-p]J&PG>wzD.7s%2ZDpnO(#F3u>!RIiL5&LY.flH̷%QEK$O%GJqX2˚̻ ZZ&RV4tҪE 3|P/P̩Lĉ ~1JÄ8LGf|{J;x!QS+R|G\"PآmZG̕0y(ε~=x"Skum!!ajʡj$vT)f"דBD,vpXv1KgoOHۅTعFB{*n79 ~N%K#B_{ `QLwK9R_ F`0 `0 `0 BKrIO[>Eb]o΅j :nӎD=3`0 `0 `0x¨L$&ֹ?pckC.kEx&%a7k̚p&|pM)g&tdP lLq$pΑs&n|%`1,ifq>5cوQ<@Ƒs"oTsFÇ1Q]hb5 @lD\;۹P0ƮQR&o05QR=O;AD;M)9bmW2i) 2Mpӎ.k:r2"45V˱m^89AN@gA悛$,1zї(cGmUJ&$m)V5}1 xXzL09px{t=%'bgMzή) r"/pY|Zҵ`0x #`0 `0 `0 ER3 +S|.{ n&5 `kؤhg&Ef8m;V J1XM`¯'QcL^-F:'߼lNEi$@.-Y `;2Z#e6kkqn&dRBX\NV PKS@߷V(Z=Ò {|%fAs>Uka["J& * EZo͙疎YAmIk3kg-E s󏉔N"%[ؖgtLOd/g\opdXJ?m;8#T[T "+kuu,rlf* UڪsiȒ4ݗK%$`ЯCD3n믚3t@_;{fT빼1/`p #`0 `0 `0 >׾g<PS|y?kCsޢoqa]Œy&{U%tKl90eP+tyP:pց 7#q ^MUk=Iη4xIbMՔ/蹈"bwӣ`C7~g!r7-^Mp⼜q$`dZrv ឞ!:u8y>PLHRxOҪd"`1,&cY=bn:2 71`0 `0 `0#e]+v*[~gٻxm"gﯶ8r상xbwk3nJKyD+E$W!xcU wֆhnf 5SL.n"YɥE^sQy|l7$XӒή=@W΢$\l?EUe_Mot[Jxp}Q:*y*m$e5Xkk#P6 DLW GfĴ(|-JJ]|º~L '+~}(jV.bhIαŠGsK%c9 T~KX6`KF7{o#Mi=2v7%n'HHsp-̐й g  s}ή 7e=<2ڻeFyD)w*JksEFbYrA>@w/e0m 8DQbR[$|I(-j `;[ɣEϱw;Eh6|N0qpZk@RYU` s!nwT A ~?BzogQt-lAQ3_uRp}&p1c@ Nu?@@C M|G:|?)ԟϽB.ì"b\|?ڹpkEڟf>]5Aq'_*ktD1=3iNPR q lb$ix~}Uzg6Ƴ' n2_ \u7(?/c=D} @_+׍i3ΆFd8{hkCwj}q/v fze6bxM8~>U3 ~eۗZ{=*k,pDLRTGm!3_h!!̨ ^9KD ځaP3b v^74l󰥈)poqg ]<#nT*ӼSBD:ޝZLϝiUԆ;>X@vaw4 o` ۝id h98F5gby$0|3`dQc(1xN7|k}/X1# a܇i8RZ-/mi8 0L=F|ґq"bgXFbX,bX,bXxQHމdj^R} ek"1&^^Y)gkA-c /ǝ&Fkz&'9jHXG{3 8{~1pӯf6F"BVgV)3w/4 ];elQAeD@o)үV&"}m$`3 I,Q)OhJJ[ Zٟf2DVUJma`h1vngd3ES3 i((S5Pӑ:[r[J*`@)ف0 8mL(;97$iO(4F[a)G/:DwQCUIZ0ӐӼ!**iZQQD1|N4POC])HC3#=D<e2 hL1bjmELS]slc !B?ks__(WbX,bX,b@/Nh d =SU?~ݸiP>DzFOI9ȫ i a#a0+gdgk-BֆO3@kK:|TLgk CXSHݰ* bD*0ViW"#h pW/l5byx 1g)u,ݩՇ(:x=?(LQstJ!"v TtܛTpZ)FǜG!s'RsF4447>Z0UDd(yMsnia">{»!BV_M6NCɑp]8_a S2E|$ '[$8R13av8ӎׄ?_'o?/bYFbX,bX,bXtZQ3*b/[ 3w~z<_L3 }oS7RQ8MWnT/6/ۙ.dCR  @8z{&iNyMa=>k-uF:Qҡ~|#ABqhD–җמ}2_8ESsm rTZW&{}i-aTK,)t)qzolEPSrg%~ק@dX:/Qz/MgB IDATkG9/ma {CzŶRg H2.iG NH3yRXs4T:&{ZN pCjCw#4qKF ;&֋)L70wDNaexZzj@HJ4!Y2#8M?ϥZc"7 BNKZ_'7bX,bX,b]N{H`ӝhg3|N0i/%ސ^waD?J}! {f>ANk?#J{9@H!R|xT߿@?5gII?㴊0sGo!V<Z70S@Z%,Qc6"Ji3%=E$i>pS|!V,4ibljl)^ւ`vD }{%BRG~NəH ԂK8AL SOМ?bD*y8% 0#-Jibш14ް^!iD2/m.)guJ:,QQ> w;Ҝ;Y4S# ՔP2`Լӂ 3] S:L5h^*4Ybp* !>W;[b^xBWX,eX,bX,bX,B~o߆ @ z ?';ShC?7~5-<"s?Ckɔ<{k=s<ڿ!mw[|}Hl6>㾍 NHL%g=K4 5LZDj>ߗkSTِva8liu4ۍ{\ 9䮤dyfGnP IpT)LUg *?B-lo04FDcV`=mt"XDeGc{U!o 6capq:aj6"M cCMT9T犚;)\oQa{9T;`g;R 5 Jih}y2 Q1gꜻi3N2 i(.3=[l*L1;zq 1`g $^2yI[E,(SK9|_ƌ^󈜯)!u!TnI)ްgSz@2a@cl%gL@7zͼ?vڜ6 /x hpc[&P*Յ{悠)o4[haB(~R :!qO0כW,ms~-4T%3N|Ky&6mm$XTfJȥc㍦}h@1D{m0bpB$"I&g(75i`w{dXz`X,bX,bX, >a)w*eUWO׾1z|b$D4Y?|p}띛d3iېQ-tZ nL!bVwnfg@Ja~x#~+)*<_gZ? @zx;hWu4M/P($Y>O-bJ9C Ԧp$%ݽӃ]Om2co2La YĈ#V@ o-ݩ$f4c#;aYojvCUyБyޠy<1&Gw4x t`n\W9ЂpT72G@ϔ:LoꋸoiCU3yv9Lgڹ ":>c$<|5kx'R^ZBaHtEcAqƿ6оӟ!`3)HZAlGf jD=gL 7j> 0~)hfGah<~+U. :^咮zBP{%Qj}[ԎI![$ƈSew4DQZsMjyPhB4R{TȦ)%dr hnO )^@R!F=zӴK?5( ku(Eb3Ia*ME>+e>*zK6E{%3"*FվWj: .RNCQ [-1Bґ){EX,~#cX,bX,bX,u5sv/ pp]pFUs= `%<*";$3wB⽌xy3=|I8͔BCHgr4\&Z!c2L273F;g\g6S$3i萞df͌vjg_~hD1W zݎɹl; 3 ϊŷ3i|>F`?8ugн"S@oMy }#A]:Kۏ @ 3B^]Q]I3; szF>%Eg/4??#jsÔ 0 :ՂX0"֝nr %X,"X,bX,bX,߅gnFk"<^!>%N@/:<.&J-*r7(BЈxa;6C#@,޹X`/Q曶ahP3^_04[8 S.B PvMjc)2 zUs?Ik%@ :Y힟׷k"s `ި5jJQZc5:4'ܠ:޿ BЈj=$oa8 ܷƍRv<'nc-1yNcPJ9+Ut!ǨWZyшӠ3Qy)Be$Ն\ $L#^05絴aPqoԼ9 6TH_0Ԓi0Z4tcwi >=°\/tF}P4=SQZk%RHF튩U(ǜ6)xTM|1)Fvawt7 0mi [/bXFbX,bX,bX#x5BÏxu!=S>'|EJwmD.܉@owL7޸jv1ޱW-v O3Q򳺸{!RFup ِ}T64텊Ի%W*AGb\Kl0H4S Sѧ1BN%e0+O|m pM=s HKS5c0UPH$&񮔜0D*nZ wSL=~ C㯔Z%FE/Q IHR;*ތ3$,tG!Ĕ0UN&cƆ>ꃍ40*|hE4dpD8+k3L*D ӦgXmVf$0ɀ4 dL8>ZkGu !hz ؀z<㌔*A0;:oXp&*R1n*<>31"ڠւ2 .y/1AL);Lb"$F:xF:f22Z&bXFbX,bX,bXDxg)~?s$3V;n?CTFe5 ^^LxdJ.%dj)SK>[Pw =?*dV&ˬ uу| זQ0X0kQ˂!:/1F*Chlwo:EJkm@(T>J%hiӼ82mhs.a`D${xR1b&ib wTK[4{zpY,eX,bX,bX,_#{q[B}ɯi(?o8G")vg[~jVN0EcSkvM36@gd&2 CC?O׸hBpdHԚKI s؝hCЏ8m!U5 C?zSN)*;2LHHft*Pin *Jht)1|PH3%Z#D3JQ7jQF@)NqTwèѼ24G(j4x#_ q?8dc°O+>m>GB1@Fe*3A#oXTɥiz1e bΜ I Zy7TX,~X,bX,bX,߅=oo;G{ů o `|pDpȯξ*\hu_edȟyCpD<g^2\spj#? Х+18qgo!HHQ $c [7åћPkbs> -#HE0Cn!bZR?w֠#`N94QU:Fm8&L=Rޜ@ELDrMDpdV8)vRaD;h׏:歵5@Eoo @X_,WbX,bX,bgxKIIxIp+mzNoDe@xi p0$U4gTRΔb:pƆ+y-|A/.nuk~TDA>Dr~jΙb8[A|Q6,A }},_2,bX,bX,w!?2:،vP~P6q6BQmb"sGs'WSh/|jpMH|U5;^$̭{$}iBh@ eD۬1[HE噂00Uv1< AFw2%B9~R"5M 8Z, J?uމL8L}$_H? 4$5jd +`Xz`X,bX,bX, _an،׸)ֻހb7ڹ!j|Rm&MKa:hUQ>#'0L:[7A73_c"^2Unlشp*댅7YhdJQi=?ԆFQ_c߼* pDZ(pTB>=t'mCZD-}Tjcb>fDӠP~OBade4&`jKqϨ@ĶmC77, 36y4v> 7bTRӨSX߶!N;= öq@9ͧ`6TRl0ǑVc۴#2 ͝\ tYmĹ&[0D8WĨ\CxE@;z=luᝠ 0@#Yx ^+jXoPGyӄQ)$|y4jfN=^dDӄL ?y7=3b$L0t#dyⷳ>9bX,bX,bQ> hA%tyV~_L46r2:eƽup>>:f 82t7Z|whG5JѻF1nW96z>!Zi.(I eoqq505pAp2 x9 W'͔;^i8E+/5?@"߾ȶ9rWJm#Չ!njrϭUǃRv["h{LG+Gvz 43yoB4i!ݸXtjsb,#bX,bX,bX,~7n~w+M3QԟnBC5FGogWrnK;^eTˆ;hQAA @?Qcwp6S!>fT!F3JTD*} AuP[8)Ey}nk7F`4وҩq/ۘ݇4 HH9b!2 4Ϟh)Qvx|ĈӀ0fҍ+!F+Z3^އy`؝h??~Nʶm`geӁR;* Bj -??RF|yS$z{Na*)Rm`Vw%(l7D*zZm3 b9;B#9 `*OQϖ \];!(* "֨Ϫ|4 ZA3?n/ pQU Y7uw9NL4!^Q?ѼˈO&R[H"3gY$SC˨DHW)m__*WbX,bX,bݰhgmH`MP*P!{'}G_ӧ}fB$p1ẍ́H/ƱBxN=SvoMIzwmNVm)0p_noi u>*N].7cT&:Y}?ti\K;m1٧EDw˸`Q}M!{ƫ*~L^3NPU,fr8>/{%c[i^ooo+vtJpg[i((Y?o lmWd$ XF@yt ;\s4^&\SI|2Q4!۱8E\{*J?h"as}Tt&FBNK+l3[M\>H바(5b@b_R̄tN'DCU wTů5bX,bX,bkw7s3xl7Z:n7ϗ^_o1!)ꖾA/BkMeGcD7QhO,pLN1- MeHe?6)WLK/g*)m'F:,}y L3E4pM0KZ::qi'%= yl0w nLɳC vCU9_)M!H6'Q?gK p7~7I{STڠw9}`$ hC8bs$8+j̵c=?v<xy+<9/rM h^N9# @袿곣A3a6#6SA)6z HpܹsZMhO{Jx(Gy"|-v^=L igVQiV7'eQ{fS#P)<{c9bťw2ۤLmA6z y' |d'JyQ]u NF>[ <!@|IQi|wh!z4ԚriDCyǏv 1Ʒ7\B48~ Nc^3`d\GiFwY՞L9Fؽ'J~J4#t;N1UJi4OǶ!l|90 c}#}N(Ҡ?9Ǻ3ysv%L80=υ`s\M1Wi$1V!τ\򝤎 Rf[QZh^iQz@c͙]V _kc黟 ³X,eX,bX,bX,ſtU54Tݰ0 -3$F⍘Go?`@θ};13 } -КhQ_϶^3%lO-nz;l ӹwb .hHPiݾ@FS+Ct;SpQ3%J!}D_`D\zI/uS d7׽sAzLیocF Z%\nU+ (8L9vjSPLRxRfF~.z;#Eܿz?jk:t.}Z*#k=;^?kc{1xζl2<-#%c"l bǽˮ/ T??/bXFbX,bX,bXfoC H񆳣Y/fv!IhyK=ZBdK3[!g_=ֺ,c̹w.6vms6c`,&)b@"%\Hv ! PPb AT踶T9^kkc5׷sc|\ی^k9s̵o?NFkSLA %#ܮY=)ry pRnjUWa6bsaA^ Tq[\sszx.:ҖO/ofԞxmK¤m[⌐1ef.8Xi0 kuxWANEw**|L¡'*Ο Sn2igX*M7:cۜ\0u2*n9,˅h~V5R %֊2M\|KgHmխ=*\͇s K'Âj1;%R"l%˱ tn R;J qJŪo&>?sŊn޸4]ZXbg1}sg A ŖܺVϠ Bܮ<{b.hގ 5~=hF"`,;xRid&&1;0i a `0 `0 RF`g*9/xD5i\t׉%WloLv6wc`3X+H~hff=OM3 lpu*3ھHu"sDW#] }Z#S2 9`%Gڢ,WV;Ö Ά]% XYEugwg-T=[[[?@zNbz{H?-EPNjw<8[/Z/C_ -r]UՋD-iY DY~j{,h~E~Eeu͉0≴Jﻯe*U):㴷SԶ0 RT`L7K& xZC3ȭ'Axg՘`0 `0 `0/›^5< or_y&*z?Tz"AϽѭy*p7PV`Dc*5GH9rTgV'0Z/rZC);DZ8cP1WRJh81`VonUp6(1$%\Tlk&u|lk]rcU@=(M/UZ5 ("L;sN]*tOx( Ox=p8bW[$h& PKm>`5-nPj!sne*ij@D %ŭ̀p kKۦRxm*khRK(WWJΆqaGmYC%q$H)q:{;-!AMϥVrSXzOWEPJ٭u馕co(4^'mfuMZ0nky*뵆s@ s^gG1OPGJ0PzJj{URkj&W˒OL["#:~ 0 d/}x cgݠ}o~x1wOcJeYx/ɟHB^/moo{}k0 `0 /o~K1zQ7_V2|e׈N^ [E)L[xLX[ܿ5^ޫ ` IJl!-,h-tĔ $A8rqaYIUřD%'R1h-fv&>V_$p6S(ť%PkPn+ -&@jWt6w`- &J^ &oQ1H 5Khgؾk [KmcRkV׾澢":h-HUDq;}.G羽ҏ`9H= ^&Lܝ5`客iLH\ 4wR zX )מzZlv>s!RBv]}O=P1Đ@ ն&"%'R\\"*R%(`O4I:dGC<08h 0 ?0|/c ޷[ng~<ٟŗ|/W^8>>q `0 `0o|)b*-`16# @r-MĒ*&5f+)5+B㝂Cvfhk7j[zt.ƈ3 Uy'R.:> [B NLPPh %mm]~͍8^m rjtcp}DQN<,.%m鄳{A* wmxw6!֦A% S )%"՞hpQSpPTpE9!ߘ ΆVn_*קtexc=`ccS xݷ7;C,Y*Z8x ̤R a@0 ZKWbjE\3=!kV ]1#9S֞63N'/־Fzn;V)%bi-`l& @ʕ/Xk/q Bv{lm;3vYd.d %Gs$, S/R6Q~.]b얮%}Ő &MZKyr>.iC_sHŐsԂSGX2= Os*;vV%P~ѾgyarQUBH3fXƱўw"fO̊X{qƊ VHbJ4eCɔl!Vw%R 9]QPk UPc%JJ$Jhf;6pb)lH 儣3 wahɅ{.Na"d?mo7~~_{ ;xW__'?˿ ?c`0 `0 JCI _)3)On.\A̖Jj"gىyM;L kHX`iƇm]~qٚ(nlkt+mb\ [)VjވDݭ%ɛ!D-iKɗcZDv-zRt0{d$,b)fXN܌L #`ښ,2~1 0 o IDAT{~_<_ɟ 3GSܳoox ^Sy&C=[V~_|+t _B~뮯}7 Ge>?O<[r:xy[?'>| `0 `0ˈK4YnA<met̬[^u7'=Q@Փ&"H1hh]JZ9'y*"زsE8%KjF,xq:/9j*x9 \҉cb;@@39HdK&`䅜#0):&X`kI8)H6ϩAES|,S |ZPjvғ(ql3V9yE>iH x<(q_{5T=%cs m KXJmFc{RbjEmx&(%Q0T]QrM~k=sI|R jN@CkֲsۄD*~B~Wm.OΫ5:͐B+Z`@3Gm)Yr1<Ȅ B7RZj8voʸOOkWWW\]]11|g|?7oCq`0 `0<1x-ZiBmzR'.en 2\& Е87[iU~&cl؝WzBJlRj  WL|?Q )cV7_ug 9>krVV ]5 H)kN o9rpBSvDl?zMP%p8o/ڈO,J WaoF &̅ )%c12ֵuWj[ F% qzS$:İ3O8 b.6F5M o㖭^D(z)Bmkn}q`ypn&Ƴ"/Zkj',~=TvNzZ]֡v,ˉx:E iϛ Zpޒ҂C7sU[{yj!BB 9[ qz#@M&UB 'Nc" !)%ȭUj8L#㑔ʖ`r،͎[ZsEO3LOk1o/`F`s|rooeNecnݺ<>sկt:=779;/} oٟ~[nmw{B{=<ӟts_Lo j`0 `0|KyQ/[`=BÖ qC}@?߬ KgJiK؋9%%tG1Z+d1)cM"HY,;SwK鄥UsykRK .+Q$b5@6XsF,dhI=j:L۩BH]j38oqk\|QZ@]?xΙFVHGn 9Tr6{ԧ\9q}pUY;1 ףSX}lSUrUtd&2C\9WaYk͹*d+aJE88fO͉53p kxTJP! V5ԚXzջP26ӆөzRDJݨR Tq3oD*ख़9* a״c<U`HX %3t"C+(5w˅!rN8SNHU(6$l! &J:w3!@j X)K(|^5L`Ggox7Q]zo~?sg?S?S1Wg7|.~?0qx@`0 `0 -~,Oލ{=?y%\SrdM | Pz `mxYa[H> efm3eZĜ2Clη~Y8||q3@3mfh Sqt:ݩf8́V‰jBM"Bu5܇^qxvآ-²~OZ|z='vci?,pת _JtKL%ƺ[\L9Ձi̱,o_b#*-J>]< GνUҋ(ȁSvkL~K (UЫ6ְRƈru}mYHʙ륢rjeYj'Tfp~BS`swsˆSݪU kSKH6rm)xK Du2,ЮR\s'mzV Q1^ڪՋ!qi -`缾O`bLqkJtd3fu3ʚ؁k-XX볹SrĊCl\7UJKc=9`MP1ۚUfJI-?m#K ~nt=$COQX"3$8~y a |o?o׿_sR~ .k>꣸k5|o't_E_^oOxs<ϼK2w6 `0 `׽o5< S?A=Lwgbϵ /'bX0m^>.7|\,njf7ȢXjm!tlUKصlYO1a _sN1 6&gsB߉&;Tm7@H*sEBRdޅ$ ẙ i/xH Pe_D1J\wޓQ1$iUt;y5)QEy1V@0O9N'J*|SX->_ǩ@M7Rqn©eO@3 ;L^ceK%4!;(=p8D \Xǹ66A(6!?*C`<&컉 #a[;n&[JWV۶@],y 9x _=#7m?s_=~Əӟ>W~Wx_py>g>YO>k0 `0 ?^D^*OGט*<[\T8KQs~,95x[{Upz3 g(p㵔l|B'&cZt{&=ZZX3:a-]ϫ Ȅs 0I Nn- ,3!aybVT*)m ^,; S!Ɂ*ir"Ror*5}"WLV*KJ,1"R--@}*R!vu)SD5ЅeYvUΩ RQҮWj ~>h7n@-CDP6ZE}Kh1I9[4p*A`G{}LSIlsb7MdR.M%anb0E|;`EqnŠ+S#1j9ꚘA3C.!ffƲH,xk׾s#ox?=~>>Ϳ9 O? <{Ew'?[n=cp`0 `0 7׽LIRMR ~7ޟMj5ݮ8fuۖЬv3B+[퍊l}3i.LDpw7$JTUmI`f,’-pK{S=8[aR&  [b9aC֪dՒk V,^sf8V{wljҔѢP@t9ADE\+$S 봄mB&G>ge:立XM{k/Ր.Ykl3BK'(%²U&XV៊AmffXV\w؊r:Sv8e3@T(TJ`Ф\$mںoI6s@QJ #4 7M;!VVr В3@K* 539Mߛl.'DaShO 2HSMK˂ J 9q<5Mȵ]RKUYDf sYKʻ>6 w34>,͜Pj!xn3z9x!Uˑζ$,$`jjέ%l|g39D2ٵGHf4Bg-WWvUuUav ֊^ſ~aw|!"ԜXb{emK>--;ZHᄱD@L&+&,0b0Q!dfRM>ϥ$rIزE)j KHut㍫h\/MXK/t0g3 0  #%x~O~_Qwnևz1?|)%\w1'<3_U_}aCy`0 `0ܝy݋^0,>21.:\kBƝigr8!\h 8X$;#3MuaQpL)%=P烈DNd8Do΍Guf"`88"&ڧtI 3%)5%焪%#F{|8flc'`z`) % L#bY"J(:Z6/9?rD f"6W\oLrFiw{3܄ڪ~5\^&pN Xݔ* #5H8kv뢵QFўssZ 1׋ -H6?,T:-(8p3E+gPε*GFÝT o]H)Yb-g1ȹuܮH9 !]+y_ ĴvR1xg(9b&]')#5A1qka!+ZgTNTY0U n0 #`0gxqKyz0}闽Bdw~_5o?_?~;zի>9_`0 `0 {`6&KpR#O8k?{)0-Vbd9h~$Դn{c*ԪyQZ'ȩ˒LD^sK~\ N\ґ̄*3VOo[bnԝ?W7#1SX1!&To2] cUOQk& o;D| yOKsqJD͞j@Lyo}{r_ з_M{l&~Hpu}[@KvT& ^UVbbtmjV"?+t t%8{0^ėk a nO{0OظK_yQg~`0 `0ܤ( G.S"w$܋>v6ىw>2dp_!r6 u&;1;X6S##2P㚠U\Vh&z*5 `5Z9e7;S*wٙBXP[2QmԟLV:s8@IjR4VJ8=!R]Jи+2ñpӥn&V: k)`Ns 㻽h- 0 xm^yx :w~R{NML 5r=ݥdRnZ&b}55 sԏs*/_)\$ -`Zn" :aػ/\'04C@gA[ZR*@$W4p"y]G>%Z`5$$]c">%ſtORK!õ|_EE3%frα9Q'f6k% a>a:L!nf SX_~au-B?@ٌ!Z܊:U¯ vv/F}?k1GKPFHIs$e3[ulC"#ONxxyOi͇uÁq7L*7!~5I YnƊs"nwۡ9,IKM .1H392I㢽 bq><}'N>RFr6ǟn}\SHxR#(8ћؿ7lsDbmM *i||BΧ_~bU_O].~~k໿/na=%~}跹>.*_1~G?7+61?__p./}{~why̏|o o|_Nt:Nt:< ?/CdK( 0N?ryn}R>VjAb-0 &ƜṬ/*3a (Z!;:`U~A'kPcC"ᲊȝQ!xuG^sR8rP) :4 P""ZF.5EF8ΐ" 08rHة()HV631ֲLݜRQSJnn0FmvR 9e br߅1`%ipa~9518a`En-5(ibQ6CN\5Ay\1X⥀M<0v,'SڥE;&f4N (K[Aaw7Lц/?S!Se+sHX[두ke1l[2&6K;XK!+i!6Q RDg+bI$֮cwψ&(}$2Pm0NFNS[~[x?s[<5ҋȽ{/z1F-w~].Á{n/[?5cw//NJ}q^qNWt:Nt:O.i6p*.#__' l=\`r(3'A\sgX-DJz[z݇dnst{q{n6/|`-*k@K8B\FZ)lY3% 90V3{c5 PNXFz)>ϕz{B`0XkQ5>$dmj䈵_U[5>@ddI hbIXr6!PZJU¶2KrɭA"ދs, cE&k(Bt\hB;]K]x 9v<Ĵ2fґ4f^al"ւ"9ӂ*cBx_42PB 7D.qw^. '}ޚ~p}цH8[_mP6u؟#9CnS[,4D:?'rxC_fRPbm戴K%t>t#@{ow/1 !?~?A}ǵW> _uѣ>< s?wq 9g۠u`@_*AYTG$c b`% y̶VJa>/[?ANt:Nt:N)?/䑎,es' jIsmV>^aY)fd|p ܻeCD`]ݣq_:uJRM:m-|*[ĺ 'f5`O>~~=PySq\mb%,T2nZث(knkeX1$Tx9fܪWC۪ͮbd!%ZieU ?VkN!\$;sj):`&,dm~r Blkzbpv3 F3 FUѻo!cH1l*-X1vBbZ댔L˖ hAk{wKXǸ7 dO޵N%L OI1GgO:oXtq|;ZP8ځ7Ctn}ߔNt:Nt:N^ӾVzcI6C*? `ՖL7q1Z4P5 !*dƵb/>$)( r5@j@Gs `C@veX\ #1bLز ԢChH$iT,V6ejfz@&L\ ,`E0m-]KLX1B!fRIa&Sts1 c !Zv4|i(.y+ 1 |DnpP+K*UX~&jpfX` Ֆط6ܙD.k b!G =!M#F1:rs2FgAL#B [h`"K;(R׳Iiŵul7-a@Y6J^ Δ\g@H94Lbl \du^''OʊҶu'8[K$f֎1*Rn!Ph!*%bΈ8tJH_%J3bp56vKyzuFHt:Nt:N|ȝh8 `壙|( TANb"bL PY N\Œ1B`2mcT4'%I"c>X> `j჆LN  2Xt)7KAXXbaEύ5ҿ;*≻ s= F0 XtK`Α1@V º e4!h*Ζ1J!&xlMpTS@S}Z#Zs{5_"V7}[:[RV ZH}a $c)ЀN3F3u@sm%P# #Dp1QP9A+KKu(@KJW@ 6)E%QbFI&v-! pNս m;mck`F0`ݪǜfLA h20v+KJc28e{>2L jE.w"hJ-Q1L֎b9$U['k7#ݗ.ř%G|t#@t:Nt:N|bF9=CΛZ٧x}W{qrt1׻J n0DeP""xwZMc1632z`GNmĉ զ>CmP?!|v5LpRJAR8 %S_,8hs[cF " yJ,err}3[_+k"5߶NFNt:Nt:N `m pWG3\'S.4|,}ax4a#n8E F((mU;ЭzOUhwֲjkU_h3lIY90<9p{,31Pj9`‘9V13)Q!9 pʅ&Z@ߋ(Dfț`5>qFdDJ=%4CR, մ1 xNԒ\HG~5TFP 1uSmzV@Lk   B羙Z%TSA PK/BN5UTQ9D0<ƨ uN@z4cQ٢޳[R>+.tٖ;0L_Ku[zCTD h(2v8 L1c5tH׹0Ŝyƍ#%TUZj& P 'R-QxhǦ'Z]E|ELe{;cQT ϳ,cn HIV󅆒@LL0_J`.{춮'y74NFNt:Nt:N4#~wUrA3 2&k n6 VnF4G8w@ZlBI"rwxLZ$GV@N ş kw%e1ڋj :U!ܹ)a`lZi]qk``Z? j XMg8!Z&xhai2Ʊ*@VUpF`̈́PBhb)X7ĐK(9TkC Fdy/~5%@0-:9 ya3@^[xh!jKJcJ8n#2ya0Ѯz~8WIuPq7ue$JC*5~i7nqFKm?c,KrI).UW$iyLiKvIi+WW0 @>'crytBClZ'z T_n!}5] _~>:aAQQ#i-$R ALĻ[݂ P 2?rN:Nt:Nt:NӜU-S|…rܸW_BZE:|( p-_\0#rNq0lj"F Us+9W͟EKD$DL~K}B:1k0B G8.,["7ߒV]A1&T)&DLo9., xTRSbψa!1i_S ˮ@dJ|! ؄i<` ){n-fP[U~="j K' IDAT% d!Su--`6%,=&PM4miOnQ99C*h1eҪVq ,8pfIs c;9n/WqƧp0t!g %j2`2"#>&&)|H8k\ Uv#8!"k& \j>TZ6B̐WEHd6m PR[Ln҂!HɗBj )Fh fw{%FiJX Y(V4xV%o!5-P\^V-/j!+T6 0ݐB%eXʱUox Hag^r1fKn`RRjZg7@ʉ<&ǯFM5Adֈ b %VFk(a"s5ls;&>QwU)WW<O~&8tVh(1zaĈg )s]..w1myJ!aW" JmPG2@ n%hл#GJɤt%mК^3Lvk=h39 )[[GhJ-9-I@E@gw+@FNt:Nt:N4k3a0#qNro3@Ð3Ufw 59+@1ʎd{m-.D 8(K%fHok[5k9rðU"h PsB &/Nm@b/w"&jR@U@rP1b {96! ܪ6j`L _ֱl,`@JFGT)KAJ!YϏ*b 4RmsosԦcLm!.j@[L&zl {䑷 ~"u=[4Zk&ϬƎC~K(I-I~AR@HX&RXb 8g)1p (mmq3ek˕By_˾'\FNt:Nt:N4Cc:kz[t|΄ 4xj X5U `xM3m5(N{mckEk-Kq$?\Tj>w  m-r'*U19-,FW&0V(} XQa'ZKYe|,,fFW0±J+h܊&9V_cRe7Hbq,B̻u"  KRyҒP'FJW3bkZşS_DX^GIeVkM]PbB0 ւٷIk`-9ɟ.^O7mF pa;npxM@~CʂoFY7ҐFgO( \XBmshٌpu!+tIۺZ )_{ @D 1۵-v-EJerhoFPZ"PJ3TpE;'爲7LƲ,GUD,vgpA2 9rhԒxEk)y1t^At:Nt:N|z`iڜSĔ1Y'+ fU8;<@padwa)3D@;auc"D*5KP1n℃sqykX{;Z^ChBiMoE: 9) 3c"J?WCYtNa\ jw~f^*-bqư`Eb4VQQLpgSR,v](JxB,XH1n @A#A,~_yb.N;{T^hLb0ZY_pZ߿ދɟHa_3n<*pi2/zf4DRʂx3V&vGǏTSvKjԴbmFc`LFhS1 ҔQJcW_JhɹЂA4#5m zR/LXRp;R`Y܀r(X fwM$瘚YdEk|([J|Om:k't:Nt:Nt:gf.8S5MiLȔr|]bgV_xVf q83>`d(9"TmM@[ KXglY(|C 5}ؗ ~moVr)k5?ﯫٻ@i;e>㲵Hy!Ud*砊us7v (ZE{ }͵lyU1B.R<N$BTPx[X.S#0v}&&^ Jm @L @qmdDCizc~x{ us jx|rԸ &m~|VLP2jm!v}sZdMuTkTuEH5XТYA9b:2F)䋘 @̅4]&1&ħf1ڐz}f!v&m,dHoҲbKQcMh00-@k2MPbf9%"F%!erhb0]9^uK ;S} NFNt:Nt:N ?-gKc 0 L-%`}Y<>`mmn^pvz7Gn!}nΟm`)d=󮒺4Q-yR(xncVfh: 1*^_~?Dgכ 99 )= >Ǒ%6r84N, {sj'e67SpT*4FUUUX."nnXNid@-m k5xVk?9:5V^ 1Λz~ׯGױ.b1qΌ, fx.pZpz|m& <^͠fNpa!cĈ`h,-N}ym7?KVc2xe %EJ :˖ rڒ͙v](CNQZ(9Rrܮ& R'4D˃fBhڒAvכw&{b X V cc_ĮC#e7{/:k:Nt:Nt:N3S0hH?GSNaK 8 ek5-.Al5q;|[o}<|q}/37^;< ff0vd`&dV {pc)p\ '{??s~. '}=Nj7@`9 H YT]M)?<)gDе6A)#h7'- A &^sF/ bm[~bW_҉90&YV˼OyYirST_O Kk0"1lbEhU(@;ު_y.ew{5sI%a^_m_<6Fya2Ct*ѵVHԖS[1wUmٓy&6O?mM%4=@dݼmb<߈8{ %<ddݕՇtܖ)f~M*{rk`#.Ll3(|זk7&_C];H-MFD_ϡc>$4 0~T3|?T k ә@56<:R[ @_nVl-cRNFNS7/b7K/2 )%#?nGY;F?w'7^yM>_~>#__ ۾ t:Nt:No'Sὗw7E>0# '^_Y_*iŽ {݇>7^תpzh%GBRRcdm|0rY.7&nU!;9`ep.`lNuU專FEă\^dK,Jꜭ@mwpY࣑Œ@5ZØ ^J& 91-#\9Dbd@X&;ڠb0--H @5pS[RԔt3Y\S>DDFhBq7j@ ٓaFB h\*~ 7N! ?h@H)`!#X}fu!:gZ Z' c.F (=f)Z(; &UBLpcl) <1-@; |30&}5~ٸ-.ED_qV}/2>K_?fN5ӍΧO+?//<|i#~G>pWXkr}_O?|>mo{+_%t:Nt:N{S'~Lp<^b )7jk{)82 `=v_Ú0VEa<-%䷊`*C q$EtQ]V8sI^ߋZS㦭.CAu|.g@jlZP bJDc)QcBƸTSMjsFdqR+/s#"n{Z&N@m`LJr~:j4j8y&Ƃ(S+]v*//8leK0 Ğgx>|v#yZބ#W>Yd?] <8 *9{Ў#Z :$- @ުk~dȳ\ oZmn7T>D >e>9W@Y 㶿Zm=)/ebb')6<{g{; ]t:<7J)WWi~^;-?0p=ԟzgO[M2Oq<ʯC?/Nt:Nt:|{~9o|H.9<Ǎ,hr:qܷfoxKCn!p2 n+q9Ñ3$*VOWޜ/ϯu}w~BSOfI8 I'"5>dUK{C ƴVqb!'c"ڵ"‚H*bJܪsՀ<8{˂mC3VRAeRArq>b!{: n׆'o nhU0 # b09g-~gXB .z8)Fs냸mfҮt§Ve4}L8!-(#gtß*‹b9%`5 \?y fXNs MbMڵ8}U?_TܒRBm`z5[Eh! hS:`/ԋQ((S6]mg5@T-M ߾O!usP(;YZ,"kLʗ}ONI:OKW|~7~ÿvַ_}O'>۾[QJ1#_l7t:Nt:Ng.^{9 y&M럊JׄU<{>^|Go>|ytscM`WaV!N'RE+C17Ȟ F[^9.h0WVo>F]"qs5ÈS|v8 4^ű4"4mgbMdRXim ` gV_z1}N8?7949 6_M7?Q&ӑb x+5 'q8Vc]ۘ+ [2` snm-1k5]18ܑp8mI1H߉Z M0^[d}k[9Kՠ0s!">B_CIF#5`&(jz~3lq;"#&G=rJ Hhs}[޻Gۖ\k}}TUE&ЀA-JcZ8ëCi& b:bD!h"*vŒ IDATecUݺ|s>^ (g3ksssD 9' "ՖJ+&C{i''e$ƄTWG}#<3e-:&ϩ4]i⶷Z3#\q~'>𖷼o/{؃øk!8rwggͫ_.#˾MR~@h4Fh44g[c\:v%! G]č#uql\ ;+00\ss_Y=eHz]p$>R$VU:TK se]] A3( B@΀bB8Yx'0&U%A`6fYTՀPl qt챒ڞ`o^ș=&O$)h*chHD@ ;- j"m8zw1Q@{&ez6>܏ m0 r/ X!8ǥ+Mm.c gVhi 3H#Bhp91`~eYˉD%ϗvs\? P{Lm0W~E b}h:SLE#P)nW-=ǺnzBNQSy-L8)iA1rQY !Ӓ\@)bl+$e 1_bڮ'Z?lh&Jz!\$t*͉56a@t 0?4UѸhFF?y- G⥋\G1VzWO7.jbZq7yy6YFh4Fh4>xS_}Sy;Fƣ ]Ǒ:GW]s͵-&ѝ%C kWɵQ>Db*BH Jk % YQh.ojX{ FPԋcWE4+ue7XZ#$}I~\teS4Tm}@+Щ)Ā"N(e; ZђxqP&UqŌ;nX܈6L'II$cm*`e@t-fm::!>)hF#{|(!V+Cm̢\Y藪Ct=k@c&a``.&$bpaz،8f@wLPK)JD "R#a ~31KX^O6f1$iU1- %`e筒?[Χ\KF*܃Ȝ`$D(5dN-2%nPCQ1su`V- aD#*ZmJ--tG1CI_kFqь!}/?~ G`Ww'|gǑ}y88<<nx@qǤc\̥_`kns5(&UJ?&^F%IgAdR[^2t}^  cR({ק1!IE?)Nv~wEi{Ys%@[ophh\,X `g}@׏f YאI#73KO bBKfE#.&rMPsbŎ I[ZMĀRUPqi'҆=0 LI!\ $P2k~L $%B-"!Ͽ,Iv c--IFP7DӗU3!ִA z0=ZK´4FqҌ/zs5M?s |ٗ?믿~y|'}s?x;v7(>xlŋ6ɍFh4Fh4z]kk-I!!Dvg88Xw,c(f3 % iv )wtg(SZ'6:tLA?xV Bŀ"*YvzWİkA&ǔ-Fy)t& ;Eh|%٩ߊSJ恸0pd%e_QZ1ʣ{=_*=ǛKcX1¬ ۊo>!`geEWW(TO۟AQm5?\almˠbbﵨ b{ǜ[B^@4BefSkgbΨt bؾh,B79qŻk@ ayAAP??#6}m39xS&i ~IMQ(sAb2:bT k,x|4(!)H%QR-I*`I#Ňg &Prx1@TST"`CM% BFtdD_KLǦh|LhFF>t/G~_ZZsUWq/1F^/Ν^lJpFh4Fh|g7RzBhð>DwkOc!l]Gd'<>$siRe$2bd.X1U{U3}@ij%ԧ}Air.Ɣsd.IQ !a/i@kEdWay϶Nه4VWhT=/m *иPca Bm~.b~W\OO4%> SPMVC, zNK(m,XR(IyBX b/_Z H@"UGHU.=!eN_%GJ3%G#Xp!n*1?c0S^QPL!B)]yMu20aU(}iE}=V ̚>805XZRFPT)g@f%ՎH/=Ht1 ;=@=dL@ʘ86)hC)qg}NgX%i4*h|| O{_nnᲯɟ|{sЇG=Q<_Tqh4Fh4O ?ʋ05Fvd4Zk2 q4LSֵ=4&` c&@t%piNi*舩2+ Ktn%nɄ9JQ@4;ᾗ[`"&bMsȶg_UL~a5Fk>kb5sxzPhJ^:W]yw\_w;ԍT7|xzG/ U1l쥙(Q*h-It!D'b(pu AθkrFq#4<>^گ L;0ΧϪ떄Tޟ%KK՝_!ɜŮΰ^Yp@PN;svcנ!J$%Ř1s<9RHafa$ fǍwHadٌK)))7e>.8|$k}&G7h_>yJNE|̖ɗ[J"1HH(`VFc:` jԑ-3j4e~Չ~Eagnv+%Y*,Z Ű4'.0HRc@ߟA5ZnEuޮGw=^=/h4uZ"@7ȓq|wgϞ~x^ cx3?ws_N[g׾Wx ǣHnΞ=Cn&>>6Fh4Fh4>x_ r(bd: Sۿ0n<&O)q (7kYh1f+tJq%:ػk N=jBwfi`Є܎`+;'Z(s|nA 3nFa[BLo,BҊ䏉I#<^.уHk!9RRJko҇~G{"-Qk>$:=17nGSDZ[TBJ(-0V(iPݚ3ۈˬIFL$FM39!z$"O尴H/J[-Ǭ+UJ҄$ cD^Zb@Zai`ir WM 12`|n> bS$E1)zB"JĮ4c@H',)'-wH%T _^N2,9 !~Ț,A{Mj|2@L%$1$3F1!<૟60%/o呏|W_^-ڶo~MHbYj8* = !blEhmv5R|g1^+5 K5%'*DD1ic!nB<޽$ @XI"r&D.!u5FoEu!qq鵀T4e}pxaCiA lqe=L^ R}#Ah.%և5쪎Ӊ ;ػ1s1x~IX~.c6"40F|GL@+Hm{R5d?`I Xxp 8SR1֮88rΐjr]8 ~{qUC*[V79| X(Iv 1ɺn9?x 1zTuI#%IGHZ?ESDJid&&D3wHQ(s) _r> XL)-U)%초 -2HMT/P bXMڶ`טd*1^Zv8DfWZ<8D yp|oHDŽfh4/x5\|ӟ/ɏ؏}>Ǘv]3go{W ?;ڤ5Fh4Fh|x/`alH!COLƒQZoTulÏIjۀSFc"Ы@,±rؚz#GG/0nƥ^i2e4b%%+FR*xt="N{hqBӫ-:LLLKSTbI)YiVI6m@H~'w2;ab `8mpD^\ &9TvR)豔yni7PK~ߵL.C+LC(@R#]fL;gs:@t-6$X*}L%}7]TMPcP g ?)eyG2tx\:YB@ RRa_WGTjNNF&ZP. IDATH(hU-lF$ Jc܄GiT2%I~_?ukyZh4>f6}p^Wrw~s~zky {cx~n{^qҥ#zqn^(m4Fh4F$_</}ޛyW~uXhm84#JXWkq5) z 48?3Ƥ-jxc>9'[jJATXT]Ǹ):j hTOaZGw`"e=?G5k4SKy S&萺8Y'G Jvt B)(_5rx͵s#W߮1<=gч+nnwo' i rc,uږ1U4RdҖnѰ-^ {)b0zꏡxF5E\-7qtT~BWлnEPڐ]ZFM]KSx)$=LQpi !Ri AA*M5FBJF29 HLgeR2Ĭڒh>j YB(P[lP*Ku̵_*iY7V8h4>DF>яy(WrG|3kebKty?C/MoIirug69gnki4Fh4FѨ<1 Μ_%bh%u;E"%nwc^w 1f1Fw18NP"\2)R= s7#LL&=`TPc7iDTRG$79t[ j:"< bt*$њ&|ʤYYilw]쩊o 5Dڜ2 Qޕ13l !d=F'ns,]BFlw4=Zx{b !@h ˵vZ+eQ&Pc*SZAN0mA6<2G`eI|Tdu< V=>IBL"S8H)0C 7H@IjR %@F/|1ރT5Uan {f-VXJlJa8AȊAgP=O-hJ )Y. 8,-%%@JNG}C|5x{5ϹWLϟ[~Oq}~~؎\v[o><|_5_s%3q^_˾~ugyk^IWc>[8o=h4Fh4Fc>n}K嵧X1v[!Ͼp[~D'Ƥti` @]YD˙N{~;ƨUX_kU)V]c [a13ҫ=:rq"ƉDw+@(f"aH~EV|);YFN%{U؄&8sbzsxs;^} @vH&s@1l1$,q;b&3VY~ ءĔ}q<ƅ1O,[1 =?=Jk]@ƕ$bIդ!"KςOzc^cFzC'Я^LZ!k}[@(iZ* Sl|\'de"FʭȄ- FaEefS8?8bM5 DbM9'v1Y!vmN"cǴx2m;RK*27(-p/F1%4 7PXky~w!/y/EcM7=[ny6o|x֚:Ϗxn>g}g}kG_x;9q9pxx}T'﹝n?(.?D?Ȼ6Fh4Fh4>) %Tw]#K0U1Z?3חMu܀5!a5RV~w/LyzPƘiCg/UzvX% 1m RwLG.""!ᢣWdr$#YeJv%"dfCC9f:haY_zej|^^5G˜p Zj)PDz&xȢ&Qgފϸc zǚb2wy$q1ە(@j$C=8 1U. B$aD&FצQTZJ}VqY`E.]#Vb^ΧaǐA)MaIʀ'BLȸݞDb1$G $T@cDzFaN[XZ"'9) =$ԉ99@ȮF)pA#.ѣ$zJd ,KĐk}B34O_J>s??sx~ooЇ>K֚#>yӛ~?ÿw?pz󾖯⑏|xpyG49?~-]xs91!$7Fh4FhT&bK\Q߫|T^; +/ cks7'|kAxNŐ`ǽ ݪg:Aa/Ax 1AIM\E(VCU}fIwsP0MkRz`jк+Fڢ'|sPM~_'sg!ֈ 2sz=DIh6^ Ѻ\4rE {\h10.G!"cxiP Lb}tB?cȀKE-F $a "}6={bH2Y1N@ayD4]f[~phٌyLIcps IZ2(ejZ/FjXuLs1KFD]{V&d"͍ @*e,Z B$ٶHJkj]|?Ol"إCM1#2鯹'}_F{#mIFh4Fh4Fh|\q;^N !s| [NTK֥Gݒ+qzLi@֨y_S'qN UU=sZcT}$LIB"*Pg`HW{L*Ć > Ķ =eE,39ۊ˙TdK ?cHF\^6^d]} GG1Z@i-"L]fstEFq~C/@hEBNcZC Hb~cȈv kpajL#1x~i(I#GG.bBݡ4>I|LeQR'jav+T9fl6Ǥ2!FO a{i6QjJ#",F1AEBe.#b(%ʖt JKm%B5#G.r/yo_Fͧ~C?*h4Fh4Fh4}<1/AYHaӟxi" 1:Ea@ qA 姦dn,mc.فCS1.L 'ν&xO-,Fk7IX R:Cd-CBbD$RѮ)bD+Ęj&jw`טnW '>0mxv[AW lf3,Y{ǀ-ɌVGxTckx|7\D#J)]$Luq1a_vZȣVg @mdϙir@*6-acBȼd74Hsʠf *)16h- #K`e @Y7fZ̎puJ[]ʜ0ou=gVw] l|`N~1pѣ)IZ$ Ԁ" R\6 `6,[ @-11 |PZeNHuewX.F~y7fh4Fh4Fh4R'D=FEhMwz:#_DpAҴ=q`+g+9v☀GM=wƔ2z##\b 3Ōtc Y'I@L2 cTZKi19%~v'tCdTM~dkQJmL.#] >K\u&CO1e‡Pf$ Zɽ12EhQ!qH;`ؠ"DKJռLz9gbh#0aL<2k#D %!$) +̋Xamlh%@A۔5h3*=H999"IsaQReӉvwM9:7Ti BF 0ZclY*k|񎻑h4Ch4Fh4Fh4}kcqØɪ?Ϣ<2xQXw:ecDkѫ5 9F|dk}1n)5+`5s5=,UۍJyΊ-ZP JDK \DPa؁ר{qnJi`z:|$ R@(N!Qqގ92!x u̍1aD0bH!1[*evD +,$+!sF"FHbsPzRuTk\m"dOe](:wde ˾ IBڮ5cTs;M2;kTkI1T@1Hhiȉhi²}-#!)I'T. K]|ƚs"\$~[@I ʖtBH( j;/FqҌFh4Fh4F[I„yeY!J.sTө}kqp0hI~D黦ݖ?{mUy~sϹ7 !!<#ia[@-GH <"@F@L@jt(jccR *(Q!M眽֚cyܘ㎳9\ ~.0R+mB_C$&S߬찻C?l F 2' 'Hݠ%)='\/"DYmsK}N*DNj`fpԆVGU!zGChXd\Gk%,Zo4eӌ!2g}ؚ9ZzgjoT*&q_!CPJ$}fIחئ7!U&Zˍc4DAФT*Ch%GbvN#Q гOA,1#& #29e/@ň![B[foB xhjJ) KMBH97BW8:jQ{~hW*۔jT*JRT*JRT*!Gn{ZL%ҏ|IAm$Z$:D$­.(9@ivlVmL&f gtW-*E,NJ,)>Ц8VmJRB*1PiZ+\ty4$fE6 MISi~ :׏Zgmd1L2.ccB(R=) FSD@c+ :g|er\JJ| Q c @"lTCi Q!T]Μ piRR@@7b<_7^UJi,s20JC)CMӢX1yrĴGVِٮMG3 ƘJBE1E8R XKc 0$4CJK$9`tSD0 %JJ9M d~ȍ gé)҅i ð~T*-PT*JRT*JRT*;$9{S+$ tRm5uve 4FHNx*V'x0\>jR=*#zVVO4P-FEuLz>froNӊcoxeQ{0[4ٕ=nU8ʃ2-Z@iJR8VzuCe"= رmPAeV!&lhq|YaDI!@)=VK+Mr^v&ံޜ&!,M#J[mZB1yd΄Ku>;b҄JO%A2Vl-Q 1aH $> tol%2elJ#eDȟ4E&BpBCj;(#`:Bϫ&JrSJRT*JRT*JR"8\m%xG%߇R-ݸ,$+&X\ A=Ɓ `n9I;R}&as˜j b0 zWqlOǫ0N)s(EH=EKq 5 B?Pi]l67*[zEAt,۵1-3N|)F,@ wh23_#ZknX00 o%CCɃ}2֒T|1z! ?`jRt4I@NmtɩeamQIBHS.=̯RqL@,Xݩ j`bm ZF@7Se]([ 9DB@s2#yX @T}9T"LRL@IUf& IDATY-3`4 E> vo~PW*ۅjT*JRT*JRT*LF]y>.rO-Y70[IAn&{APZ>*OQ D<  $%o&!mL}a1x #cHAm X=1O]i.R*)OV`m8 рi $~m BQJX,躎,*HBe| EWbaO-ǀVyHa!Ex$zϑvmca1*dHǸ;hr.c)(YP d$Pso}Rkr_Yȣ ZrtxJz Vz4ЄжE@h~Xt[0%DR,|W$(}JbȒ@I1)Iq# (m; x^)Ha$Ka ň h׏JrPJRT*JRT*JRC"@kE ގb]yo鐋 $ߓukUܱi6N[y,=^+ln(TD=$O&6ΓB, SI?"*EEEF r~Q- &0 Ljg|{S"7*'6c`VMAbeeqL".FSh !6y7?ƾ@tEp;b F@G")?LwȚLR4@#fs yHьB( !+c둶[lR>Mc!kav!ۦ1=!& ,g#med (O)epmWҠ4o.gr62(Y呲x5Ǘƶ )keIhҘ eُ\FҚ $) PDL "AL)z}OT*PT*JRT*JRT*;$! Tz:˜pA D0`tb$q7l<η<.DhE{%do>@A"F[~Atq1Fbh ezJJJ)`tO Bƀ0S\6n`Ā7m2!Kfٶ`+(έDnŢ18eP@#Ge~,#z cB)c kV=E7hH& CcВSx-*<2k2Ȃ IdB.1.&Qw=ncO{!^Z6%jߴ0Z6 IZ$i"R,Ϗ2*I cҳb2H '0)"%E(9DRئQ; ZBCl dfey}2kLbs|-eT*ۃjT*JRT*JRT*OUA(q("[bpr( ;S$}Eo(BaE/MvWk/cH٦K5~EI؟ [nẺ,Qvfmrg}@iX,"A+ڎ"FYXK7ldj93XZ@$(-|ƀl ^vyd~e!3RHJpاp̤$hd. UeG񱛏qbFޗ)1 NSt)&<-ZC,cUݴYYAt=]\䃓4 )#z- rR$K{McbcK2PŨG1~4AhʥUFbi醾\#˱ǛA氾NaT5Rm|}9#ԕJv*JRT*JRT*Jr$"?;h|Di!$9!³syױJ[bp(>zhiLI Gޱ &1FXG W%1EKwpCaE1xaа|siu\ B[8ƭ7;_6=MͯMfS[D_{PL{(YGvʶk`4tԂ COR:{KoB`Ĕc9"FP-±<2ƘbF堌4(R qhېb$Q S ka  `@mєccDGb.\%FYNd@b̂C$ii(3Slrt'9FD?WЦE+ bw=&#e1jj a2eˠĎ6´4BغOB)/٠`aE `,D4H"RҺb2#ڪ\Tnwj.IRT*JRT*JRTp!/("Xbf~}9+sb5 #GB,3@c,3 !v~Չ>"SDC< 9G34D0lxZLbnsJ6 "q5ńQiJ@nxpj?n0L `!},v>e,h4(bah%{Ϫ#>q 5V 1d;R*&qcnl>ďt .1HiQJĄKz=$?C(-v{87FڨnK=b*|$2>$(X= 9҆.:_*v9 ,0f Zhl8=moLV&lSZ>$qII1z\ Q|,cZFzFmR c@VPg[6.p ƖRMn'EwJrSJNj>q͟ߩw~uWu:*ޮ:V*JRT*(ioH{9"Av~Xbf5$VCbg5rtQ6`6D HaklnW+qsجR *6TGƚZ`O))o>ü>dric_yM98 c|۬?tcˀS-6Qs{eg@ '&3@.VAԂlL `oXѕT!D8vA0DIw!u3-THcD#.iHA(OXN}I)3G^i҆0;mJb(ih0Zcz|H CٕJfRV|~;RZG/BiD|JSWvp 떬qncnۢ!`24Y̯ 1y8ȪAEVP+ϒH{kٙz+D7L}Ig0pdUWL a fQ?+Am P܆GhPi[ F("gp|čb!f4c̑>s6}@ꄱ&*& @KNEX>0S##aL>@Ӏw$vzZDq:b RsH}G=ʴ Vx\(J)BkQٕVRC E:dc)2>ЯCoA'g:%ajeB1)TBR U)1^qsIHl>Cuz ( Gb`c30 !^>f)b1qI@1i( 114S?L@J=TlsJ؈?D)Z `ݎ|.vsvA&G`BtDQ) st>bt$3?ΕJ匠*ۈ> ?_Iȑ#9r|~+o|+u*?/lso[GUT*JRT\W̏?'/W9:BW7Je( k5E*C\G1eݼNEdЗ!f֣^a(I%!cw뤂&ӇV׼ׂbU0RDoI87gPBԆdz/H`Z$JZHn)Эxޗ1L)0a }MOvM1lp@ʺ1ITimH12iYh 6 BcbH)3Xjo g34$ =YYjQC[ mZϧ~`NpʠÊ*'hdȵZB# >dž̇(qP %9 b0D;A+@QN; DcGJhh&* 1Fi2agp~(W*3jTnrqIam[:,vww8sx78~7~N56_=\sM{)/i׉|s_RJR?'*JRT*?i.{KO^3J B01NbiBYZʒ+f|JmT8aZn$xX<4BDZ5ʹQ)D(6Ms?`Sw~(K;=Jwsc<hfi>l5!K1AZa=r^ Y`կj~ vqS H#d$2f@mqΕqRc xo!`">eRNH!Ep!5X̦ .zvFږ nje1Z,TVE@ıpF)I2{R*֔<|de} ETF ?&XFQq ZDao)&^*pDo Z(D9kiEm.VA@bA1Lƅ<&V@R9FJ6ɓ|G>w?-3?lgg3tF7_|6W~EPTDRT*JR2ુ"![ǥj c>j jzB #.v"M1j%JeP}1a6Li)DBaI:ZW#4%-18TLGb2ajۣ[1b4 ƹ&MC?8A9%fRtz\ | qo7Elm-t]7VlkFxFsH="}ן8۲8Xyvw5pJ!11`T@k"!Q4$N\/6\~N0RpQ撘`Fc"y:]. #oZ,A >Ddr,؏kƔк$DJ1 \5fa>roDWDyeQ{E|^"4Z1Fa2BhJa44JǟÖ9@bBPRATfkG?08rTi .ISA=?PdT*g PFmo{._?80t=}8oZ@5[ᆎHBzxȠ,ZkFd4,Ө9@-F ituSnuIAN!-BA$]8wz{[f<w7[V~y/_= 9sy7p?xySg< ~Rk5?~Nݧ> <½} {;;K,\?~kw:ϧ> |>{^,Ks|+_]]vPү>Ya ^sC۶V+;?q^Wqĭ}?c=*JRT*? 6M|<߻-B4B7RGq':" EjF+Zx~1)z4,P*4=5riCs Ki\O %Hնr# JὟc'mC~!D:>7LS?L0*SK߳>'N{MkZ:#&0N,l-sao㘆0`Zr*f X S+fB?7mXh+\Wg%a=XObW)eBIT'HCB$#a-ZNE'} Z\d$:J  v̈ѱlld)"']ȱP;,D ,(m0i>3-Po $Rj]b*$i3$RڢS15l2H1l;RYT#@rKwι-̷~?UJ. ^]#px&㟼m|e᪫^Crߟ\~xγ6Xoi.rqz׻?x{X}ї^z)|}ǡo}i\.Y.nwa{xq^sX,\|={9?$rmҶ->ȣ6^Wfgֽ .­׏9‘#Gyhn)n4W+JRT*ʙ_OUhe"#Ԓ'(\Vh]LNtAbB$B rX fIb[l!!#JքѣĠK҄0j[7czKe2"ŌVň0quye,r}dcDž̪לeu$Vrhrpp=|r\U#1FawOn A FV۴-}NNs'Y4$JO?!꘶]ēISBD- cZy%I'$*2y]`X ! .&u;:(0??_s7pg!rkkk./~!Ogv=~7~ .`E/r/2Ws`>o#/os{~e(.жqsru>;)%|#cf0BPcy<2Ii-:$ !XHct;z|ԘmQ2BH3ʬ}4s"ڴ(X4c-o:3L}cwvj="bjԧA dױJs?3IӚ\L/[M)2 z Vp"J8ܧ/'Ð,2H +U°O.FF?(QJ""j)SS( Ƌ .c@1hbcHUu< ā~ #ZK\hO#JJAi#~"DV% aWY/k'aqYBd$FO0 i-xihMk w^ǟg^ϼwmz!O|'nyKq/qK͏zo/?cǎ6{훶S=)-}?R֚_JRT*Jrs!/?KBJ(^FQE2@xdq9$λSBKv(i#ʔ;DR_)P]1"ʆ8].heZ@"}G$B&ƀCbư6 C9:`E8R 11aHV(-䌌hݠI Q{ApnXC*Le9Нօ, SC~8* tCQ0ށO3j9GTTD FA>)1%!D 1(}XQne=&Ӷ,q<c[۔pa 1Bw>bb8O1DQpu1ad R s%>܈ޗJ7!o>7S0 j[;hͪOk7jI;0emX[T*3 YR}箻72~gAя~O=gK`x֏'N%q;s~]@ k^G>wqԧ>9{\Te~<|“Gn^ >ݶ-O'ZA:,?oW]}v^K.L/^eg+~n(⩷꼯wo?~g?빇LoG_ݷZ[[~3JRT*JOߎ!x$aX4Ʈ'iZtąLJ)2&QDZj1b.aF1 c O!B{JuVtCrK!LøRcU&zܰbi;;;0JT3-378<W8bcZA;?B9o2 DDhX߿Ҳv,?Y`bh0}tnv "dV,Ih,- "q4-rpqbq_p0& X-o~zt$hm2YH(mhKiAУ@?^ (B̳@uԾ@ct@,F C6YЅ2NFda`A2j-4/)Jc2@iyO=RKۃmBW~V*3jT0>x_=$Tn}W+g?pmǭc?nr?ooCӍ7޸|`~Of~gsS{}'z}_淐s}}S]|_ve꼯yoe߷xgJRT*J> zVdSi0IYHUbN aK2@$¶㌉8nTiܢb7WYe!뒄1&hf#--XZi Pʌ&b\y{.c;R*O)51U V9Ewy2{GR__ՔŢѣrOggg:PxM;3OXLz5?~{+_.*CÌjm֮ۿcWcMY7|gO>رc7G~^{1t~NpKzoyj+o[~3JRT*Jrb3-`O Y#0`q\QXHՂ!F`%BhU}OJ@ u;+ڝ# #@ei%8J`j.~G$}" k{OL%!!%EG:#Q,I @FHaX NvII,ڞL=" ,)=i#~JX,w]ڝ,9kWߜ^eXyؠaJD0C,)c|#n: X]X? ֲ!:(O Xm2Gibѐ]}ѕY%qf[04;F&9`&xRjR sF+ HY"F?#1W"Ai(ʹ!RQ @LZC R:ycS8*JL*ۉ'z?\p~ݾ|`݂xo6*oDesA}׾J^+b(S\q=^gZzܣyS¥>ݯocouL77xz7f};W2#`Xp]*g޽/}Ptm-y?jRT*JRL+?y1i($MX 50lXhB$d9B% %AHTdu5.@xZ۴ﰶw\{hɲs"̛BqJiAy(jKFh崶2Km^άN7464vckό4"ͣ*2{qu}"ʪ]UiZq"bs{}ucmwbl:{̗J*b U  9͖V=^\Fnf2ޖrd[@k ,E1؝FEUOa>1i`j98ݻ .L͎mlؚof7Zִm32d%ٸKYn'ǒ" 2/`XdXO8xg˴2W(oR : CFC!Qb?Zmx(TPHxADQ@ظHE!1A<`4Џ%bkPz;/!ӄmO_jD#H$"~5~麎,xKyibF?wD[O{Ҧ_<\긽sA,gӟ nOsvAޛϮfy>?d. OGOym/O\?\M$D"H$|s'?TuMYH|F)_g[`x:poym,~Nn|W {A[,d к&(%|жmw8 x<ؾ(+2cM5=m7S%Z/>9Xg*~5Ax瘷ZKSBibAmM_ghhGy w_A>ca;>PkC bp DQ8 tB[K藴mc~{ki8 !(Jup,ONN2GldVHd0XCۄ E'D "۸1Z):nkB<6JIq 1B!P ,Mײh,)5R)% FE2 cS^EJ_V+B8чB`]`ztBK$WewyK<9H)گ}owp_i??}Ӌ_߸q 뮻x׻~w|`SE/zWӭ}%&B0E׶1S6GH$D"H$fnY~*Oyxs !# cF5~\n2]c}z! B2! bAjZ' 3vx?` cYR}*e 6 'vt]bš3`Q㮘?[H:b%gE 3@OϔBDׂ\gzͲpm5V>o4mt! U!(B "?5}.A+͘&#%:0Zc;8~;B&؃Ʉ&4`eWϝoO,N/;() `C{>*Bi DӉ Ct*pe;d%"O`, It"cb3DDJkz?ңcb=8&J/D"qUqa$W#<9౏}l}=RJ˗WiblDm]?x?{h/xH|,h6sq/?NtmRx8D"H$D"H\ɓ, s aP'mP8G}c axޓe ޞp96l+e{tZc=֞b >x|I[T gp K44MKgPm w1OqP3&.JۺGӍ@ _"LDEYZ_\m3/a.'<)9K%30 BD|hd1+^jA"~(<}o r¼8\:ўuQ0o349{xq ,WAT@jjXVh f`|}ظb*O~V)C }pz b3,W-U˅Gsy܏}B=N>&LF:$-7}V-<'1M) +LJʧ]Gjc`hι'>Οw?A}>3g\#fg󙟷Y 9ḇrBk W"H$D"H<<_ӞÜ zd>e "&c]q7[ l]~ y;Ƣ:pc}`?^U >v&xvˇRi]х-8<ĢBKQhQQSru_{vUnZ+\NSXy*]N*x7; { >޿&~|i΍nHhqn϶c9ﳒvl^whZqm DX>by\F1C(:7ڞ\ X2a&>G׀G]"#2z0CHEJdJҏZBH7UXh-fCKb3F<7AFQt H$h"I⋿?v_/iO{G>OhWeݿy4xǯo{ze!_xvW?}3oMvw?<]ng>s=G7Hy=>ίD"H$D"x8Y<9/.;L1JN0L5\91*3 4f ׽Xp~"a`vݖ:.Y]st0ڧ82_d@/+kնTbcgs#7Yp#v]wAO)|uޠ0m / UKasƛN'L'QlQuyܧ(lbPuO܍72ӛe rPA@M+qa70%Z)F8c IDAT9}Cc8)0.M@X"Z;ӑcdžNo9OBrԘoP2n X@W'yΩLfy+0X-9;dv̋UKZE)H\$!@"I}^|{Wjgw|B?}n8y\wݙ4YPk^ɷ?1W~/]Wr>m}۷pmk~x>~9:s{^|M<Ͻfy?h!~ޗ]]𿳷oږ_7^Ȼnfkz|4_D"H$D"HE?g}xWW{熽 Bj6>u]& `Y3 *GYXVԉ}W*ń%@c$UUWS }߳QDu;{ª+tR1TT]h/.y=y=яqyG|3E 84 m۳\EŢ#b^3Ԝ-`>Νme;pr9x#rrԧ]O}N=F3gѻѻ5r# hJx`-b$B<_@Ȓly@5+b $2q+ rlp!Å 'Q F0 M"J$W-*MA"!z+z+ _ˇ, Euםo4yW/7,_=锗{yɋ__~#VKxc_w)>?/{7t3?ﺢ|G_konC-¿}˛ ._̤y1 }{ySo~3,~n4͊n'e=|MqW굼5( z^>>Ʉ'=I'2ݿ{+?f_rw'>)%~| =x|ɇu=p_+H$D"H$>_8}V{1~e2 A0l!"߈;d(rzJ8G$y5*Gǁ1@^{k@Q=3c?tC#CU0 g/yp0% 3+r&jWhv rtQ#D5o`189@ `zeIֶ4bJŬo:r (P`c`C`1.eqgH dɰWr)pflsl|-2shޑq_bmwckzG ! Ycy4.Pg;DG/< #I_:D& OmȲ  |;_4qW!|Y'b.^ lg=g=W_1!/'&/z R2N'x1>_ruW#̙3|<\o&y͛0 q9~0N)<;O毩~GtYyϟ<ګZ'p~%D"H$ħ۞'T=!yB*P d@J0P Ch?F\w9/eBybv#[񷝣Ac,y5Em-=ji'^byC={pol?Θf\0穦*݋4J~*X*1>o3`ۯl=w/rS{#$W9঳5UUNgM*<0Jn}8aM n t"d ==c鈙D4 /g}J0`v6Yz-u{-;¡jAQ(%q@) `_%sz"-nRj6@xo79(( :`:ڶYȚ*/o &H\$!@"I⫾/?6nfN>M]hӶ-/^ ~oIy|/b˾=!s,K.^>A~_s?ٟ9/|LB,K>;? _^=q{ko'_;M̶W^muם( s>7 ?8G#['DN:SR[>]5p~%D"H$ħ{|A3&s=5Ls5یho Te_ 0??r>C&eq~dn| {>m]2v% &}O)Q 2?r,.2ob@{۝U9FrYb0E1BmTd+ N7m}ܞВo)m;'1h>XC Th:;nYй gzT"xm_ X5.-QMytp~{KظHDgL okA"`6#ysKo-{DNkg5HECuo6nΟD@r,WC,Pؖg^OH$z'~25?R"H$)~wƭ ?4)DD"H$D"1}#EQ9K33@1) 먻!6N=p `>mZ vPUy5Ŵ1>R=ۯK'Up0ț}Lo8w2ZO`[63q|7?3X2@]>d~xq#b ] V]{7a('5SIUOi%b@NRQ)F:ɜM{s0?: i( c6~& 5uJt^sV[,c,q|Ϫj&\MiB.]ޡ2y8qb gͺKJ2E~~%zEpb<f"cNNp),3aK?/D")q>!?KD"Ǚ3׍7w I$H$D"H$c| y7r' q1+\HAHQ$m~ISkO =#Zc~ k9ۯV< eG V-I NjnNѫxZKt;{~m bй}XtGG4c]G]h*i R]QJDE!ZXATTΑ]NX:m 7cƂEApOͤ,"3gp"Mp<%3ȕ?4_ĺE,x@ JdؐD Z(t>Ep *7HrOCJfH3M7Qr\୥t>3Ap(4I$k$H$([s=]II$H$D"H$@`q(.52kRB 9 ŨY.ui11H^Ur#X>3>C3TB{zugUדArUg~8TvX8؝Y8Zƀ`c,Kq4vh1 Zz*ޫ1x"8_]ttX$? 8Q \59E z*0rpf[7dJ,p~` P"K6. dJRa +-AI~0xSe<;;Ⲹ--'aV ߕghy3bw 6f ¯~T̒m$sGd.f/hf[5@IY0) d\1:&/W?媥9Ao 7_Ѷ)'5f`j>//X,K\ym'oZP1smF굎~w!Bq8*jt6cgwog=5[WC. ǰPpfy狱Lm(N0.D1 b}2 `B(pymN,ېюRyTz@ͶvE .vNEo,q=E$kH$kd³lgяw}]oO}*iTWu]_&-Hc"H$D"H$/_,}}΍"~PP)5ܥΏ 4.d#Eة3o: O]#&JNcv\^ba% }$g$2O:vk߭.\:h3;h;+):n'W%AN(Jm"\W!EYQOw8}je c` :V0mi: lc]I.%w Dcͮ_S`ukɑ;p[SIXPsDۯd#UB.aN9#vSb>)*'XCwd5"WM;٬޸m۳8=˜u>:{\.i;ڻ]2yR (QYOmpAXH%o5WanjɄfCe2ڱ=3=4D"H$D"H$D"HwƖ,.2obcYEsE{cwRȃ Ppة3IΩ=2wK+U=rE$ھ?TT]fC`)NMe]㆜B%ZČadM()@UUH]"B?fCQm̾ZK8]y.{/5nJ KK@,- Dʭaubw1W?c{}&L0g|ϵ`=!peESqXv`]@[VF1X^I_Dq>!?KD"H$D"H$D"H<8_ ~?bS7Fw 1h tެ T3İĶ۶gwzҎh5o/'5!P\'~v..Zn:[3ov +v(Ǡ`,Of?f79TRr[bw ESl]!bߝhxb>mvHKUd{f.pص IqDD;vKL( ߇9fciT 9$$Q -dhc# p gx8g`4_?-l6cY,Mq^%7 !ɕ,3`"?,/g9`wWRX H\s$!@"H$D"H$D"H$3_oC1j+WK[+N8@tXSUkaWG~B&rvjm٬Pm=6WC9 UC97.?.X8ڡ=NNJ*N"-2 6@ǚ|TX?{E},[̤o.](Ρ1L ɋar| BTczGc5s ? LQƀ-8LƀP ȅ[Ԛ\MX3@`H)G@tX&d +251E:V|>Pƀlq|')8- /|0}$k$H$D"H$D"H$D"W˾6;wCY(+U08;ڥm{j}?A(&;tDj\ZZ*z9`Y &j;V`Θ7IŅC&e39evrsVeRkB1\ĘP\ =R).4C8Rlq sG'hP9*v~!M٬=NaDυV0) +l,:r\^ooVZqUXؔJ9+01@ 7ZwǂqG%:d1K('֨7Xλw7#98T;i+P wǜ'lޗć @3, J`]4z:=p"/KZ,08#gR+t9u9O_{+ ?9Jf Kdd~lbـuY,ږYzbO$"MA"H$D"H$D"H$|kymK)/p1neP'7Tlw [\j,{Yi73vz.}/Eü6"%Ţ]@TmǍg;uƬr*.&"Tw/I IDATlZjmBwƸåc1]3gyv*QJ u9w4vp 9i|X{MH }f wH *˒/ZG3lf' =օMiv:yuu `䡡u9 ê陯z 9hC>: ȍ@ﶮ1M @A ^rJ^(+/"O$  H$D"H$D"H$Z ]Q 0/%xʩUux 3a+X/JK&rfe?š┽y;:ₖ,W 7קU fPUU(ULݺpwH!"x5 cV MWPTŦkqejSU%ՙk<<6kq~]G1AcP(xΙhh k4,MIN %|5*"XZD1Q젙(u@JTк s_S:KhS&zBߋc !k[,@:D׿ŝH$#@"H$D"H$D"H$G벘0u|!ӧb}=`WF77"Yi٩2rș)NCxEѢ;ahXZCE1,qWK@j eFֶh{dH: qB2-A;2Ʋໆ&T\j&d.hYQ^ j3-]ۺ8L6cX4XN.ʒ>Ot"HD"H$D"H$D"H$17[qb0+p*[vfn,:c(X[5=s6r_,ljV] jzB[1fyXY\6]G BkK!5ȋym.c*AQMqR k(c8G3 h Й L*y7]ǟ/={`pahƀ9Ms|rX/9ZE1Vj݋c\8!Jc7$Pi] ř]8Ut1'k w-?P .YJ$D"H$D"H$D"O,iX5) 8(D*>#xنpqͲcZV&2W BW% MojLbEh'l@3I.AktV6ONez:E9¯({bЭ]w>C)ELHb@"<ɲ,XQBw\0]3?:dh%2H)ɵF E_rd7CT3;gQ<074va6Ax=[rb;PXG0g zgAWX1y9B0Z Y.LCr,n&1I! IBta\K 2VLyRFHT9+xlL譥>U/N$rN9ܗD"H$D"H$D"H<|_oG82f('|d>zRm\j04qGy3li,'P6mUlSg,W;+˜U-b%XC9jmOLj4(8ⲩ)K,slsSӁLMɵ4(X} d:Y. ǚ aFvRXkik m}/QJ"2A}js}` B3̔C{bϱ 0c)rQN?5Z0qlAǰ381ZGᇷ 54aB-VA E-K?lO?ECqZ)+u%;vOD(IDgfԲ= o;<*gj2%Bz"EW Xe;*6캶UQV]*׆P(%>-SLr!$~=Μ̙ )fU2n3? @;z#Փ񥫨F5* 'ŗH`mv~QȭM9UhR&̩{*Tr(/ˡ"[)3C%OqS}t͖lJf+Tkp$xo5'0k *TഅTdVSgm$w.TY]ui IH@pVeqBY1l咤$mIXSJ_JJ&]>XUZu$KFnU.TjF&brvLR)H<@NbL$59RUi2{di t+\- 4m tF}_!ZbEy||KجQ,լhخ`(H4*#!9,6&}S_6z"Q)P㕥6H* $ْ\ *%I_Eղ*wMd$H>yjR$ve–"Udv%T)wE~b$mAu0Б.9䌷%.# )uʪ’"̵̒DQYlR`D%%H.$ Dj<_VSM5^Rcצ eec' x}Jug$'?O-E)/#`PWzUᮒ/$TeH iDUT|J=VRl6"|!Ta rʦ$5ٓdl(R"gC52lSFhX1YeؕbޥWS@gg 0~\e$ufI%$IJ oVR"a9S Eint͔$9uM8rZ\VK2J U6UVV)VS*?45cѠ-!S,lf5S(Th8(j WDHXHD1[R Qj~g/bH;sb.5^:UQѦy|A=#YNCh6TJ|-dS䤄xy̽y~I-Z%I*I5RCF[[8!!@' IRu6_/R,T0Q4_bbj"ᰢ]UY`(@CxCM0EIR43Bx"B@] K;ϝx {[zOI?Z6UT\!IսQKWƫ)%D,^,^]XDaEPD]9-_2eJe~o]2A $I2\5 e*떡dKQS_`PY!OQI$jdP($"ɤ"yII6)._&ٍ PrDՐv>sJ6U)VƤJf\kVKJjrNCn8NSr%??PU:W$uh֘b6B59 dNNVRr PJ$, *jIA[TS -#_ԥTIHDQ[7* v1$ ?$|re))_KWU*XV`muPPC+Pum>Ñ/*4 8#vݳ2j?K8֖.*3`Kri J6 gKJH2C5^>%ꞕ%MnKŢAUl|*t)۝ǪW)jOg}`Ptg?XTT KKRS쯫&А7Ld,d@gg*GOϊ*S)RIAEdS$W9*jdV~zdXe2ەf$UԘSEIayavTgKy`R2d*.FIͽ&œK+螕\4*j$UvjU2社'm5[TnC))UT$nd_> .i ( KJ*CM%U hb@4uΔ$.)z*xECIfQvF2][UQ[Wr2M~]4)VAEj"@ y`UU<^J*UZV.>9v)W]EJ d_bN]uM *q(Qeu@Ag2-^Yӻ*Tswl]vS Gdz&oJfz5qpp |~dV ɝ!Te1bRU{$7C]M=Gf#5JT$ *U͒x*g+|`6E4M"MUppd'c3^3$Ie1ggbRJ23v9$HPd5\I;ƲIDx- VhnY\V6 1Oի XD+33fիWOS￿m>G$n.UʝzG9VǏ$mڴAƺw.IbZ`.qG=yߙSYi[.Cs>ڠA$mXi='~!CjV5nX]suï0}+նpӌ$❒]:dXT 4H|{| ؕ`QlDŽ4IRrrv9a2/=z0fo;nj9.( XՖFtIRyy,^/3*٬ѣ[o.hVLx2׮]i='~}%%UTT?d2tGr8f>?Lc-LZo0aq֭Z$ƍUQQ)բledt$UWW˱]vyy&MO-a&I:^àC=zal/\P_vc69kWh IDAT=$ 0@>h-lw5hn}f}nV#i+jѢE={2{~:ӴaC~ d l~ꃓ76b矍?=SfjS 2֯{-W(;;[_d^]֪ *B!ƻ ._222W4.o]̩_bd2)7wovY3pXO>9W'N[$=<_:TX`^>Ŷo0־NNNnͿ_zLcǎmJ;fS&k#5x *99YpXZ~.\3gx(w4tP)\K,-ܩ҄Ϗ;R~yIue^'|$Bn;iDIR0՟`0zewNhrrԭ[FIN]=~Ǝ#5nk̘umw5kBhTVU?,45+KGy SOkBP/WuuRRR^3% $8Hc7tmW)++Kd}r c~.]Z}wQK3&yQ=Cl3f

|iL90]yF_|uDnns?4lذ&Yf(%%E^{u%'ϵH$5^nƛ.יgҔaÆiGn(c 'olVV8 I!K.Ս7ܻӏ1SPR]R6_յk< u9Hƍfb1LWwqN= )..VJJ,&M:\On][V}vG}[C쳷xcl1Pwutč7]mѦMۚԑ_\XhXduM=zO]5 cǫ4]B=))I ,{WxӮ'>q}8fm[jX 7jsgei2/^a @"lqc#k)")>Sئs4 /YttKM'{#{#ꝱǟZ_ g6ƌPjj<[QQ%^rGy钋ymMnшի5mYMž4lP?^EG1M /7JP=fuGi$()*Ŗf̸TNS?G'tuGH9Sܭ:n..#Qi;rL,YR |I&js\^34gƚ: U}ωܹ.ױbM6 zg[n[Uiٳ1{PO}A)[<ԅgh/dԵkv6V;sbK_r4ƍu:kZ2->C @"3]`{4,_^nny z5{ȈCzmZ_>+;h6HN? EQ{?Mh5k Va=~aY<~mKK8s/j2 %{ GL$y^]p-òeˍݳt|Օ_U^sg~X՞='&r4tk7G}~G7x1WZ_z7}/Ir:闵=ɲZf&{[nK{gzdn޼Y3f\bUc{;:iF”$ 4H#G4>|OWVUk֬ќ9Our|>_}G9Y @gcwy,`4,Kѓuѓ[lS9ڦO>j+)):䓌K. /Cw„1LвV\ʔ_'{ZͶ8~ǣot31/lvcnFr[iZ++-HwPrr233tLw#7UhK]@$|>edtdҾR޺m ^tdž v(/ջᄃSN9Y&I\sdIRUU.ωM 3PEŬf+paÆnsjӦb=iG*--MIIIsl6UH<O}G9K.=˨0SPQcN_C߾Z֭0D6ĉJJJڦ`P˖-?O>f1APNӠAy{OۅEnWqqISC{ׯҚ۽z2Ұ4GL ^נAUUUzp3-o8+_RR>}f)G/'t$c}jϧv]{ >أ/6js~9|vgl/;}L\=vwn$~qǝzϷ9Qo5 2D/~'-YDo[>W.4S}ӮQi;TC QfffͺNP(aL74qҁ:唓4lXvbSZZ.}jG}Nl3s\C%,V @"Zӯ}4S Puu~ɩ_ymi(=>볼\k% P???C>;jHc{kg/^v?tag:VK^8Tfs|eۖ! KFm 3!wg裏mFx..~a;od3[CD>C_~Eݻw}ND5{9Q(''GL&5j(5Jg}֯_>L7̺]DC//^_|1+&ݮiNm~Nqc(**jc#>'teg #·8rlBE͛7wchDֿn5k4Sw9bO 6իYV_yym_]K'X47SzРҥ>V~W8Xz[א!Cՠ'X `N +TTC @c$h387los{mLjj]UYCn~,YU٤4Jw4S/p5;3K0 ￿X\Gp맜R_z7Pv4vءƾjѣ$9CqT؊m5&f\qN?4fb1N8}yNl7}t蠃l$j9RϺD:1S&3Ѩ~R]]mlUAsy'렃$~?ܳ7{$iɒivĖ.׸zt\GRٱ313]23K2ZjpHJ7nsa P8îgAJK'TVV猌 I>i>27nl!]KNm6:j8xk=5&ۼ ׎r]ӭnJOOo4v۹'7׺uλkDK~X\|>86MĞ{3KKKzuAjߟޤID pŕKk{v4V;sbKwHREEcG8H*wdI |1jHchӶYyټ;¸qci~sm>_M{=4|pcݺbuXKڵ<=kmh9ԭ[} $̜y1+Ǟn]Jp)ccn׫w=cP>w4G[0Ho11ĉ1rcK/^xQXLR'E޺b czJY[!I{Wb[nqǙ2f}QjG{Nl3_ANK~P$ir,chD+**Z=;~[ᅴݚV^ml_<0_3bǭ*IUUU=s,ЬY3l%iͶm8cmW/5a˜m!n5qFIҠA7nР:{Y מ˗k窫.3fWWW{[a\PP_a[ |2&8rfͺޘh"u楒'I?sjĶ0`@g˚6=zַv&T(XxI h3>TԚ";;Xω-{l}5yԿZ0VF#:ȱmz,U6m&h85K?2.Ky@9ť[oJ翦޽{mv{ݵkVK޺BcƣfoMxzչ5>" 7oxNNA9*+sժ[n`{nw?ԩǷ:4icFhStìx 6C _Gwf!7. u~~; {05>f>&3BwqR%I?8 mzEQI5rԠv{N|s#嗟dee}6N]YYʕ+l=ݺ2EEE{5LV8HuJx?7^}(}G9Wg$_^ >5R#jQnnؑz{kNHkcHd FP~{ǪGƳxOOO̫'_\\g~V?PK_k9fl4nk˽?Mq钤=z֚5kTTTh4zlY,I/-rr6ruE7rN9dk׮=pC[m |}FjժwFq|ӻ,l1q};]VsAW)QFphƌK5 9Taa TYYY|誁*99I0ԓOՅm6&;~JQGMґGa[V*;;+]EEnF}e O_~E~_YYY}p8/e,TumvDCSLiot̺n' icHD"V6u?o3:F}͢fL]xRRRud2vkȑMm8%}⳱sss>h2@^xE͜yQNN$)꣏>RqqPTÇilo[:IX,Z4h jzvvYY>m:~͘1CGrAnY_}V`B]v٥2NA|w/j@3Jx[j=ׯ_/٘ j4[G#Αf_}u>'r7իz1O4QFeU-b1-_Bwn}޿۵C4l0IRn݌Č:+Wԍ7ܢt 5ݑjGN\yƟ;yyy/D?p3gbQ߾}_PPur,H͎u>֖[c͚31~JMMnS8VUU {ަ|Awqť6L]vf3Ejկ~_R(ִiO>lzZn|-=zG7Я6@ …S.ݮH$"ϧ2]V_}m:ӌrWO<<84())IG7n{r~MO>D/]S~G555zǷ}D쬭 /9眥wWzzZLrǣO>MDiibVh_oiܸl>vsbnyu'{o,lVy^OE IDATɧL5U]-iv$MrQIO Oׄ Էo_.l6AUVVjÆ Z[=8v;zCZjוmbQYY?(octU3 //n:8( ~k]!39lcyC?'9:_V#Щi:yyy91sݻ$iڵzp3|AZJ:c;\ӧ_ ٬H${NСi$}'|A Nɹ);;[d)O? /Iω뮽/F"ә0ŋ3/cK )OF"'Ns.Ly@'B"t"$Љ@'B"..L]qŌf *..w K%I,P={$͟.躭KzUH~W2~J߇GC 1/^c,PVV$om:IjIUzzNf^}RWE]("IڸqFv^34}:!ۭp8r-_\sVǏ@$ɛbZvvտ7V\s>k-ZHSă6G3uU׶u=awͶ;C|=`JMMUjj >H=~|'L=Rwn5nܸ]᜿.P]tIxb({gkҕW^O>&4ַocd/_nb YF~hk)Iz $Iz Sl\7tƎ#I Bd6Ciii effjM4p#X^UU'L_|Nƍ3>Da-_B+WTiiѾz|7L^{G֭kk8St3$h4<-[\ ݺu5\ŏ@T;Q_p.Fmre'{!I0`|>Zb\RÇ$}dM焩GjڴSW_}M=zdJxbGjO7j?l0cN>`~JOOl/ZHgW_vOӆ vҽީ*(ب,YR^vƌ_$.I*,,]wݣ7^hs=tI'J 'NԫǏҨ`J(|&+֜9&IC%I}^{h̙W*))Iꚫh4h]ZZjl7 'GN_c;??>5:# 'ĩk IN=Uv~>vm7UWWW^1k@W_Lgu^B$]u*,,49`~`G"2ib B-~SEQc? Jf?%t4}߭,I\.pكoθ~rl߳gOcV7[oP%IXL?n#o]~vn<> z*?pʩGkĈO?+4v ^8.DۥԿ'IHD ?xoɒ%'^W7t7Uegw7/_I:Yi֭_7jsȡ5vXcҥ{;::IK0<ؿnξoK'x4C~~zfVWWn'`όw.;,DRը ň5&{F1bI4vM,]ԨQ, @ ]a0.^{}ggsggΜsoLִ{L2=jԗ1z~inݺFm""W>:Xreqǝ onm٫S4j0/rLZ׏v)~$Cw+YI' N,Y$/O>qI'Dnnnf\x"S'NJzt&f󫯮}DkL穜@'(-[~b~„yēE޽""č7 ߎqJүQF >K8ӒÆ=7mkiٳSuإRm2=s2+h z|7?ɨٿF1nܸ8ӓy͛$ӟy_5jԈ˗}4'}\*}:E@'4`4)5~^=㩧|V-/6lZJnO>=r>z#"oߝoߝA1r8oҥK2=cFPA~ѹsх)S,:uVYOfzUѰaØ={N{p҈(yÚ7~O^&&OGOm~-ѬYXhq\wuqםGDDګv…*(P 6{GjՒ?1s kVaxGyQNرc~sf߾v&ӓ'OGy.ˣVZѶmR[jL;vadzΜ?oh׮m,Y$ ~0*v}JY3}}ڴiLU.h֬$Vq!' %ΝUWlmY;v#8<Hi'-=T%Kk?ٷEկ=5dz<5*y vhlwy ,8qBF;.z+W{7~R;t(,mѤI_~~Ғ Dƫӏ3F<#}'|wtvnmo?+v͚5K.8-[>t|.Ϗ>X5p/2wۦ}+WK/̛ ^x1MeҴn:4iri%ϰ#""knnݶ]ĬYw(5:ūU@`lz(ɓ'p11aDF~~~{14n+[kԨN뮑W%aEEE1j5}#"W^H#?s2gKǷ~ƠAENNN|-7]AAA2_}7_1keN9E֛@iԨNGÇO۷oWa.]$~mL2{]k+bذʝ׵kdCQ\\;vd)S,o͠3fn}mq>}zq;}f͚I?/}իL/Zh,7cz-@ M+WP_K+L׮]dzXscvV+Luy~0*)&M'Cj*?~2˫Q#R'Q~ -[ƫTV-~d D;eԩL/YdeqhѢEy7TP0"z6={v7}׼Q'׼;y-}-[^SKoԨQۥW}2ΝhOoo&mlٲrsQGFNNNDo7ޣ P?:<{Ng͚Un;FFϣFb׸q㈈(.._~ GϞ="'''9 s/?+VDjȏn}XTT|:u5֭&Mo6""2}g޼tZ*Lo^ѧ겵+(:ir&s=9vsE7[2n Vǜ9E'cǟM9sf=zJL6-ڶ-y;={u*w٫t1ڴmmwWWQ_Lxx?֭[O>Cܺ9d l9>htt\Yf O+ Uv~ 'm>noNInϘ1#rw53g:_79̜1xXtiDD4i$~ʔ.W^M4ne'x][t %) `ʕ/ow[NfT(CX̟_2*@^^^\yѾ gm6Y{'NvȡFՓ70~}yyyQ^h޼Ts1.\ZrW=-1}_]EE b̙9"b._;St%""ڵk7i1wnQEƍ rsscŊ /o߹N9sbIۂ iӦe˖O=޿1pѣGxǴiӢZjѶmd$gyfF* W>o6kM_\\}y\kbĈ+Lײed non_}Uu@`;7g_o6zժUvږԩScY[t;u*>}Fh՗(*Z޿EGǎW ԯ_?ׯ_*͒%K / VXXXXlY,^8ۘ0ab<'oܹM4n8Kqqqk=ftۥ_yѢESuGOv\ 0 ڵku։իҥKc޼y_ǻ-mڴI'OA޽{HPT : _QPPpᢘ?^|ɧ>,S]ז`Qr 2@:˜#W6@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@YD dE@ɓa?nmDDrm1[d Xxqtz۾=~+..z(TwF]EDСCg_&QHI?X`At뺓>tpvءѾ}[n孾lg}ѮD Yۆ~ ӻlٲX`A̘1#h*c~ƍK/N؏PUQFma&:_)M%*%'''#???5jݺu}C\=:LAթVI|d|mF~#~Į8ڪc4i4ԩx;wn3&zxk]yyyx6lX*L۽{8_GݣiӦQFɉŋGQQQL<9z+Rjwn:tcXpמ*""ڵ힑CO=ӧOor-\N&UT^z?/w9=:l]GqxDDq!y83b]vƍE͚5c…1{OKi-_T3u?{:wn{flghبaTV-ϟ}y ]MM=>cw("JF٣:gۈ={Nkm>:޽{4i$j֬K.ǘ1c_z`+C[1tٗmԼs6#GӦM#"⡇^S?$K"////_eԡl._嵅8>nl9R:2ڪcԮ];.]3gΌ#ތ]^N&' ۏ7,}-]65> Gt0z/U+ڍ9Ѵie]N:1gΜ?^qC78uֱr?~|\}+m>@s UPըQ#^rez]wuԭ[7t]t==8`n$bsGf͢Yf?~ *'7vءNxڵvѪUׯ_ ly7[?8F+\֭[G߾}cqlyM;w8{ߠ߷h"""/^XY|U&}7|cm۶ՋzEaaa{VnW6?ݻo\+y楾oܸqۮѵK8_ŧy='hڴi4l0:ĸXcݓ>4'8֬Y3j֬;?G G SoJn|;ƓOTfy 3NOn<32|>nJstS㗿<1W?\V(,,i~&Ϊt qcM2X/&]Jc=Mf͚ʼn'˖-Zj%u@ IDATW^yygi/_U:U@nݺ%~miwѼyIrŊ1~1cF\Y͢SNQzԩSr덱ۮW? 7/ )'#?F4GգI&.N:ٳcڴ1o޼XtiԫW7ڵm[4իAK.ν"yڽ{Rŋch֭^j*ڴie27ń sѫWψXB|dM6-c{th;o}}[Φ|v푛[-ܵkOǰa/?Ŷ/6 (VX 6Feb⋢y1gΜ8qb,_"ڶm-[-DžGoW%U>0g@rjm4nݶZ&A4.$qɒ%1z={NԩS':w ԏիG/K/ҖL1dt5[y{v.8Q#??xշʌsfJF/3.tu(W㦶:WHE1xϏo6ƌ˖-mFaaa[o?a?n~LU$ӏR65ϦO&aRu/ҡM1~cmMFnnnW'GNNN_o]ԪU+ڶm'򈵎ؤsY@Uu;l|~W&'ӦM:~Ri %t :t8K] 7\ ,_<ro@'8^jhѢ1bDT|JWV3I&Lj(_lN?ڵkt%Zn7xu :d;cDD̟??.JY|>njst(yy~qSK.m ||F~]{GO؏S7cmL?~88Ɨ 0ug…Kc]{;Zjkq8-"".8I٨( +9re٬AZxO߈(..#Fſt(99˜lDD›q$motᆬ.<>?bqa'_rc<8_]l;vl9>CҞ&ovUw3=QTzgbѺu mN[%_|EnceUܹs*=8T~|ekݮlƎ?;kO;7o~D {?y㫯ƍKҜp ׯ_R㇎:zرcDp5ו1i8SbΜ9Q2 ' R賴>:>nt_1-N?(;h/"'''V\wyWgUW)1mt:iƌq,__xw]i7ɔcst^JkܸI9>d޻ラL??dzUHډz C YvѧO5zGnݒa'L]vE}ԯJ^߫~c_M6}~ֿL#8Mʃѣ$ڥHCUVmTS̎3g&}aA?rWְxOѷn&9܋AWe4t1b[ekݮl^~r_piLUGX}oߝMbJ./^8~=eOqţ]YsDu=B孏q_/Y^ 3t~sʹ z%)_RSF\!}W2q^^^߀Jџ7l?VIkÔ'URZpQK~ʔi-[VPzU:UG, M>=vN{?8vahР~ ||Ԯ];?~߸_m\4hP&M$#G{ƠAEn]'֦w?{GԪU+4C\guf?>Ǝ|aøP>۷dm&-"bv;x(""9t1LLU۲sP6bٲeSj>J}f΋O?,vy͍#<,^RivaupK/:oʔ)k]^x58rrrVZm1j Igu֙}<ڶmM6EUW]^YSF\!]VX~֚fܹm׸A qcedұV_9LyRu*+iVY5*+W[tv^g0"U…K7?o;=dpygɻsrrz>uޤ!Fy`͈Ӎq_z}G;ML1_oHnsrrUVѿ8Aq/F>t{lCrO_~-^bn͚UyޥKҧw2fdwncKEV=ͳQnW&+;mNNT}vМo_mۖ <{VI^Y3%`FDt>Ȯa~-nw/R}Y%K$k>A qcedұV_9LyRu*~^+*(9im'u&#P]b}DaaaǑG/;e-_< Zg}>qqHb'N><r=zuu_~fo{ {)~듢OѪuhРAyW^?f8sWޫpYAEFUV[o)""|q'DV5Aoe6V,_ҥ6,_Zlt8DͣEq1?x:"">Gi٦+W*8>:K}qmps-YߟQd/(_9 ʛ8qbFDD4:VXժUjժŝwEE 6-Y$j֬ZyM?0:pPik^˵IgE^|ugJָq8ƫYT2ej4j(5j?qX|Yl2.]@ w!W1xw3OZ)12|U,_Z*~~~&\re< t'ׯUмA/'Mzdl<>E޽""ӤI2OU@+mt)?C_)tMrST]U;\g [y5UޢEڵkWTT%Bv7͞=;fm6z9ۯ[o_k-Zsy"5Z6z(.m'LҥK4jTfL>UƏמ{ɓc1jTɻz Yf1uJ*\+[2Y7TWy!8{klj'O|ҤI~0mڴYg$O,Z'7GJvȖcگ;;찈(ypȐkbΜzUP!+mt*ҥZjqA{5MÆ &l ۏd;<ƗU Tי<9^z%{l7n\2vmrjժt?x ծ a6z=*L׺uYg^َ;~퓜,K&oceb5߱-Qt)UڜK^bUz;vlDdoM6: +w!{Gz%7t-[yڟ>a1}lo ә_9̺_ƶ[z~@ [3JwydG;(:zZOڹ~v<юV͍ڷKA"#"rӜۓ'S陼 +..c^0Iuާʣ</NA+ЃļyiӦѯ_w'fS-͝;7^:SlvLY|wN;h^}x2G#"b߻߮:FD48}+C}>v|駛]MuYӧWz)WpwChhٲdoN ""x啒:[Fy+צHest߿GqX2=f2p?}qc&32suK5tmWl\rY/'M UOtMqO[*M‚ۢqhѢޮf{[XU1]LT_w礮O.5.Hkٲex5WM6: ԢEx;vgN;~؏S70TyaRu/kctwT~NF y_ӦM6#iL0!ϟѬYh߾}ԨQ#>2:޽{Fv/2NڱV[Eͣ8>ٳZicvhܸQu1fXpA4i$:t#lթS'z?qobԩ1gNQ FΝ! Kg+y*&M:$'kE< v%g̘fΫTۘJZ4j?~|t)UӕqԩӒ#"ydh|J,_ng~̤,mΣ~E^^^t1{3fL,Z(Is}}㚊IK^ΈH^n%?E5 n1gSNtܹV?DioWB[۸=bw-{qdUqIsc&}گ7 :.>Pۥ,\4 &ڨYf/.ԸϷ(_Z6U*T+Ò%KcŊo~ƈc̘1lٲh۶Mt!}Q{7{bcTWUO+*9L*|mJLR9TmjjՊ=z5͊+⭷ގ_tF?dlpq͐NH7|S5s^{kO>7NzhQ34u~XTV-֭;}zce^OtЁQfѣ{2cCyدh֬Y4k֬tK.Cke:S/F|ǞAK9yJnθˢvQnmʤ?TRY*K)UYڜz%v8C#777 T_#^z|,^O?|7o^xۡ=M43<#֭5jH9˗SO=ϛ]MgYСqgDۘ q;Ʃ4-5*ox̍t3%S~ծ\rqԮ];"Jn]~_3O}c=6rrrbР?+CצHestX|Y{}˓Of͚Ffes5jT|[?}qc**[32suK5tMiWyޑ)B?M Uβeb1{7n|<'o'sx#cQX>ׯbŊoѣij>.oYqЁ⨣yt!4h5jԈ+Vļybĉ /?X2.8⋑qaDaa[N\27n|O?{"ʗ3}L6=Yl2b1qx'➻{i9S޽{E6mQFQVɉ%KƜ9sb̘F _*> {!N9W[r2eJo>""JkM>\̛;/ 6իz2JU,u;Sc&Weis=犘4irxѦMYzWkzg3ό֭[EĆ ߌ:=wM65jIJeb1vظ㙧_lj:jK]82]>LMmnyHFDĜ9E/[EWGϞgQNҋ *_X6UT+Ð!SG:F:ubɒ%1cƌ>פ>`?n~Leߤ*[+2sk|._ҮC?6N]w)^[cF%""Mof@ɧoDF%ksyأdJ> 7x#9)PuΟ0ȕMj&ϗ!@r'&ASL]4m$"",Y7V@۪G}"C*e}wy[>? 7o;|&S`3z?Dݺu#"b֬Y{_8ܓsx˔f-Z7x ,O:Yg|ɍ͛'7u""^{udmt񑛛-Zݻ'Oƈ=zLZ8N&$Rڴi{W?8+d ~2ߏ?>=bd4`-[ӧO'x2;S@ղ|={v‹qῈCF2IDAT2h9RnjK |nD& ,"@" ,"@" ,"@" ,"@" ,"@" ,"@" ,"@" ,"@" ,"@" ,"@" ,"@" ,"@" ,"@" ,"@" ,"@" ,"@" ,"@" ,"@" ,"@" ,"@" ,"];o}8`DFD \iE[:CIENDB`presenterm-0.15.1/docs/src/assets/parse-flow.png000064400000000000000000002444221046102023000177120ustar 00000000000000PNG  IHDR-pYsRGB IDATx^xUi=B-eTDA\("qĭ8QQ\2ʞBm)ҴsnIl&4=}sΕxyy5`L 0&`L 0&̌E 3[v 0&`L 0&X 0&`L 0&0K,Z岰SL 0&`L 0&|0&`L 0&`fIE \v 0&`L 0&`т&`L 0&`L, haN1&`L 0&`,Z=`L 0&`L %-|s\F vN: RgOoYc:/2UBVY YU)*PWMEi g`L 0&0}:=#t 0 6̓@Iag$8J&<e`L 0&`fH$+BF9:{@OS$2zZ]QD)5CtӥH$9&w( ORD!Dx-xr;23:=C`L 0&DFșupV'Eixh_g:0q?=ë`x ' J2ߗ;WnL 0&`L 0&КQD '`N]LTZ~42EΡ?Q/u-aj M8ԭ[(t7(gqv9hqfV`L 0&`#`p"x,Dx[;8xǖ-E F {7or.BYzL 0&`L D )f.Dۚ$|J&{, }n_ WY  t<-&`L 0&-ZPaOgb_#kw{J9V2_g9\F~6q&aL 0&@g#`G.p?]ymJ  R=N~trV~T^b#u@ũtZiV"d:UAnOi 'i.שC| OCQ!>w5n>j)>[^8z8zu66ԵiQihW9V]u9QW]yU JQWUޮhhw"#a(NtyLPoY7A^So߄ʼLCf;L 0&b&ˠkgdC]Z |ړMRdO Ty]@{{ ki5l}j{\q/®{BYe vQc1`'ZٽJTfyOH Vvk0bYNȉ߈P[Vzk]Ek|˪oMM&<&С!L,U"P'yp&`L Xw :c:`gA~[ЦƥKb R'pݙ*xrM%8byEF8܄8zJ;^j8}oݠ78z\q ߦg>-] jUyػtJQN2;v4??#~ZߧG>[mֆBE _1W|3_hvro旣̊=eL 0&@p+<u5i )DxU9T]0w 1L*Rg9C{7_0g'P;k89A#%ZP؈gA-yaup aO$]iY1&`L^ ߨ1xt 32k6o%924fD cK2phh X WR5VZ8 9V23`L 0&`L@;:k3խ"-dkPH|`:З kRs&`L 0&%Y,݁Sr%ϝ}gfO@Q՘Bǽ<^* 0&`L py;^'X'D :nN9"6lpS꣮כc%v/ g8mdl]mXl 0&`L 0! ޣpn:y੓h8{2'{ޜޮƍ>@>Lߧ#)p5%kd4 SCyD&`L _wDXM8tw;L?\g~\KUkBbk1/oG$.o4Y'L 4}X rmų1&`L@w}f/Ai==oL`VB@qhFmy!^cZ|Zl]O{\~RuI$=-X~7kT='E-!G-&0&`LƾO܁=h@lzG0R5ס7K>AZP2mL7aqk\'#۴Iw Ԗ"%PLMN CJ6ƞZ;VAj(Ȫjk;cc'jmK*U^7-RkF.'qj;c*qMXF<ꌸLvV!]kڑ%,ϩ3#7G^ǝ%]5z~9q;kzD5fFˣNT-QU>Z=-uktEC|}T3O lgu36|{Lȧym\zyաV*UuHp%v8[bŶ&LRv  wO&2wO&`pNx/$66ھɿ[ Z^cmp ` ػxb]^J˱ck1*rІMb#մoP%Ƈ܋6vQbߟt—kdP^+݄ 0BA 99) tj^P+f[< EqwA!XY*5L5#u[^' vj P*6 :5Z>(?ݜ#u[Pdk^u5WW@V]9U_oa3}쎗BVV`!LjO+"t5u>nYA 0F~hI"0A Q.&[R-G\p4C;,`A7j,L{H ( ]'O~=u>z>.&lVig[`kF:hnj~'{济^j_v:vU9؅[aѧU#6׍|q}C49#=U?NdH;nvH4!?1Nu^'n*_U+M8f{.A&qkJP$~"M| P0&,=[;@Cd;L@By6XsY 0!'ý+k(7&`-izO#uP]Yh-MEK۾?is| #o/e#t-g3pǐ{l]M'sɠszcʮYLͽxcnG=k0;`mP.CǨ>b̐ XŃӦ>iy.l]l u񀝣+*xM:wZkJi=]0VE[cZCzlR'w99R9D{f#F`ЃCBe Z}~}ʓC}cmǰDv-8{`F#_&P;gŻ݌6f$7<YE1w ?q'uCtĘ`ΆIFWR'xG{pB}Py}KiAh-V܁## i%Z yd n x{R o*&/?}Dwg O%p1-sj:'}N)Z}-n>{a(Lj`C e2 1NL 0&`fDVcy| Y.;02N)ZL|le~mf0'0Q7Q3n!ha޳B`R`>%\ք` T*OC6)t4UZ½ֽ΢?kkj0;QoOTX0FWX|t!󿃝+dEHX(ӏ 0 >= ,P_AHk9ҷҺ5_hPšAu*tfVƞ;&lY1-^0'vNOHҲsAԖ晓 0 >k4TwnkѦMXz@NL.m^gbeHZ6̥E ҳHE K\5E Y Ƥ˶u" `_+|js훍rLN>LD$2{PEANQXSt o} _طe74^v̥E s80WqE oCELrha9kevc[4Nwp Њ;,>E ucێ)YD ]gMĢ7%Ls`Ѣs%r#kk0nY[#08sqڅs5gѢ5"-46|0,ZZX'# %OOKX_bt~!] '8cL :bk;ak瀝^p,Z}>1 h5*  R 68'eFL X)-ta5Lk#ӱ1%ryq}q&lƱ; t4sW'j Nqߨu89=D}=XD7k\'k6R:|dٷ/;q?$%aWVeMeL" han̙޾NvY6?ed۶>ݳ&7 p_>Lj(Dr¢Ee+=Ě6 $<'Lu҅<kW_{-GD(,)X {UQ( ^ZUJlRގW/\ճcok+oHMMFe]xaע27d@L 菡Ce~&>E생2~3k:#0Ufl^v̥^8~0FE [k0gO\ӫӨ,ob1`fM MJOv8{xD!!A]iiؔyyl}jaLRV-O^T^8qޫ+nGqQK'-e%H - rt''!\A_$6J)xP&^QU9mg*#02KK ѳ F*/aϒ j1K%0p ϏDi賿 ǾcXv#JOi=9^hBO<' ]}zSߧ5w-L/ ׆/|Tllu IDATy 38[eL 0NK`p.6,LD`DrJ#)j7H {WfÁ巵`@`"ѧgbeHZ2F-4gַq]DH!yRJ)Jx'јZS]Y76O 0s#06$qUh(݅{BPzAF8}zNL ~kxPƄ?X5ڰhaj<0 Vw4԰0 Wk>BfnJϗ"NJoyGY&u 0m L E?%%'霾3yǷ"GucVM@qѕsjUГcDȞ)7fsbTwJǎhk[#wW:C#>RRp ㅑeH/Û{\u'RgG!SKY;3!e1&:{cFD0l.SF_Bt. X`BZVroܭdzaNAFx8(9} 6k}|U=kpgT{Du?7r ồNؑ(_ԁsIv¯MhT##qCd$5eg7ֿHKCraa}ND{?@8CL 0>GLt RwD)#mǨ-oq=0,Z~%* GD M9}SRzMX f4J\_S:<5$(aY:lJ`+l8MŤԍ;%yJO;lll ihh鵶~u꯸FӾMm]mj3ml.5Ŝ'vz+ݔCS.KRkcJ; M8drΎR)+7ou]۴ ?%'7Y0[&Sf@^[-XY% })2p&D _gg?y2\|0l x:8NN:r9fⲲZy͢E.\&=t6)}^b;t An(h cX`-tRN'EuԶ]]_ٺXP/9lUj}(eLԣ#EH R7|\OF߻}99)z1NziW[-6cZ榚.ڎsǩ_7}`9$F?\ieÞ nݢ} _KNsiI^aAAyTy}= 9?liw *Xa3v8U`']ț #qsT(`ҽm +EO/9J?[ yDTP{@Glp@N4C?A(E7FDon,: O/QRM_z ߟtԉ4:zmmVF_l/mm/ 6їV51>!A% B+8'!Ƣ&€Gԍ4&Knѷ,Dț,y*F][b[`kԮ=Ek1/zF]M (NYAרwTTH؎]0걯DAbN~ތoPS:z;E/@:CmZmGF8nCj*2KKzȢE-jڧ 2 .e6ȩr$”j!?s6psh҉ȭyCzzR59J":bXբ $ߝpQ-U>Z\|H9TO !R{jx9uglIAQʄs䂴ID(x:*½EςF"5ӵƢfT^q`ٍ(>_}" vL)Z(ɋxaD< QS>xPpz/P}lx1ͨ)ԿV/;GXb+Ɏ|qPhrѵ)"P]m[,ZXjjT*ka7I8Х+X/|0bVwQA6<\; M =9xpp~9_ԜBBw)8/}ZFi-'BjӀH ȷ0:͡8>wq ʜ^J_'_cx,ZZtíјL'BUQPUYЌlkqwDQ!^}xEG;h**3E; C5E Sh7W] ߺeW2Ǟ!P!p.=гZ׿c!Mnc;_B݂"1_Eߤu GX{'-}7{wXAکUV◤$lLOG\f^ X A:8 ~u:rD=5 Ԩ^t|՘է iA)$= gK6&`CD](Œ=ʂ__W Hpz/0c#`Bv={bZD +C'c=z6vHf、W48 5]}"Քhv~U#Ķve s~~JgJJ_))ؔv~FVc+D*6s3 JOlv)!ڴFNxDZ )T(bbG=>?"~P*N*˦5(jJ7䰋8'm6zzad"NBi"L/gmi-kML%Z(N ۾n(|UbkWCVYɽvNňg#p@ 6 dc_=ܣt5a%Ln_+`tuŠG>g(}ƞaCeBN7ha+u A"D> oV'Z }rHٽVKC!?>Ǡ^jѳ^G}MYUml{]=A#g EA"P[ee#D%}KgwRg JuuE䟌C}] #1`c+2;hA.\#g7Wӗe~pCqzkr];xcm;jˠRՖ>WkGq~!NVUACW=+-3ck-Z,=ڋ0G^]:9\/]z- dٹDįۊ =}t-N&.\ .~A/7H0twpޯ Yy^,lclH ٳxj֏6^PܸIܻcV;b#<Y ~?JG6 i+ն`R '~& V]@CC(2wl M^Ddn]*k}̜eBFל\4+!kg"G PS.B9c_<*W&lՏy#$ߨ1v;haxfo,9%, T.,ĺDu -Wp+CO:Q8D#m  Ęd.N5,<2q8GL<>\[ꨳa^S>u0)lDK=֊wabh-ƇԈ5Fs(҂ ˅hTmi֦+Ccޑ1hޝYCtE81rXPPDžCq&dg5ˎ!;yGֈ~FƏNHw'tCd[L7hAiT=<"m$=AEN0ڷupƄmIcV'픶@gX,d5(*(CTERm`f DMAF!bG>W)(ܺwTRϏliԋg<1ʲN"Dž:hڵ@-:E CimMCY" xIu5%%msoZy4cnXS]EПڶ[AlqcEϵ]`JjH+T'㿳yɄRBE8<("4$ rc@:DȐWeUG]Dq=u"_o`6rV/o`7fc)3>\ܱBQ/n@֎5?5-0v:}2,z7 p_e1ז't( A5%8d:oC#b|R+OF߻}ȟX=Š"+Rh FB#G?PH1[?yTOy-} \{hu/kЙE #@5Hoo%TD*GZ ~8yRpcI:Aϥ^IZV#Q HZ(QumZRcEt7!@M=1'Pm"4㘈&zM,g[}P Bʫ+P%UC#f֒f5hJdY(D >mM:z!hw#܇=<p# 1CEQ:y+n>)+@QA۵NU4J/ 0ɿ)U5/=}GǕ?;ykAљBQ4 =QL< p $6"#uʇ.+/k 9=)R#QꁨKni六lXzu+H4"_V3(2?SLoZg`]q!ŤiڊFI=T\-t}zAuB.%11J! J3P.VsQ J(-Έ+r)BoƊ 4R"*skMŠWl}j>Cѻ+6B7('4J8(>{CAlAK/ơnW>ya*Djr~(ԿC˹nM%oӎ3™7K- 8&~Je 0zTXQr('J!=a==bw- ߨ}+bl =C JM9=GmDha"f7cZCb"~INFym, fP2x:֣Z.An-Ni}cL  ,NJOu(CR'7Y 6Rzm2>xxHV z

'qR}l\Ejx\>+4!q!| + ;ri9;pDŽ@A P}Ӟ7D3T[o@mE}-i^L]x^q JBEit*)RTM F^]?6n={GPpjEU]&WM}] uBL`FDI]ե΅BSߗXSЇ%ITwPr(H^'@͢UzMyWC/pfΆ>^Q WuBT)~)CCi*Y; $kH Ets~xE5m$P.pYt|ӣqlGM}6b^0|aVD?WԪ8[bjEeL 0k&`hѢ[þw}!?}g˔ *<nm} p"?- M}ӞRgn__r5%ÞYlʩh?LS¹? lSVoŅC(oNjM6 *?-F :6 )B&,;,(O!dȊ[+R S(H-6}b?,Ν3E9ОNLSq5>(R`P[OPFq9"G 񆄊a7 j櫈{aL >( IDATtxtLoM>ICP*NoKޣKX=(BRɰwIMZ-Գxт aCe5dF3w[c_H'čmXs!#U3Rg/Qsj/Oëa >j[xAM[m7/o@_F5H}֫-p`pI@$7I)B*rRE1M׮oCΑ p!i(:S2MÝh.QORaI=$Hɟ߾_RF>Hԫ=ܻE!Bup3R/ 9wWPH@EY//N0Zh^v^mݙ`L 00h#8ʍxodg\.6$ ЃIjj3ɿ'r:Dq zBOuߚkSNQe#b{!zآE<܎3/CY[_nZlm̜DVt^ϯ#DLkBTqGz^BL?mdl]7?7BytlU9kNlѪp˩&O zH52:RIQ8R_r/G74CVQ,OYq: '$x`L u-GUƤw !ׁ巉z=HQqk6T[o2-xтn:\he"|2Jҏ8HDiyBF|P&wRBqEK'])6TBÒJI.2mU9>'${;:9Y'$\DzMlߊ/"J\H,9 RZѼӿw [KǕ-(J|3;GdEDP"C.wM@T)(L7,K{Aqzhal 0&0ChT ycJ8zl@|H mA!(=k)4&DݺP9YW: 'j 7 g?kh;*:OAܯa3.&lv GVE#4Fpzc_:+A5#>LbʁP"^R1qp,(5:xJ6-DO;Qׂ6:=}=TσU<$0 N))UǹR1LdSPQVUyɯhġԨզSOč6F.<-m|{RPiV6HDe7𜨝״*T[т7pzep' 8K/L%v[M&7@/ZB "?8iCof>.^:EV 3eAvN,ZXТWuT΋`L 00heAv2Y$qK#cЃ_(ё(0ꥍwFqQ8ڒ8r(5Z~ u58sbLE>iOKPFFT̑"S7|f<, z'q1~?ՌBac$)~ oK4󤨕4D|E hRS#jXi 9(NBQOhb7 MG߈HlD F cHi:| 4接֜-HTQtjH`Rh"Q;Q nLhaNayhayk3} KpzO}Lp&.-tu6=&Y* vި*kUN TllmN>SYR56n> *8(RBDZH]eLqn+-[{GʋZ_+ƣS + )SueցAGS-_-Iݔ=N,Zr%y,&exlHB-n`-_Cߊ/蹰LP] 1t. vlc17ybyfyL 0 1=<䈿(ŲzL@_}c!6$|f*}ȑ~$ﱪ{2f!Z{l X -e%;va^u(C:vxt&`R|}]S5 e6WX ~kt9:!SU i<Սz,Z,Xf՘է X2[L@oѾuxqtW͕m;2} F`3i[:C_3܏ XQ/'@4ϐ &c.#\k,s24Q3:haLV}B6iDLSNcg>ϕ X=;gmO ~tΘ Ѐ FL=ei9b`)QUm`-`;L`.Y0l?U?rm>ѲWgL 'GԴܘ@g&{4<)*b⩝^st5npiN$ 9z/bN,Z9ǝZRy?:`L 0c) -GUƘ5a^5ٻ!E~D'0v`Ѣ𸫒xRtuV7{Rf='/5`L t<7!օ(;oL xn=\zM܁XНB%,%VR9 âe%x]cOؖ-m 0&,cm=G-sv g >uU{y- -+ж+_ׄوUfHE k\Վs= ,(]  F`@ C=.V؈4ܘ9BF]U9 5%& NN7H]}Hw,F(,tyW,ZC0&c=n¸I`]g<#0޽ qd\T[yBL){w? #A8= :hvܕ &Gh =[ tJXAWtNN:asC9#3-8zv uHX0Rjѓ/aGRB`qZH"D &h$GP3v :E 3%& ivH-EV?Eg aPIw9}ZWIpX}:}j`vdՐ?Zqwh@77 Ž68[MUSR#AvYKhsNƎmBPW塸Zh׀ʟ!]kVc3 n[g^*".Ѐ3pxt3BscL50WAbcҬ8ţ.αi!Z)v O#d]8Rdn_c N v9 ]|wdxe>w OZ 'ieDGΪBH>:U^f(;_^[ kjtl|tu (M@S[*/[qe X;ݐ\PSGSJDMMӼ^U,"A#MΤRtuU/2 QOUb -! 7EUe5: մ"C'n =&(JkC *K!,Qܘ9ura. ,xX&[ =&eB/c܉ BXqn:0h%Z}}'](VØ3t}fs҇r^pp/OGd8ݝޗ&RfEF{/1[1K{A ""H2;s.ew{' {|΅=\CBL!Uՙ ^zuzd#O 5Yaj#ظIir'+&.QQ ˆ%q+f)k|Gb -쿍gF'Z+W=FGhыia56  ,PhQ'&q @֮~zj-"@@@@EkWGkk9!*n@`f*Z@@@(9Q5~~X@B@c ](yיMi {ϡlB@@z(z߽O[~x^ SFcݚǞ2    OڟpVO~B맽  D4il['!  (hyi>Am7 @ R[u>T֕ș5S@@@g Z$4jaLU͚i}7PS'ȘdrjJ3OP3֊z4  M =MkTq.˃kl+ҧԴqZi)@@@` Zq5s~/p}}+1vSMWujxm~yz;G   p\Qi5 -Le_4uAuCcP;qLuDEf_ .A@E8ن5-[t'_Rw#_Q EIv&czj}li@@ Z8p(׾]7u(`sߕ+*J?? - K#g+)Ҽ'P^a!05y~ε  @M-j*v8 I׫IY"Wo2|x IDAT|34 EXNkD V{sj]9[_Ł'дωẕ䕛sׇW!  Ђ-b5Ф<;kK7ȳs].'0=S~LyvΧݪQZ@}]+'+O  !@hQLZ\5>B-}.\e0-VH}.K.VM~B- ہ30@@: sb:ce5? u<f 6xS>yD>_n =׵@z>u牷hu-# DELt3qjy1`mH%y5b ǰ1C1O ; TȋI&X/\=[mt  ~76Н6#.Rƈ IH( .6|:_B; DEռ)j3BV;o.-@@*@hTN: -6GoD96~\5Wͯyð\JmC 8J ; RzK:KkWJuBP ]  DE$zxNC28zbS e 0 V'[N^h&D4&+MOwG ?shjțiä^@@ Z8l(ڦv־tZLy=g_eT$5PLBL(WQR w<+ٸc@@ yQ<8ӬHoW)+>bS+.bS̯lHyrU\m-+h @@iN1=XC$.̯|(URk2+)je@@( ^pC Sx3!(@@$@ha٠ 85A@@ @hI*-A@@@0 ӉaZDd3T@@,BȚp-E8*cB@@$Zp8]3H   @%N p R?   @h=a: @@`->ԏ  ҂{ L-tb   J  Z8}@@`@ Z2,@@@N p R?  DCh֭fÆ*hh$1Q!,-=?T   *9Y?_ul߮󟠄+ո׬MG6 A@@4U+{=ڼҋYi/DSSgUXX@-BL   @0n0@ g]3f*8Kt)|j`I[ZТք4  Vc{%ژ"iiœOY]ɓ5yժFЂ[@@p#eShʕZJI^ 1Gˁ >Ì@@R=O+V>;>}zeb:mZX:1(g Z8{@@"T =>Z%~]!սgjTv:Ӷm*ɰ,@ha١6@@@ ';N>]/-ZTȦM5 z^ 8gl)@hai(@@@z:t;o7nԙ|PC?X}g@=Z:]"  ~ukDC&Nʽ{hC>͚i+hmVVʡ*@hTNC@@B+pװae =<{3'bDF{ٚqN`FhG@oT.@h݁  8Xof6~~޹S#|3F}u_e.])etݗ_"-l==  T/j[X/Peh1:[7?i\9z G|F@@%ʩΝuɚjHNK/UNQFj-@hlQC@@A]'+u)ǎ=VOΝgͪ* Z̋@@@[ $ݚ{%jhmzYigvtok޶mE!   &+_KZhDM۶3p f|   1#۶GgY6iZ:x:TΘϟ1& Ξ?G@@fxnL_zI?i\͛W_՚Lp#"@@@n2D߾^w f]tsєU4a,l @haI@@@ XG6m^{KFVΟk׍SꭥK PuNL   @h>8Ln׮ JSW_ނCoBТx\  Q^qPڛKSڱ\jBRB n@@@ '%i*96|d?XYf#e8.@h3@@@ ";$۽5U{jĉ-@hz@@@Bq:i⩧J.9owH!8B M#   N;q/ l&@ha @@@ X+ZnZjvEH @@@ T@@@@ !3@@@T"P)C@@@ Z@@@@ PB@8@@@B*@hRn:C@@ Z&tTKbJ+\%V=Q"ٯ-nҏ[b#v&5UUSbJiPNt2ͭ-0a[E;Rn N;M9 ޯamWM=󷻵1 9 tnնZ0l$<^ J\򔸔[沰VZt2@@W<CePV[‚JQVP1kSTi1moDŽOM+x4]Quhv.ogN]y;hLY+F~RizhՎkuV V=յsuuU?FfT(WBeݦXWI++ 1>r9_/h;ʎ@/T]ܫysi>[]X G⻍zxt'o>E슩<;@ha١6@@l.`&()E;mK*_)aaQH+]jjP#}z:5)ȥM=$8t+i3F  @ pm^h[?l @m-j+  D@}+s #"@-I3  D^<(J0/Rq"`>4[z5g[f;vjC@@'Qd=¦DYT!pj>j׃®3C]   ԁ@xq\cJ[ҭ_YiǃŽBM   ԡvE+:&8|;D@@@nKin}_Aha٠@@@B$нIk)Z+&D= EV  @ Hk|Ok#n pxvL!V-Lp -l7%  }.틴0J7|f¨"0Ht,Rߥ+׆AfA@@ <#hŞ]JxQ!|zpTZ\=%ME|70t@@&ڸ,J8$aS."=4**I19S)H|9׫!f5FNF@@ rAyրeș F@d ZD3j@@8Mow۴TmɍN@)@hLMB@@ WCCqiʶaP@B n @@P΅:{gLj@$ъ=n}&V$tP   8[NRHha)D`lBߣȥ뤏mp@@0  fx&pV՞%)  T(pn޹P%~Ox=p יe\  EO.CC`?B n@@p㦌8,Bb"@@OBԧoB'@h:kzB@@ QkkcNVi&@ha@@H #ͧQ*,qt>'!-7gT   @DѥPt+z${})OF- @@@«I~}:.K}^F%uPt?%j,A@@oz{4"XONR"B@@@_>nW?i zE]~sl'sF-j   !׽?6wpe_F-ҥUz(,y}SX!(!  a DGI#W4ienwЦF;ifʴR kS$NfGωbM5qU|l[ ?n 54-}zGp-Ik  @?y<ٯǏ.74?ew~֒1ښV &-ޯgOW:źa@[ZEG[kPKo@{ZM;&t>仕"Ƞ4  @QkkcNaյ@gu-M9|M+0&.}rӦX׻$_+J-ʼn=s~׽@J`Zk`b,Q;ƣճWW^'Q {\5+IQw$AhIX@@ tzB> ݲwQ9,’F~jՑMܰDhi{^!E;{ЬljDyBD_4ɯ$yOTKvxGt( L0&ϸRԥic-?F/wfl S{# u۴Tm Ί[_sefJ*? S4UZJ٦Im @@@ Vӻ[pЅ=a6NjD\t G+=<'E볂[m`*vSJ}zգi$&կɾC3=~6ڌҨHY!H4i|K/,HԼmjN԰Drj_mug-jԸRk|y>$Of02l62tՇu%u~Vq~!5Тb  @uhqR"]tGOҬM]~ã1h[ !bf^J+إE;ZQk͓|r̪h*l}OZOs ~\VW*]~y.f|L^>%˪gs Zښ[qܒ}{u}{/:ڬ}}Ԥ@ 9 Sf^Maocr̔j7mu뉹cVΘlFU|ޭ87U bB ;   `#z=sLWdHM)U23,WIf 0Y6)V2) L;I0+.U` eyUŴ8M_+ʾqzޛcufɥyR&09 4WڷGy(OtZ~_~~"Tn@ RL`Y[?ec0?Myp2 '1o\>jS;c6Hi[=ࡼkȋ^1d̘L/,LҺy`dZ$ty=MypQNq!Ϯ{=n+HΡj+k+MO;Ջgd[+6nŒy!kl%.۬(T a^ZTyhaU{b(3[s:o2SsMіıgk[ZGìneZZ+&̫-i~xd r췲#?ZƔ%aB'VߥK>9tՀYUгiIy a"R zrǵ+^K29L(e13v*#OSj-/ZK#&pܳonw Bԥm@@-̃fYGVlϏCt;i2TXuL+mCUGg'xch3R63Z:w=} bŞgiZQd}dk1Yփ 9f5Se剶w%Uvb`~g/?En8*Zab4+)ĘRkO>kxl>|q/e&0Cm?&Y(x( `1)Y֞#f.<==Tiq~;#J0oY-5? ׯ{ f>]5 ^]oCsWS>ZQv]ưxvAzytR"ed[b(;Y+-^@@B r=  \>Bʚ`VG_+-~n‡F ^ eYyI`0W׾ZaM0ieB* lWNL[yyﳓ_q!?%F`r$ߕ+Blm:Zv\7 {e@e+-Ԝc>-jV,ضWfYO]J5Ԙn20fՃTY=,9@Z_z"&d0a5Yyj]\y`0:2zu-֜ì1!SW阾gbxfL[\g y(%oc64Omڐa7X5q)Ow9z@@1ź_S;c0 4;qPfoտ[_Ue,|kWhdFn*zĬhKv#? fŲe\լZSJ3]s1{ȽOm#"  @ )vGYLݮHWXHm`^`=VJIG$[?0#nɋĞ7ʷof퇐놩fN͒إN6,;Jz{Cӏl}QdDF.)x67<ךLfy?&[A4fՇXDbn"U%.]xd2>i`l1! JL0c%:fo[ce0r^)y=<_1%Zeѧ  5b?Y5c% bVrDj[üa2 >"U^=ljٿ/׋[1b6y I1Kk1fo B[r8w9z@@lyRTKeU_(iٔ| 05,64ԲZUa^ݾz7.sNey6TʼnQ<{K]lj>i1ͦ֞&0{O<<&&|Ws\7_#["̹|amu}1WQQ`15#(ድqQշhG>+Wij͆^);dVf5y7A;ݚ> PLW{fSSMEqM| o[lg&@+ A7^ZxF&04QΖԥJr@P7_T1sjV=v:䩝fZ@@@@AnɎC6y<3sɵ!0LЯ{M}:igqa^sD $L"L`0m}>Xhe`0O5Z*y-Ouoծ(x=5*΍J/Pp,8TKYaBYQVY1aVkmjb8kʃ1!M٫.eך#9o=WU4o,ɧؘ}(_ sLѫ BVŘpi_{}b=s%V= +$󷹕uYcto\bFdt5L0JᎩ&יW`ъ=7GI9Т6z\  0#Y >OgnjQYa $o.`go ӆ ,no[PaaM0rpPv:߄f!#:].Y`Jta/ʳ ˿F"%A@@0F/:}a6\7atfͼJh-s>5m+`>j)>%DZ+&_现+'{cz@@@@ 8 4.A@@@ {cz@@@c(DТNXi@@ }r|>wɁR"  ^;/]IUN .2@@ ۭ@w)Z`Eyi (ֺhcL!A@@> *A.k_z{]k?Oˮj6Eɸ@@ȘgFY] tVW{"D@@ -1 BȘgF  @X Zt2* @@@ 㦌8,Bb"@@OVZԧ>}#:BY  Ib]/_su J3 `7u/и΅]t[FA@@^i>̏Ra^Q M||O@@@@ 7אږ[ת*VZci@@@ 14Oz&3FH(-l5   @h;.G-}%VΫVA1   N :Jzml\һ$_Cy=Z)  D@%:K>_E;ݑ[%z`d5f'kZ@@}<ݮJ}նHWX5]YrlUb@@ź~@U}ߥh{H5 P+uÀ|mȎ֟חC-j5\  @x Klǔuqzeqbx!}ښFOha) @@%pm|ӦX.]E|~{G5 ; @@l՝u F@B@@^JUw|-*j@ ,-rZ  h?Q\t6D۰/#5f@@p9:+Ew%L%"hB GO#  Z)gJl!9Zn2_ŚZ-BL   ԓ@tL:gUW-馡[@@@RWS*lϋ?J,glKhQwm#  aR|ҫ9QڐQ&p_8u)԰֫\V [` {'!#  @ U%'{]ڐWkUڷ3WmigC d\مQz} n WfaWNf^uպHs\> ?1%QvC<}7)>R dSTӧWo+@wy5sclMj^Y;f\oEّidN1E@@槻u),wRC?'ձYɹɚ]ɱzybnUҎ؏ZzgaCdgJڸu)ZU\jR`uю1<ܛ^Yh=$Wvrjc*,ʮ3k+l&-Tώ 韒ʃ})eW63Rcz16S m]kٮ+$$Bzħk@@U]>KyOMJ ޯF ~%K5qIUjWOnɭGX}Uu".E[*o#rԾAdE3S|+$.|E#r]aR-_У@v*٬0Ί*>{ tP    ZF ZusυszCB n@@@ $&j5hǣ.|'sB@@B",)I˯Z;C' ER  @d-*mS^2$' Z8y@@@-JmQ^ek\@p-Ik   2RS+1;[}^~QSl Z3B@@@RviiZpZ~ tP    Z˴63S^}5Zp   ,pD{ZwN ݎvjB@@B$йaC͹wZHn T@@@0ڨf_|V٣#dHN pQ;   PKM l.lEp=i @@pMjƅ;57U;ņE1#D@@*ݴB-ޱCz )l%@ha@@@ }5ӴpvszCB n@@@ h?_mo ݎvjB@@B$pT5oV;!nL"0'B@@R`P˖wӏ[h̻s-;wT  Z`HVryyN}ZGS"  8L6m9h֦Mr]"g!  @ >d6jK?RtٚqNJϻW/ѥn6M+XSZBz   2¬|͚ }flܨ3* -bbE]zì@ P@@@.:H=yzsR~ +ݶ>8,M_^gQѹ^>T|jS0TBHyƍ  a/7ܠNC|\vz3u:SN^ yub2mZػ1@Zg.@@[1;ƩSҥBz34uZnҤCmZ\|+"'~ڶ-5  Zpo   a,`VHSV҄ɓI:?/֬|ȟ_ׯ9RՠXQŽBM   @zr\+ڐ}@˧q7PVtYzr UE3&@hg!  xt^g ==۩^;VW0a -q/jKnc (ܙΜ7F@@8];晚yN}ש&OV%״uN%_ ND0-K@@@ Z5HHШ7;?sgr꩚믺Oւ.St]1e>ZiC0 Id   @u+s꯳f~V׮z 3|>{iT@-NJ   n瞫{hkxNnz~|7_1_yub2mDE!@h @@@i+#-Mg|flh :F &$hٕW*.&Fc{Oo "@hQ/t  ^atAdnk zпOsj>wzwy"DaQŽBM   @ ̜0A=4œ'kUг:Ϻ髯Խqcͺ"'׽{EzB8@@GOV%'#~Y+?|n0@֯9}6f  pQ5   pXfYmQO=ر%Kt_ke]znKlaEK"X  8D`yiHViT !-җk3Tϧ/eE,B@@*_?=0rfnܨ֬CF酅 Oݵk"P=  X 4K^>dZ-ʋ'MV]p P=  ԃg۷חkĎ͆ նVݫA'CEt   @ \޻=XmSdmU=>w5+E-8+Ԅ  Ա@T-r\z:pǎ:L"0'B@@N൱c5Sq9O'rs@@@Z C:DTro ֪M.F Ԥ-@@@AM+厎T^~Yrr4J wBpaƇ  T!kd‹] IDAT<VJVA1   @hW맿̜IVszCB n@@@-@@@@Ђ{@@@l)@hai(@@@ @@@@[ ZrZ( @@@-@@@@B@@@B @@@-@@@t\VZFS(61U1rǧȝR`P <*)ȑ*0rUS*kbEFhQot  UZ>Jo[ҺB@ΦeYDY+{j"dt  sZ<]펽\:Rt \5O=dUIAly09R"ToTbbRRfϤfޱ^ ½y{i;W>ؔ}aE4Mm?~rjoC@ j9 +LhҶ.F@@Ym@]κ*nю_8kTh;u:6E]+fv̈́MDž  8K ]o7ym@ ']']o}MQU;aq  Mi7Ji~ ʁԅ;5/a\=[>o_C#١M@@l$րPZR|ڹk-yТd\  Z >[~wL{g0s@ X]tԭ)**F^^}[ZԈ@@pY=Oؤ~6~@ x.߮6#/RyKj4Bqq2  h}\|E͸hE"Қ?-GRΖ"`*ND@@yE ; ҆_ҪOpB5",A   pyly5ϣU&@^=*'0k{@uZI  8O'[|_޹yb+׾]j7׏ hl1q  p4ӂ/Us6Eph14A F;>ZI  8K UW CQfj?4ީf3\Ź{$ED  Z;O]ϹWiޓ7*FיMi/ݠ]Kvq  qb|5ySG@^;zAS-}H>E uJ_?<4NTY-'@@@~Eh󬷵jz9#u*IQvEj8  6hsr]|9fB9 O "K^Q;Nh  @$ t8:u8zO{9X@&T5/ܣs>"pQ"  Arj3|Vw6~/h  #NI펿J>yLJh T@@@)eK~XSʦN/Uq qFM0@@"GUϨQZڳrv "#Z:S~W߻ʚ -1  @{Y ;ւg_BDB bX!m'B   TPT }d-}B @@"I"f"<B #  4BQԁE$  N pLQ')@hΨ@@-@vjC@@-@Vb@@-@hz]"g!  PYγCm  ԱE<JТV|\  -=T@ Z 3>@@ @; Zyv @@: c`GZ ZԊ@@pwBpaƇ  @ `g -\(E'D+*!F1rEVJ]  @ `g -'*!#E)r7MTLBk6 x{JTRX"_f 6ʳ1Gr>2G@p FE5oS׮֮Q^튎V/жɛNFHզo_SiiiA"cڧ(m$Y+(:JX~~3CEpXkNtb\1QUl ylVҺh  ݔ$6л>/o_\sgڽb<;ծ\DVg`%ʜM?l$<(@@']']_aC״O+)}g6m!>4e?sETbX!}B}tw܉iZa]'6mԑ+ƥhdkŦǕ,-Ϻ\QU){ʚC{ݬ?b8@Z~RZuѠ>RqyhP԰P﫨XyvoVІ-7hwhͧO)q'I.,gֺOS m?Sb6Mԯͳߓgz|GE%iIZj' ԿB6j=ܿbe٦9+`#:lQph 6NN7O({.@@ ZԞa2{[+><ՈhwZ Χݦ-|*n^^Ni1+_qAըjyVrJlᐾ%֝:C4uv:>A{_cc^chD,ϧu or)I;)*Z]T;m[51\ MǏj7+d;NIM۩ O{7F?k 3gg g"@ DD$Zi7>עxWh)ɀV[܃c.`M:~|/Bq|ʫ:֊d|8Zs/+Gh<]kV|Ra*Yw|ͷN; }^/Hec%#$"eYGQcޣc@S;ԅ/AVu{W_'T~аY|V)9{:]ѷ 5; vRKd5:N` S. C@(q 5'wơx|q|+O[S5D.% A7+\ٯ5iyؼhysC{,mrDOV `[E"o `7:n*AR\!D"@I!U&T4DsQT--]$j-WsQA-kFky܅2m![NܽOJ;V̀Vme{~x7#u;4šwoؽ=d-@╏ٖ=+w@h s.p: r"Jk݃c1yٯ(#2yih*<+*Y#r'qQ#gaͯBlB[]hN L?v{O0=6t;UziAI0\V}rXp< ,gh}r?o̗4gyZ˳! 4o{1lV"Ѽ\~g~\2" !6{Pe6OZZ"@pd$Z_vٶ7&A݇CkLz'x%AVP'v4B\ h={ A{c Z+r;gkOYW4BP+:RxGDCnzv~⧟N YMf>U7jcSB+3LI'n7noa3 wv=w!t.& 'Ԝ >5XٯFƜ[ o[t@,~E[cqvW}0_o5 ;4)4|lR`+0% M((|%"`'XE)еiQcPY !7 D  ڴ{?,r־d1wލgs/d[7F:\B1 vZƉF]N>39F~ܪ..n|X2osY8VVahl9yF$Py7S;6ѱƗ7>ѣ0h]qNĤyYy͝8؛R Ln[fuզ! j2r5="x& uh?K~Xg7Axs!dU*dK{QN~ltlR`M7Go-!f2`¯O U+PQtR&D"`H8#&_,1ɘTԅ+>~2^DzEM#Y ;`-v܆KXi4_=#`ʓ!aǓp}a׳v{Vރړ6&<=y%hƩ10ַ!vqǮ.¤%?B]̆G촓A#g5/Fydȩ 2Iz{1p0 )*?`MES?e=x1? GcQxm`ϿVqciKy.5*$^8w: >ih.!c/"5۠Qxрa3x_"{*Ĥ[d kv˶; }?6%Z<%H\:ef(?{':1w@(@5{H? D"`CH8dL;G8Rj/\55yu!{Q3`?5flE[K ?=UQ&o.D!F& L]DV0s;xI؄0l|[VǼ2aUEZ0߄īXFV"|5w܎#mN:a1Nؐyq{A#fA~+abP:ُy3NS^bݟp᧣(dRiy)"hΉ8b"Hر>cr_r("@ DH8Kh&c(Xpk;?)}И7tD@dL'[~r@C[0d"ݼ:TP6$~\p`aC 6YiDRx%  o(រ2AH3ƍ[*CB8[~bvܨZ" Jn7Ҧ( ҝm7b-?MN8M0-B!W]үВQ7B"h\=xsZNӫAԵL"@ CDiAcu(t\ɲ04U_;w4"@ D:HwZ؄h6?iZ=yN`=-Xo ei wB2 D"`n$Z(#DlB[2.(x(2sGc \:)}еi+>"@t$Z :rZ~h! i IDAT~SC<bHOrF@Q"@ f#@P!"@,@ꢅ DܘYN?ɴ@d8c?5u[KQȱh DA'@Š#!WRj,F햒~NSsE)6?'EM DfCIQG/JBKFB$D#OѧVrWp("@ D` h1iA"@AE3 k48'ON^Gֲ &D"@FD $CDXE sEGV)@d8&C  Щ0ED D`h1xi%"@OE3!r#д]A@S v 49! "@0-E"` V-Zz1M"pM$oBpQ@D"@ bXJDUE |]OtpR1w[7>Ȁ"@ LD {Nc hI DXVO"`-LDm ¶A"@g$Zsw"HpSHD L*D DJHxZha&Dl"@= žG'@"t@$Z8`R)$"@ V"@ӲDDD 0$"`[H|7D"@=8>-? J!"@-%D$$Z&"@m僼!D"`HD h9T "@ha%, & $L4-l+y B؋YopRQ: o;q2T;Hz L τҗ8{\p6s/ʗV.Z+1qZzSʻqj.)DG_quWR=j[JM4 h٥؈ sH8!-02{Ъ|J iW|㼛GqI\[vvJGUdyfDI7;0;=Y:#}M#4o>k]éз߳GԊоUIJܐwnXsGα>l+#=߇!=Ӯ`^˚jEM"7P(BFɃ}MH 8(-4c ±ѱO~G0m n:4 p.hAEo} $Z,Zt?}P+'QyDX֠Nk$@y+տeo!82jxo.mԲ-RkvACzcʶҾ-!:xHp?󇉅0H66r$Zm@v4'ua \x俩McOϞ N&B z:#.-bU$ZX?-N@H[!-0iNrK' S#W1w8 "`HD h٧v:q$%ib_p &`#plΑg+-5sS ©or X DdiGGݱž'oMD~# D$Z "lZ"Iv D;iF*n MHpSxD ha $Ιw"0XƄdR+_Tz`BXgL+"0p$Z ]IFD  NC2LPx?8mER%2N"@}勼%t#"@ xIx&K >^[VO"`-LDm ¶A"@u) \-&qh SDH–xJ݆#U4-|!K S4q3k])5I -l0) F$Z@v4v+07AovH)4"т? *ZExd3};]@#fb"CD %EDHpdAiD66Z/Y_.vBD ;IINx ۾ has4??g{*wRԝyuFW! B 7 mXxMGQ 6Enhai"'@g$Za+HTa u,^k+nD"*Z<=f MbY|cA4FsI&Ԋ&dP;h|?MS.$>'~?CyuvhgkTHsD{$ZНa+*pemܹD [ AM[Ô!*dIP(2yge&ZH{HB nAMCSq(jKL'EU$Ǝ:4m%(m-xCM" т[!Ad!ȅxO'l+y!? 5J6lk^Y=S njb{*%lN<,Z8xD\qW@ noTt '>}JD J9CL#@ih 1VQ$ZX4@K P6(( n+yp%F\Nx "@N@) 9m!^/C7j+I03P2GA0cdBB6҃)hGFѢM{6=7m]`eny FG>-- *}$uJ]( r׽ FeU$ZX=?-ό -,Õ[#@eļتh!w]N@ @C^aM@ֈ0HC(BV<YuU haU8-ƍ2?-ϔ,[$@-f|>٪h1tSr#(z'KDtK+r(F6\ Q`BPn5Z$ZX =-LND+KD $kDV ha1_(Z^.f0Od##R}7:KL"T&0-, ̛LpiA˷Ӊ&DP#N;KX?ݵ53< 7ܤA`9_ g>5NPO 0Ey 3u!=RSu6d"`kH[-اҫmO0od~q|ʜ T_כc2UZ" LDAN"@?G1' !&GF޴4={>~P'ϏѣXeUmE}1F:tj%b{J"056 MGqHTܴ0-Ñ"@} !5SR0#:޲2w@lK[-?5|bF#wǻ6J"IV:- "#@X%"@,Gz*r Ŗ[,;,QQDxHCv}&KDtK@("(mC E{ڜm`[>XEHNi4F|5 bMv9o4_pd"B\_o@sqE G .uZ|zT-u$!%`+15w ~MֈBpCVG$(m!^6MК1^&|"'lH+*NJ¬塨6w-Zt,;U8U!t~8ޭ`Y%^Qu|g ~ Q=mX3(ΐh1(i"`^-Ɔr⺔;P^?i647`F@ւΟ8j KqBN`5z7 W%?w6oA .Ѽ)cem"rHٻqoa.ǰEq{P{r[q|[p}=]`=Agamk ÞGH̴J/N=_sv`!|udWoOcwisO`| wc[ <tŀ tr+ D(hA)D Z UC"C`[ tZ7|ᣓZ16T+@Dt Ba$[[Uo aChwR&C(/Щ6* ["ÑGO!w~_OX@ٯP˧@%k3$ZX1-`#0kBY*3I1Xż?uéSbC~>TZf:Oo#Ǭ6ȅxOF[12X2UŎH*\9b fp.^ӌPO-jxnmO~mD·~mOikӟEd A^SdP # |uOy5}I"ui|2ffb߫󝜶vGKJO@D #A{T "nLFG=-#"0oP^nLQKn.(om5GZɆc3HF*o)EqmY"{cBCe?;u pjpN-k?T?]>וW"%-$È 5~?킯2 ϽzPͼ_ȷ|qt )գE6m=F*VKx%5v6lk[}؉W$%x ᎲKJ! C9o"Q3gwϵ- dkD];1hʅ3w: !v3Zz٤haľ_8FA{ 虋:2R@GMmy\5&$]v={! 09"hzwE! bg&a$vŽ'[$.h>V O@Q9D 3BoFbSuvo)u߆P⾍=l`M0tPNVb}+ѧ'*8U+B#[Db}Q$ª^h J0dĶA"俟bWL@ Z_wW !:>OۙCLv䫫oZyMQ +0&D2LWl;%G':R|xĝ4ᤚ6!ɓdҤvM vNo79L1|_z36wb=6)Z޸AκWP3˟C8z> :%Aob"z?x#ƞRO\p+NCPq^|i/n^:;N|!0:SlŬOLS:_6$Z/A \zm<|҂ 49ř|DC`nB,elǷFo9D$Zos9[@q+Z…8]ضDzi $?rv!+pR7xᑉ-&"<z=D{>0tVϯl"ƃ:?&itJ+JEcԐ xv+)VhFǫ%W&V/ 5#T2^6sRlS&M-0+v.gެѦ\F؃vǿ5Z-z_Zh׋/: `z0F\}mõ΢5o!SWlk`$MEǬ> `E kvai9J\v*~x@^]JAy1Cڬ]D 7>e`F,|UQpPNkaU|n;o23SVSrssLKv.9s-ګ3y54u)x twoq47ϟ$$Zbn)%qm\6 Ã4o|+Xxy*enނ$ Z¶ncR܌=o֓WZ,¥m\`usq^8 A$%*E|X9Wh<7zh1˻@KEGh|3\Vq4lE: ;-"iF;a;.ʠo:u;Jki ILUU(%;48:MʒKمmSE. 3ƩdףB&k\e}~uhƮ343~ tq{mݎk9#tY;1~tx˵.nnd?}-Dd HVuF{` U_`>4QƷ"^+$67@*( "! 1Om@5/bk%a9 IDAT.O4)۷&f7!̳D5{a\xzrŘ:Dd_tA%=hЅ$.a킘R;ll/.&V&`-B+wT+S2mF̆@( RKw~?TQp%ehOqw+e X+}^u9A4tXkα׭3r1%0uOw}"؄E,W*V-r!^뉒9_`1Yki%x'J*&޹L-3;x 4KK+DwWLl+j`o A:c#Nf󭋚P!Q)*&)r ֣c0<`> ha0P^ȫ/ޜ t}BOZGP җukn ʋi7"<}n\,8}P_OhzvqK~"É%]Qn@ Su^ͱkٱl Y<cc܆BWQD@w"P+}ɼϱFEfdHENյ?vh;@ޱW9gysv <3[`'f`5g|v:8ޙc8-#oD{7<Գvzuqﲄ~FD~n:x#ռѱf+goQ|u'WHk1W^.1r;Um0Ac^xevUBؿi kQ#0oLG@t47nN} 祳ywcKW5v(q[jn~$:E '_C-"G}.4f7WZVȩ x%MzE"¢xM3Qh+5^)/|DXiOz(gGB _eCft x?{%{ kВ^`{M>6B% aSP)h]E{5 =nnCKV=O1MC;_#ohaO.MH#&i>:TYH(A"Cz $#09R{zFЄV5BRGA^x; =>n<ՃX9ۋ8\)Mxa'io\XE̠a]bJq.%cTHrs 5r)hN?XK;o~6cjN$H?Cl(o7/#0 <9TM5\m'v ( #>Zz{1܂y^dl@(Bu1V۷ }{U0?W-4ݽh!"~Ib!d PȠSh5*nC i@'܃%c!s[+/ pE’4*G\IT1AUUh XѰR0! re_d#XO C[G]5K"&{d cP:ы\82@uDTfFEኤ$.`xsN? A 9@ë/ݻ?rES<,1Ĵap\]ֶdv7Fb f jQ"D[goe`W@䪤${ohi1V_0 XKv}>xty`v@4 vP,^E[c5NG>s*[,|5Hq%N: j?m1O빋c_;z7 ۧvDb)&<#ڌ-z@wnES8R Lnښ]D qсX P]` (ԚP)zu<&rxljJ>46L~>gA`UB N^qK>戹wj6ؐ$.. >3_e5Va57 E''yEyТwVְ1Ǻw\ n1G%Dw21 {*"`SXDZWUx&UU*;!a^Z5 e+4gh)hM`";6%nvahUfMgNXK0>ǻ{D> 9-eYj0ըӀ)J )@km_lHEw"U)PԞ.A(֠pÉ/E}v{E9 `|i?wuD q8D1oLÐ[Ras'r_O Ceеiah۷Qg1j"ee:%gD (ϫP)?F*H{_E cuurr'y>~ "@ M`nB0_f=g>~@N r Hj DҮٖ}z4DW//7&l_iAȞ]Ã>;{Kp qGж r!oա^O`lN>qJ RQIBG#! Qf^;Ryo8;IZ:Ebڪd($XńGڪ`YPք\tT↘;G@*F_%Zg=-֗ӯ(ٮ/tEwp0oP\ԩB.`ESƮo,r"@Nv Z扰+ ښ\|yo AD ȃU[2.Щu/Ul4|x-ʿW*-b P>O9w CTV&tL GJ~*a~/7ߴq v9r8F-!RWݘ!Mf:'*Qk>vUu't2cYyqkC^ahj; D"`H8-mv0ˠWEkmѢ#T=9WaDxQh8>a*9Vm.D'M8> "`HܑDZHy`2"|AߢдQg ac!;$`KE&cFt4o֙]_?2{"p9fEF.ؓ+ D!@ D%v $ЩtP) ;HpL*Z8&m7wcftg `#O5c$šim"`HϼD`h1hi>1F*)o#UZ CJ4B}4K -4Cv8tD L*D!ph9fǴM{6@h7 QXgL+&@]ϡ'¡I h7_XJ6 i硐ha $H0N#NGn+ Šii"`H,9$Z8G)J"@c$Z8v~):"` $ZX*$DD JBy}"`"h?fjUw\}l3@ “1 M=SKnL?z8.s7HS)pgPq.gނī5Щ:J\zDBQ!Q.zQ{H3c_>]l\ngn+8i}?*N~o}O`_yfկy+9/eO0&>c-{ {;&=3ĮW#o;cڍiY$ZX <-K)sAW&)Q ގ0EHcM;jIِ1 !qnEky0p ֶɰ ]n vů_?(.&>"So~pX9Agamkoxe=(W1זl?bk-#xwp)5ŝ11%}/*]O^c 9#x֤~gx2]iÖGɫ`{pႎ{<cиFіOl^ oLz|-<Òzeq}q/3{XL^vVDj;o ^@`?zHɴSDvha;pvO|]tHTT"gA%&P,[HtDhq6ɓ0"CO|O|oh'O@ D}>^!qI>H<|i:Z2 fכ 6k5P삈: T-u]lDB)ܯQ[4r ~JC<"T[[a`G7BQ[5ƈ =(qAĤc|Ƃèe=+|ҵzJՑ}O*暮1/'W0[ݱgL}F?GS1QCM#'@s@"@)-AVrН"V-Φ/VD+7isؽ2e{ H4Dv ha!ψ H`fiqJWx:bN V u7U N}PV%0=d8|[8VO9@l"h>Jxh WsOw>=-&I"vHD ;LLS>:wIY_FBnDD 76]ODMk;$@&́]!=BR4()F`5ʃy'VKGM'Bs(TvkY=.-r6-l;?ݒV Qcoqg?.KPbp}Ѫ?E čEh"7-fD3S ©osp&(2C~W'+Q$ҭtܩyږg-&? (67DF ha! V2A~0Q>Za|r ]  DS6E@Q(.Z|i/n^(rֽB7YbWOLz|-m YuU$ªiq"`H9/_ЌH/-rxzO"@욀V[>A:Ycadv 9ߕi1_La"jia"`H<9'*pXDF 5sSD7g-H E[>w2'] m6Azs^齈.}.ԞaNdL;?#Z\撓VD } >L^x&xQMhR>g(V"@엀>ѣ3C-o?'6Zs\tFKyf6oW2RATd6`=( c߸: 57???9:7iE^ nNqwg6rvz ~?8hq>%"H -PJ|?hL|xE"'Q  _/Cv!p_r:J t* %@O+*E@Hp[DZ$bSBPo,4x b52m!܂xq{lBП珠Ƨa}_&G77 GkE.|pQqWFND Cl@\ЌVmRCj/"@wMQbSMb$HXB>>o8XRĔHpl!bbɬ MyICR O®g/zkgD kg'6ND ODŽ :o . \ۆE#.kK9h1G赽7.e1yBQWڥR'c.ER}UwUoh*>ֲS&· N%dhѯ[&#@"&DXcx/P T)@bnkvIxF&W \s &<7$ʃVP6T8øPĤyH~H<kMGp;SƠoA45`Ъ:?L}zN:cgނīt~xCKLn(0t*/Q#iZ]}= =s@$F (>5ŝf[o>i !K!%;q|(uzE} kR:NbQNE]&4E[S5F,~ yMNJ5X/K(@^WCpF{0H<1 ^CoƯ}3h1i-"`HäF."=b(nA#tkk`@&G0)B߳t#hPR?kZk:hz|OF}Ξ~a3AK18_5@^ڬ]acCwL )<?後[ػjDϺa6[+rw5EOe6N@֏+I&e}>=jE N~ G̬+jv=wT&~ }ًғKOhTXS?ZfhN#NEbu[H6]ĉ8b4Dҕ j?QKB5s1vǏMq ~ԭuUAEm12^Ə=w.a.8n oGxE"~bEu!jo{HM͉O I,EE7pM඙?&[m ofD 3$3DQ hᨙu^Ռh* b 2rJaW^T˅CةhC[b\q¹Eƶ9b\UQ-_½zl.;˔rmZMm]+X=&WY"uDe'.Z\(ɢ-#w;?bWwlYHeVmq;li0P\gG_!gK6oٻ<~PдKC~ _>h5y!4m."px>oN8}wMe@piO\Ҳ64%\23R̬4\RD QQd;(" 03=;>w7\ˣZ (-Ȯu),B9q8'UMz3!_8mYT>)Fo6gkH޷1*)Dd;% F$D!؍sp;I w/>!v?¹-}ҭN!ת!J{7t}syҞy5x\S?χK006wM1ܔ3z1ofҢ15Z-pRuhHzec2ڮo*q ߓy[oBM0*NJ֩Wiʸ[;e좚Wu [#b06Xz_ݜ+d3ƚ[n05,Auj-7?5>Uۼ߭QXZ}ZP.ZWSK0BECJ1DqW>x OZvLڒ #gɕ | YVk ,]4?[nI"?!k2̖dڇ+SNވ˟m bDVA6  3/!읻W:oஏtFU#]eʻ><$ E즊U"ir MDYt)+.Ȩ5ørlLbxchEm+qbI?ab, {*EE2!VMsEN);gճptdtq"tz/9_!c5l*{_+EUƽ:;t?/bsm=RugLZ4(ۣ@ `ҢM GlѺX.IocY"knZI*͜x/k4B!χ|ԑhjdCcXIvbj:'-ng{p,>$WS%02Eׯ#ؿA Ve$y{{ y\4bkB òM`y/>Cߟg,\ܚP6#Qnd.ǁeV{̡-' %+wʄ=cwY? (##dE u`ivbUXSSўcG!l/|G͆sCrk(6z1_7]eʉ]+X)G]006ye[; x ()t/juAo)îi*VNcǤEc( `&-Z afPr:Cln?7pصzv ^X8 I՚"-7/%W -N@~ 8|bV0a--'.}F#()̃/:,}FA3+ӟH"dBC.sdR@ZFm砡OOFԒGaն+_ܔ2s\=waW\ad%1#s CVWvDPϩ!742D7J62Y}%D#xL\(C\os& Xrp8KqڭvyȈ݋H f1q#']gh v aVK$J P\K{h0i#aRAI#wZu &o8}52NG-(/.@1*.*5vB`m-UxGbd=ʤ8fw:g4|/! MyR6!S&GRF!sP%g~[Gw bK()s^_2.Q0P؞~8oҡ ^>UnK|G ޣP # {q*+ŶjG+k\Y~D# [ل()NK]y5GD__~#g0'Z ث&-%~)%LZhD1L P0iq{$>uȢwX-o3>\e_FGiS=/Vu9ߊ0w2E۹D]Skl]LZ:v!O\ !V)\5! vwVۙzIeh!LZ0(@ P ENخ5l LVLQQXWsw+{>PUfEѶw?CS[DHڷ?7i c&-te9N P@ 0i7 epu+#ni蚻bn =^VټJuDAS#3fȓMđ4CI ͘FA`BcQN@דVmMg\ yu;Y&Y<ؚ7rO !܎g)LZ$s(@fE[`b*\5.wE&-tq9f CIz`V Pj`(Y 1-[UI A(@ Ph,&-oå(IsjJ rPE 0i"@ 0ixl(LZJ tu p`ٳ5HHX{Ԧ(IטȘИ` L&-4s^(@ P@Yc̺ %p|j8b63 )f (@ hϚ@tzt MP#+_F(D1O+]ki={ޑmd\DGPr_ pêSA5zFJF‡QV`)_ۼq׃mȶ}\urU>E?8>#fk緭>zn`bL9d]8ҧO'ֿ]k~g~V>^7k˽XtPɣL?B({fwyI1i5&-4f*4SI ͜FE PF&-jv_@%WZ*my~^Z(;\>C ;}/ؽ8|j[M[x vBYZډ]8ŋ*m<<w^{Mc44By{I>Җ{5tJM_U fuAcMq8+w/4hQ5xh:]TwK Щ?̝szp SKYb&gb~|POUһ(>XV1jcuAkxldY\UeC&ýen/ǂDSNl +`Gb/8nJ4(46=)50BD P0iQ}=a~s[7t~z ;]u!(Ne.굤}?!wT A6շm;m:=A5*~}Uu߿SQZ[?[3 )V35~7{wOts[ߟ5a-<:FUog\Җدo$?}v}U%\_&qc(TS$Jlsz 3U}&4T;lc?Q2, PAI m%ƨ!c5ld8n.FVIZe9P[{Q^^VxvNm۪(zq$m(@ P@3Ќy`@e@rĦ[ޤ?שEi=`hiSs"QSܤl-EXL(A PPjg(j!mSMsN%-ڽƭLq(_)lQJ&3 bia#p(@ Phn]OZX{ոwR@mw?;0M`-f_(@/INc8>{O8GHFЩE`?F!stMPE 6\%GZ8 P_@דO?˞i `@'>yyƽUAw)5$[@ C=q )δrH(@ 4'- l<{ T8hyB]^s|7ۈ}5@e@t,$ ܅N%-*5.LCLJԆΎ)-GuuWG$t:I[qR4U@ד~f9x8ޭ49<\>e~1iMqE4#s 0iQ(lllLRQLݞP+pjv E@ZM c;ۄ(@PpEvbLA͜#x\$BHɱnvp5>#g!aw8vӹ{31رwi5ZwO@%TЩ>@(H=072 r1W$esJ)p8R~DZS>0}rrOX7CE@i ; ۉb6lKZ8 t}7\vt @ L\Avl:A( P(pLZ4P}yؿxLU#fp znwҡL^Y2 4z<2mVzt"o{1dɍsalFHInOG}/}/"9|#NE>ҹ\A<-f]Yxn eg{sWv0[RFI*2g Mm@=Klw|@) IDAT\t:SA~q[>Ǔy!3n4shDƑկ!nsI f@WCޙL)P<@ƒ(J/(@ Pm&-N<7;I]ѦJ;P{UnIp sWBUm }S\>.Z!tzjkO4h ^Z =Kl\:N&f+B >%ҁ?j [1 fm綯Bp>\zG_YC8 < P+wwPW%ɩ`DQn1T= #at|}`:|\l}/9p0|!x(EY 24, [2ɪ8ԙݤI }sCtx8"kQH<ܳ,X`(@%IO~Gt/]S[[MlI]o +`!H>"KÕaHؽWO?H [x #8i}#v\=G>)](N[# [lyO a׹?qc7~[G~`hn c0z"PfjEr:+WP;X~G[*+6/y_~ C9-xƟB \5d?$\,R+Vy@ι_dus4Nʣ+N<.GL&{ p9z[}ou6ialWQV\s"QVXhl,9H 2\0v P4L@דҢ0(cU 9K8K[KlPf6fF">[;~;zM[>"D,t-OI?nWWqVyck'}suOl 1[jO6*XD^r_!c5l*{~΅U.NA6k' a%_ևli%VD~8Ag懝o)$!IϞU94c<0a- L-oટ$JV|ENRf17D\A"VT\)qx o׫8R7PɟE➊-It6i!uF] g>)Q6}Zv(.PVVQ1 P[@דSB4sK_v{'tyz mUŇs ؚ7(vAQ\7m@t{+!r#΅>9J vr7|GF>(ɀ`٦31mJQDM z3l<{$(JBIqV*> WdSX|KGٿ>Azt\ R#//h+\n 9arEÅе=zBiq~ǻ< ;!tVE K;z/* VCaZ\!Ryk$EKUCr%M}.Z+Ʀ> A)W[[%`b/>-j| (@ P@w"J.T' .ؿhc1#H;*Dr2H+&9xS#%wdBHlxa [,:\.5NAyȈ݋r;/:QEޥx_ܔ2#^=aW&*r/ pa皛R,wJ@VV-LLK:= GqIՒMæ=q+F 9cBg-2m=C'cd^DAZ"ⷯFAf D!V1g:"V'yᆰXDlDɭ.I}.QԱ-m嶝[{uzd|P^RsF~ h<&t84D\RYE PSIko[u U^4G/gx^HE*=};BianUpJ{wLC+kab*V؟U!%SEԧOiOle"vӂaMDaUAIⴎ ¢Hn;V8K-(/.@1*5A*[=:ߍwxM" oey=cț(MG^o>ضo O9~,z).qHWb|bmˊ媚;:-Fl'WZ[y*7r6 smt>i!&Ճ+[h;1ߖ@V3HXuM(@ hڒ_@_A zuifN!&ow+1{PVZS6# onӨKH \yG^m"Qr/Afqy2^"Q2uZL%-DĽ?BH:X9`̕+q0s񑧆X700.EU ܊"A ?Z?ZZT >?Oi1{d6T~ 8FHM}dZISb /ەz[G5>褅1ze%e9\ 盵)l[=fM P$zfRtG@]I iuZݔaF!!;dž7eW:vY[vrb~xG-@ |$@M:OZ7uBOiH!%ߤ6,",]!}E[V-!2(JZ-/Gډ]8ŋj/O\9 NcN~ZOh!7zWY8Ƚ׵A[6~cqu}AiEҢrdfVp sCj%dD\DI35hZaV0vP ~;(@ P@ԕl!!_^Y+H?KV[бc%8|MOO!s1?BҾ m궟#gVb~lKZTNB_K؅¸R,V_g"\6Pp1(20vT¤Z¤L+ޯYEHKFFx 4.9[(@ PJZ1C GN Gč/caHF0q "^#[5q|5߶X\y&-E';n Sj5/ !%%Fyq\_.WZrՓS@zVzr>`>+͠lk Sj\HM6(@ Pu%-ĩw/>&kM#,4]obOGYq0 4Nwn8ZDҢRHJ7sY&$^PH'd09y)N+PW8(@ -ioCQVZgǬU;6T9)OpDtnoQInd1M ԇRJ#)o"^_wS@ 4e(/Ci~OQ_*\YTE(@ P`u&-iNı5ro)y?<nw??ƹ_-vLm;o/ M:7r6i->iѤzl(@ PZ.Τk>ɍs&)xDYqvAi!xRdcW6g`ˤE P(@ h:.>9O!|CjCyڦB/5)QکM MN(@ P(LZb~cee8m͝,Fs]©@$Yӑ1N·S`7yLLZ491;(@ P 5i+JLFB#s"hݶ w%5'4w-}c3XBXeVIq!kK~Ę&LĺJ uh)aB9I'Q]}fN0CIa.ʸ d%Š4?&m-J rp涔t5O0̝`df-J TxW~(f#7LU^Z Ag`kG[sEar\M}1il(@ P,/cx Ǿ{K{k9vA*qv3P`}UND1t@Ȼ;GE##o fXؕLZX7SBLpjE'?@$Gl‰g4KLLZ4 3;(@ Pf ;i!T[{8o-u΅C=X-0=D7rĝۃ̽y%D&-P(@ P@34!ia=^Y#k7ͺ Ũ(CrbbI0il(@ P' I `؍s{A1" 谀(+걈®Q>g4F͎(@ P(yp|7:?DkXm ͛9FD\GܴiSF¤ESm P(@ h$- S (ppH;9X:$g`?'R5N)@ P(92~߃s0Y"Ñ(H0FB=,)AaOKRșP ;;(@ PfhbBO_VmcPWO@ƙ((I!\;;e]C˟U'&-Zã(@ P@mYء˳ą6/EeL P ,]|p3@i" \9ڄ=޺i&-nm;(@ P(b45i!2n!cƩpjJsZ|p`PSA=zFš傺©I O(@ P49iQ"\<€yH]P{U}p-@@5p]h3)X#rb~J5bLZh40 P(@ G@B̩=>&?#~jT[j:`LV:Iy96-kȘШ`0(@ Ph^mIZ}#8 F>h!5z+mى1͋(e&p>.T&/ i܈и)a@(@ Ph>mJZT%dO¶CPVQ%d'DVId'?_Ih>LD  XVzz2ʢ4$CbzjŤ (@ P[@Y:ާ7l;FVTrQ%O-ɫo˚Q Ma`j% M`hfgT't,hv:YG-lIe(@ PpmOZk6aSFgQG#T82NG TxuԄ-3iфl(@ P.Ғ7zo L-aZ\ӧQV"e Ź()ȖJQ"hAP(@ Pa-=i0>E h@CGp컷j KaccS)3 P(@ P޽ph8(@Fh8_p|t&-іMQ(@ P@:?"|d)@ps oLZs(@ P.;@G])@ hwߋhwKۼq1iap(@ P(Ф6#~&틍SCSw,Nmzv}ˤE}y?(@ PY]Ϣ^py(hE8n.FʤE c(@ P7p  Q'(@ hGVc2iQ`(@ P((yDz)@ Oǫº]7D}2W2iQ?>M P(@ FW"T8;h ATIDAT,{F)@'k0uj}An&-Z s@(@ PE}fmEQvBgЊ(1`A1;J Иa (@ Ph&s`hf #ze7jtGVqD.yK.M- (@ PV t~c8vNŨ?*vK \Okd$rLZܒ7P(@ P@{NF 8,#Z@g>wؚ7r[I[ P(@ he.b7FL HԪ[טho(@ P@ oa :7r: O Y@q^vMSho(@ P LVmqr{H [}`@ex 8zbƤEx(@ PO=1x { i;>"#@B@i^#[40&-ě(@ P(}zywgŇU7FL l'!콁@LZ (@ P(P@Ap5 >K& P.P(d- SޮƤEO;(@ P@ Yء;!fG.|YI'sD P_[,AYiq]3o(@ Pv I8ȏvQS"oDДajKyYBcҢ^\(@ P'sA[Qr\ص6׾A0b P@+:?3(@ P x>*<}^FϢZ: Z&syOF}bt$GR0iQo2>@ P(@-__!|P'i@1(fN9iFH޻'~ՠh(@ P #aI1X8\)@#my/y13i`:>H P(@0u@ФoDfa^*Ӵo Hsg/ty3(m]P} CQNzceҢt|(@ P)`-4j%?L[&O0j P@c930BnYZ 2o+>&-nS(@ P@;̬ɏ`! (pn \;Ĩ)@ {L8 1\> ־ێI&d(@ PR^߀{RmR~GYiaS%okQp7N(/+Ù?ՍFɆ(@ P(܃c@X(; {˓FxQ^m=ޣ`?*ѯ'!Txb1iѨl(@ P)`ha6!A 1l=. inPڻg0X֭IKH¾Gi~N1il(@ P+gh p8Lʁ%NEvI$D慣(ށ2r PVc+GX歽a+O#a׷H9KKLI&e(@ Pnqtm æ}<&%y(BI~Lb^s~vS@ V02 04wHMo!&-P(@ P@tmxæ]7([#(@Jqld"IjAbB-(@ P/` @WYWehh9 ,B%9(οR4?[*.j dҢ!j|(@ P(@&`Ңɉ(@ P(@ P `Ң!j|(@ P(@&`Ңɉ(@ P(@ P `Ң!j|(@ P(@&`Ңɉ(@ P(@ P `Ң!j|(@ P(@&`Ңɉ(@ P(@ P ?2Oz%ȹCIENDB`presenterm-0.15.1/docs/src/configuration/introduction.md000064400000000000000000000026361046102023000215340ustar 00000000000000# Configuration _presenterm_ allows you to customize its behavior via a configuration file. This file is stored, along with all of your custom themes, in the following directories: * `$XDG_CONFIG_HOME/presenterm/` if that environment variable is defined, otherwise: * `~/.config/presenterm/` in Linux. * `~/Library/Application Support/presenterm/` in macOS. * `~/AppData/Roaming/presenterm/config/` in Windows. The configuration file will be looked up automatically in the directories above under the name `config.yaml`. e.g. on Linux you should create it under `~/.config/presenterm/config.yaml`. You can also specify a custom path to this file when running _presenterm_ via the `--config-file` parameter or the `PRESENTERM_CONFIG_FILE` environment variable. A [sample configuration file](https://github.com/mfontanini/presenterm/blob/master/config.sample.yaml) is provided in the repository that you can use as a base. # Configuration schema A JSON schema that defines the configuration file's schema is available to be used with YAML language servers such as [yaml-language-server](https://github.com/redhat-developer/yaml-language-server). Include the following line at the beginning of your configuration file to have your editor pull in autocompletion suggestions and docs automatically: ```yaml # yaml-language-server: $schema=https://raw.githubusercontent.com/mfontanini/presenterm/master/config-file-schema.json ``` presenterm-0.15.1/docs/src/configuration/options.md000064400000000000000000000107611046102023000205040ustar 00000000000000# Options Options are special configuration parameters that can be set either in the configuration file under the `options` key, or in a presentation's front matter under the same key. This last one allows you to customize a single presentation so that it acts in a particular way. This can also be useful if you'd like to share the source files for your presentation with other people. The supported configuration options are currently the following: ## implicit_slide_ends This option removes the need to use `` in between slides and instead assumes that if you use a slide title, then you're implying that the previous slide ended. For example, the following presentation: ```markdown --- options: implicit_slide_ends: true --- Tasty vegetables ================ * Potato Awful vegetables ================ * Lettuce ``` Is equivalent to this "vanilla" one that doesn't use implicit slide ends. ```markdown Tasty vegetables ================ * Potato Awful vegetables ================ * Lettuce ``` ## end_slide_shorthand This option allows using thematic breaks (`---`) as a delimiter between slides. When enabling this option, you can still use `` but any thematic break will also be considered a slide terminator. ``` --- options: end_slide_shorthand: true --- this is a slide --------------------- this is another slide ``` ## command_prefix Because _presenterm_ uses HTML comments to represent commands, it is necessary to make some assumptions on _what_ is a command and what isn't. The current heuristic is: * If an HTML comment is laid out on a single line, it is assumed to be a command. This means if you want to use a real HTML comment like ``, this will raise an error. * If an HTML comment is multi-line, then it is assumed to be a comment and it can have anything inside it. This means you can't have a multi-line comment that contains a command like `pause` inside. Depending on how you use HTML comments personally, this may be limiting to you: you cannot use any single line comments that are not commands. To get around this, the `command_prefix` option lets you configure a prefix that must be set in all commands for them to be configured as such. Any single line comment that doesn't start with this prefix will not be considered a command. For example: ``` --- options: command_prefix: "cmd:" --- Tasty vegetables ================ * Potato **That's it!** ``` In the example above, the first comment is ignored because it doesn't start with "cmd:" and the second one is processed because it does. ## incremental_lists If you'd like all bullet points in all lists to show up with pauses in between you can enable the `incremental_lists` option: ``` --- options: incremental_lists: true --- * pauses * in * between ``` Keep in mind if you only want specific bullet points to show up with pauses in between, you can use the [`incremental_lists` comment command](../features/commands.md#incremental-lists). ## strict_front_matter_parsing This option tells _presenterm_ you don't care about extra parameters in presentation's front matter. This can be useful if you're trying to load a presentation made for another tool. The following presentation would only be successfully loaded if you set `strict_front_matter_parsing` to `false` in your configuration file: ```markdown --- potato: 42 --- # Hi ``` ## image_attributes_prefix The [image size](../features/images.md#image-size) prefix (by default `image:`) can be configured to be anything you would want in case you don't like the default one. For example, if you'd like to set the image size by simply doing `![width:50%](path.png)` you would need to set: ```yaml --- options: image_attributes_prefix: "" --- ![width:50%](path.png) ``` ## auto_render_languages This option allows indicating a list of languages for which the `+render` attribute can be omitted in their code snippets and will be implicitly considered to be set. This can be used for languages like `mermaid` so that graphs are always automatically rendered without the need to specify `+render` everywhere. ```yaml --- options: auto_render_languages: - mermaid --- ``` ## list_item_newlines The option allows configuring the number of newlines in between list items, the default being `1`. This cam also be set via the `list_item_newlines` comment command. ```yaml --- options: list_item_newlines: 2 --- ``` presenterm-0.15.1/docs/src/configuration/settings.md000064400000000000000000000241121046102023000206440ustar 00000000000000# Settings As opposed to options, the rest of these settings **can only be configured via the configuration file**. ## Default theme The default theme can be configured only via the config file. When this is set, every presentation that doesn't set a theme explicitly will use this one: ```yaml defaults: theme: light ``` ## Terminal font size This is a parameter that lets you explicitly set the terminal font size in use. This should not be used unless you are in Windows, given there's no (easy) way to get the terminal window size so we use this to figure out how large the window is and resize images properly. Some terminals on other platforms may also have this issue, but that should not be as common. If you are on Windows or you notice images show up larger/smaller than they should, you can adjust this setting in your config file: ```yaml defaults: terminal_font_size: 16 ``` ## Preferred image protocol By default _presenterm_ will try to detect which image protocol to use based on the terminal you are using. In case detection for some reason fails in your setup or you'd like to force a different protocol to be used, you can explicitly set this via the `--image-protocol` parameter or the configuration key `defaults.image_protocol`: ```yaml defaults: image_protocol: kitty-local ``` Possible values are: * `auto`: try to detect it automatically (default). * `kitty-local`: use the kitty protocol in "local" mode, meaning both _presenterm_ and the terminal run in the same host and can share the filesystem to communicate. * `kitty-remote`: use the kitty protocol in "remote" mode, meaning _presenterm_ and the terminal run in different hosts and therefore can only communicate via terminal escape codes. * `iterm2`: use the iterm2 protocol. * `sixel`: use the sixel protocol. Note that this requires compiling _presenterm_ using the `--features sixel` flag. ## Maximum presentation width The `max_columns` property can be set to specify the maximum number of columns that the presentation will stretch to. If your terminal is larger than that, the presentation will stick to that size and will be centered, preventing it from looking too stretched. ```yaml defaults: max_columns: 100 ``` If you would like your presentation to be left or right aligned instead of centered when the terminal is too wide, you can use the `max_columns_alignment` key: ```yaml defaults: max_columns: 100 # Valid values: left, center, right max_columns_alignment: left ``` ## Maximum presentation height The `max_rows` and `max_rows_alignment` properties are analogous to `max_columns*` to allow capping the maximum number of rows: ```yaml defaults: max_rows: 100 # Valid values: top, center, bottom max_rows_alignment: left ``` ## Incremental lists behavior By default, [incremental lists](../features/commands.md) will pause before and after a list. If you would like to change this behavior, use the `defaults.incremental_lists` key: ```yaml defaults: incremental_lists: # The defaults, change to false if desired. pause_before: true pause_after: true ``` # Slide transitions Slide transitions allow animating your presentation every time you move from a slide to the next/previous one. The configuration for slide transitions is the following: ```yaml transition: # how long the transition should last. duration_millis: 750 # how many frames should be rendered during the transition frames: 45 # the animation to use animation: style: ``` See the [slide transitions page](../features/slide-transitions.md) for more information on which animation styles are supported. # Key bindings Key bindings that _presenterm_ uses can be manually configured in the config file via the `bindings` key. The following is the default configuration: ```yaml bindings: # the keys that cause the presentation to move forwards. next: ["l", "j", "", "", "", " "] # the keys that cause the presentation to move backwards. previous: ["h", "k", "", "", ""] # the keys that cause the presentation to move "fast" to the next slide. this will ignore: # # * Pauses. # * Dynamic code highlights. # * Slide transitions, if enabled. next_fast: ["n"] # same as `next_fast` but jumps fast to the previous slide. previous_fast: ["p"] # the key binding to jump to the first slide. first_slide: ["gg"] # the key binding to jump to the last slide. last_slide: ["G"] # the key binding to jump to a specific slide. go_to_slide: ["G"] # the key binding to execute a piece of shell code. execute_code: [""] # the key binding to reload the presentation. reload: [""] # the key binding to toggle the slide index modal. toggle_slide_index: [""] # the key binding to toggle the key bindings modal. toggle_bindings: ["?"] # the key binding to close the currently open modal. close_modal: [""] # the key binding to close the application. exit: ["", "q"] # the key binding to suspend the application. suspend: [""] ``` You can choose to override any of them. Keep in mind these are overrides so if for example you change `next`, the default won't apply anymore and only what you've defined will be used. # Snippet configurations The configurations that affect code snippets in presentations. ## Snippet execution [Snippet execution](../features/code/execution.md#executing-code-blocks) is disabled by default for security reasons. Besides passing in the `-x` command line parameter every time you run _presenterm_, you can also configure this globally for all presentations by setting: ```yaml snippet: exec: enable: true ``` **Use this at your own risk**, especially if you're running someone else's presentations! ## Snippet execution + replace [Snippet execution + replace](../features/code/execution.md#executing-and-replacing) is disabled by default for security reasons. Similar to `+exec`, this can be enabled by passing in the `-X` command line parameter or configuring it globally by setting: ```yaml snippet: exec_replace: enable: true ``` **Use this at your own risk**. This will cause _presenterm_ to execute code without user intervention so don't blindly enable this and open a presentation unless you trust its origin! ## Custom snippet executors If _presenterm_ doesn't support executing code snippets for your language of choice, please [create an issue](https://github.com/mfontanini/presenterm/issues/new)! Alternatively, you can configure this locally yourself by setting: ```yaml snippet: exec: custom: # The keys should be the language identifier you'd use in a code block. c++: # The name of the file that will be created with your snippet's contents. filename: "snippet.cpp" # A list of environment variables that should be set before building/running your code. environment: MY_FAVORITE_ENVIRONMENT_VAR: foo # A prefix that indicates a line that starts with it should not be visible but should be executed if the # snippet is marked with `+exec`. hidden_line_prefix: "/// " # A list of commands that will be ran one by one in the same directory as the snippet is in. commands: # Compile if first - ["g++", "-std=c++20", "snippet.cpp", "-o", "snippet"] # Now run it - ["./snippet"] ``` The output of all commands will be included in the code snippet execution output so if a command (like the `g++` invocation) was to emit any output, make sure to use whatever flags are needed to mute its output. Also note that you can override built-in executors in case you want to run them differently (e.g. use `c++23` in the example above). See more examples in the [executors.yaml](https://github.com/mfontanini/presenterm/blob/master/executors.yaml) file which defines all of the built-in executors. ## Snippet rendering threads Because some `+render` code blocks can take some time to be rendered into an image, especially if you're using [mermaid](https://mermaid.js.org/) charts, this is run asychronously. The number of threads used to render these, which defaults to 2, can be configured by setting: ```yaml snippet: render: threads: 2 ``` ## Mermaid scaling [mermaid](https://mermaid.js.org/) graphs will use a default scaling of `2` when invoking the mermaid CLI. If you'd like to change this use: ```yaml mermaid: scale: 2 ``` ## D2 scaling [d2](https://d2lang.com/) graphs will use the default scaling when invoking the d2 CLI. If you'd like to change this use: ```yaml d2: scale: 2 ``` ## Enabling speaker note publishing If you don't want to run _presenterm_ with `--publish-speaker-notes` every time you want to publish speaker notes, you can set the `speaker_notes.always_publish` attribute to `true`. ```yaml speaker_notes: always_publish: true ``` # Presentation exports The configurations that affect PDF and HTML exports. ## Export size By default, the size of each page in the generated PDF and HTML files will depend on the size of your terminal. If you would like to instead configure the dimensions by hand, set the `export.dimensions` key: ```yaml export: dimensions: columns: 80 rows: 30 ``` ## Pause behavior By default pauses will be ignored in generated PDF files. If instead you'd like every pause to generate a new page in the export, set the `export.pauses` attribute: ```yaml export: pauses: new_slide ``` ## Sequential snippet execution When generating exports, snippets are executed in parallel to make the process faster. If your snippets require being executed sequentially, you can use the `export.snippets` parameter: ```yaml export: snippets: sequential ``` ## PDF font The PDF export can be configured to use a specific font installed in your system. Use the following keys to do so: ```yaml export: pdf: fonts: normal: /usr/share/fonts/truetype/tlwg/TlwgMono.ttf italic: /usr/share/fonts/truetype/tlwg/TlwgMono-Oblique.ttf bold: /usr/share/fonts/truetype/tlwg/TlwgMono-Bold.ttf bold_italic: /usr/share/fonts/truetype/tlwg/TlwgMono-BoldOblique.ttf ``` presenterm-0.15.1/docs/src/features/code/d2.md000064400000000000000000000016241046102023000171750ustar 00000000000000# D2 [D2](https://d2lang.com/) snippets can be converted into images automatically for any snippets tagged with the `d2` language and using a `+render` attribute: ~~~markdown ```d2 +render my_table: { shape: sql_table id: int {constraint: primary_key} last_updated: timestamp with time zone } ``` ~~~ **This requires having [d2](https://github.com/terrastruct/d2) installed**. Similar to [mermaid diagrams support](mermaid.md), d2 diagrams: * Will take some time because of how slow the d2 tool is. * Can be scaled by using a `+width:%` attribute in the snippet or by setting the `d2.scale` property in the config file, which is passed along to the `--scale` parameter to the d2 CLI. ## Theme The theme of the rendered d2 diagrams can be changed through the `d2.theme` [theme](../themes/introduction.md) parameter. See the available themes in the [d2 docs](https://d2lang.com/tour/themes/). presenterm-0.15.1/docs/src/features/code/execution.md000064400000000000000000000177251046102023000207040ustar 00000000000000# Snippet execution ## Executing code blocks Annotating a code block with a `+exec` attribute will make it executable. Pressing `control+e` when viewing a slide that contains an executable block, the code in the snippet will be executed and the output of the execution will be displayed on a box below it. The code execution is stateful so if you switch to another slide and then go back, you will still see the output. ~~~markdown ```bash +exec echo hello world ``` ~~~ Code execution **must be explicitly enabled** by using either: * The `-x` command line parameter when running _presenterm_. * Setting the `snippet.exec.enable` property to `true` in your [_presenterm_ config file](../../configuration/settings.md#snippet-execution). Refer to [the table in the highlighting page](highlighting.md#code-highlighting) for the list of languages for which code execution is supported. --- [![asciicast](https://asciinema.org/a/BbAY817esxagCgPtnKUwgYnHr.svg)](https://asciinema.org/a/BbAY817esxagCgPtnKUwgYnHr) > [!warning] > Run code in presentations at your own risk! Especially if you're running someone else's presentation. Don't blindly > enable snippet execution! ### Output placing By default a snippet's output will always show up right below the snippet. However, if you wanted to show the output in a different place in a slide (e.g. another column) or even in another slide you can do this by: 1. Defining a snippet's identifier: ~~~markdown ```bash +exec +id:foo echo hellow world ``` ~~~ 2. Referencing that identifier where you want the output to appear by using the `snippet_output` comment command: ~~~markdown ~~~ A single snippet can be referenced multiple times in multiple slides, as long as the slide you're referencing it in comes after the snippet. The snippet will only be executed once, and every `snippet_output` command will display that single execution's output. ### Validating snippets While you're developing your presentation you probably want to make sure the executable snippets you write in it are correct and don't contain any syntax errors. While you can do this by constantly pressing `` every time you change a snippet, this is automatically done by _presenterm_ if you pass in the `--validate-snippets` flag. When you pass in this flag, _presenterm_ will: * Automatically run all `+exec`, `+exec_replace`, and `+validate` snippets as soon as your presentation starts. Note that the `+validate` flag is a special one that doesn't make a snippet executable but still validates it by running it during development. * Report an error if any of the snippets returns an exit code other than 0. * Re-run all snippets `+exec` and `+exec_repalce` snippets every time the presentation is reloaded. In case you expect a snippet to return an exit code other than 0, you can use the `+expect:failure` flag. This will cause _presenterm_ to display an error if the snippet does not fail. For example, the following defines a snippet that's not executable but that will be validated if `--validate-snippets` is passed in and will display an error if the snippet does not fail. ```rust +validate +expect:failure fn main() { let q = 42; let w = q + "foo"; // oops } ``` ## Executing and replacing Similar to `+exec`, `+exec_replace` causes a snippet to be executable but: * Execution happens automatically without user intervention. * The snippet will be automatically replaced with its execution output. This can be useful to run programs that generate some form of ASCII art that you'd like to generate dynamically. [![asciicast](https://asciinema.org/a/hklQARZKb5sP5mavL4cGgbYXD.svg)](https://asciinema.org/a/hklQARZKb5sP5mavL4cGgbYXD) Because of the risk involved in `+exec_replace`, where code gets automatically executed when running a presentation, this requires users to explicitly opt in to it. This can be done by either passing in the `-X` command line parameter or setting the `snippet.exec_replace.enable` flag in your configuration file to `true`. ## Alternative executors Some languages support alternative executors. For example, `rust` code can be ran via [`rust-script`](https://rust-script.org/), which allows you to use external crates. These executors can be used by specifying `:` after `+exec` or `+exec_replace`. For example, the following `rust` snippet will be executed using `rust-script`: ~~~markdown ```rust +exec:rust-script # //! ```cargo # //! [dependencies] # //! time = "0.1.25" # //! ``` # // The lines above will be hidden fn main() { println!("the time is {}", time::now().rfc822z()); } ``` ~~~ The supported alternative executors are: * `rust-script` for `rust` snippets. * `pytest` and `uv` for `python` snippets. ## Code to image conversions The `+image` attribute behaves like `+exec_replace` but also assumes the output of the executed snippet will be an image, and it will render it as such. For this to work, the code **must only emit an image in jpg/png formats** and nothing else. For example, this would render the demo presentation's image: ~~~markdown ```bash +image cat examples/doge.png ``` ~~~ This attribute carries the same risks as `+exec_replace` and therefore needs to be enabled via the same flags. ## Executing snippets that need a TTY If you're trying to execute a program like `top` that needs to run on a TTY as it renders text, clears the screen, etc, you can use the `+acquire_terminal` modifier on a code already marked as executable with `+exec`. Executing snippets tagged with these two attributes will cause _presenterm_ to suspend execution, the snippet will be invoked giving it the raw terminal to do whatever it needs, and upon its completion _presenterm_ will resume its execution. [![asciicast](https://asciinema.org/a/AHfuJorCNRR8ZEnfwQSDR5vPT.svg)](https://asciinema.org/a/AHfuJorCNRR8ZEnfwQSDR5vPT) ## Styled execution output Snippets that generate output which contains escape codes that change the colors or styling of the text will be parsed and displayed respecting those styles. Do note that you may need to force certain tools to use colored output as they will likely not use it by default. For example, to get colored output when invoking `ls` you can use: ~~~markdown ```bash +exec ls /tmp --color=always ``` ~~~ The parameter or way to enable this will depend on the tool being invoked. ## Hiding code lines When you mark a code snippet as executable via the `+exec` flag, you may not be interested in showing _all the lines_ to your audience, as some of them may not be necessary to convey your point. For example, you may want to hide imports, non-essential functions, initialization of certain variables, etc. For this purpose, _presenterm_ supports a prefix under certain programming languages that let you indicate a line should be executed when running the code but should not be displayed in the presentation. For example, in the following code snippet only the print statement will be displayed but the entire snippet will be ran: ~~~markdown ```rust # fn main() { println!("Hello world!"); # } ``` ~~~ Rather than blindly relying on a prefix that may have a meaning in a language, prefixes are chosen on a per language basis. The languages that are supported and their prefix is: * rust: `# `. * python/bash/fish/shell/zsh/kotlin/java/javascript/typescript/c/c++/go: `/// `. This means that any line in a rust code snippet that starts with `# ` will be hidden, whereas all lines in, say, a golang code snippet that starts with a `/// ` will be hidden. ## Pre-rendering Some languages support pre-rendering. This means the code block is transformed into something else when the presentation is loaded. The languages that currently support this are _mermaid_, _LaTeX_, and _typst_ where the contents of the code block is transformed into an image, allowing you to define formulas as text in your presentation. This can be done by using the `+render` attribute on a code block. See the [LaTeX and typst](latex.md), [mermaid](mermaid.md), and [d2](d2.md) docs for more information. presenterm-0.15.1/docs/src/features/code/highlighting.md000064400000000000000000000143161046102023000213370ustar 00000000000000# Code highlighting Code highlighting is supported for the following languages: | Language | Execution support | |------------|-------------------| | ada | | | asp | | | awk | | | bash | ✓ | | batchfile | | | C | ✓ | | cmake | | | crontab | | | C# | ✓ | | clojure | | | C++ | ✓ | | CSS | | | D | | | diff | | | docker | | | dotenv | | | elixir | | | elm | | | erlang | | | fish | ✓ | | F# | ✓ | | go | ✓ | | haskell | ✓ | | HTML | | | java | ✓ | | javascript | ✓ | | json | | | julia | ✓ | | kotlin | ✓ | | latex | | | lua | ✓ | | makefile | | | markdown | | | nix | | | ocaml | | | perl | ✓ | | php | ✓ | | protobuf | | | puppet | | | python | ✓ | | R | ✓ | | ruby | ✓ | | rust | ✓ | | scala | | | shell | ✓ | | sql | | | swift | | | svelte | | | tcl | | | toml | | | terraform | | | typescript | | | xml | | | yaml | | | vue | | | zig | | | zsh | ✓ | Other languages that are supported are: * nushell, for which highlighting isn't supported but execution is. If there's a language that is not in this list and you would like it to be supported, please [create an issue](https://github.com/mfontanini/presenterm/issues/new). If you'd also like code execution support, provide details on how to compile (if necessary) and run snippets for that language. You can also configure how to run code snippet for a language locally in your [config file](../../configuration/settings.md#custom-snippet-executors). ## Enabling line numbers If you would like line numbers to be shown on the left of a code block use the `+line_numbers` switch after specifying the language in a code block: ~~~markdown ```rust +line_numbers fn hello_world() { println!("Hello world"); } ``` ~~~ ## Selective highlighting By default, the entire code block will be syntax-highlighted. If instead you only wanted a subset of it to be highlighted, you can use braces and a list of either individual lines, or line ranges that you'd want to highlight. ~~~markdown ```rust {1,3,5-7} fn potato() -> u32 { // 1: highlighted // 2: not highlighted println!("Hello world"); // 3: highlighted let mut q = 42; // 4: not highlighted q = q * 1337; // 5: highlighted q // 6: highlighted } // 7: highlighted ``` ~~~ ## Dynamic highlighting Similar to the syntax used for selective highlighting, dynamic highlighting will change which lines of the code in a code block are highlighted every time you move to the next/previous slide. This is achieved by using the separator `|` to indicate what sections of the code will be highlighted at a given time. You can also use `all` to highlight all lines for a particular frame. ~~~markdown ```rust {1,3|5-7} fn potato() -> u32 { println!("Hello world"); let mut q = 42; q = q * 1337; q } ``` ~~~ In this example, lines 1 and 3 will be highlighted initially. Then once you press a key to move to the next slide, lines 1 and 3 will no longer be highlighted and instead lines 5 through 7 will. This allows you to create more dynamic presentations where you can display sections of the code to explain something specific about each of them. See this real example of how this looks like. [![asciicast](https://asciinema.org/a/iCf4f6how1Ux3H8GNzksFUczI.svg)](https://asciinema.org/a/iCf4f6how1Ux3H8GNzksFUczI) ## Including external code snippets The `file` snippet type can be used to specify an external code snippet that will be included and highlighted as usual. ~~~markdown ```file +exec +line_numbers path: snippet.rs language: rust ``` ~~~ If you'd like to include only a subset of the file, you can use the optional fields `start_line` and `end_line`: ~~~markdown ```file +exec +line_numbers path: snippet.rs language: rust # Only show lines 5-10 start_line: 5 end_line: 10 ``` ~~~ ## Showing a snippet without a background Using the `+no_background` flag will cause the snippet to have no background. This is useful when combining it with the `+exec_replace` flag described further down. ## Adding highlighting syntaxes for new languages _presenterm_ uses the syntaxes supported by [bat](https://github.com/sharkdp/bat) to highlight code snippets, so any languages supported by _bat_ natively can be added to _presenterm_ easily. Please create a ticket or use [this](https://github.com/mfontanini/presenterm/pull/385) as a reference to submit a pull request to make a syntax officially supported by _presenterm_ as well. If a language isn't natively supported by _bat_ but you'd like to use it, you can follow [this guide in the bat docs](https://github.com/sharkdp/bat#adding-new-syntaxes--language-definitions) and invoke _bat_ directly in a presentation: ~~~markdown ```bash +exec_replace bat --color always script.py ``` ~~~ > [!note] > Check the [code execution docs](execution.md#executing-and-replacing) for more details on how to allow the tool to run > `exec_replace` blocks. presenterm-0.15.1/docs/src/features/code/latex.md000064400000000000000000000057501046102023000200110ustar 00000000000000# LaTeX and typst `latex` and `typst` code blocks can be marked with the `+render` attribute (see [highlighting](highlighting.md)) to have them rendered into images when the presentation is loaded. This allows you to define formulas in text rather than having to define them somewhere else, transform them into an image, and them embed it. For example, the following presentation: ~~~ # Formulas ```latex +render \[ \sum_{n=1}^{\infty} 2^{-n} = 1 \] ``` ~~~ Would be rendered like this: ![](../../assets/formula.png) ## Dependencies ### typst The engine used to render both of these languages is [typst](https://github.com/typst/typst). _typst_ is easy to install, lightweight, and boilerplate free as compared to _LaTeX_. ### pandoc For _LaTeX_ code rendering both _typst_ and [pandoc](https://github.com/jgm/pandoc) are required. How this works is the _LaTeX_ code you write gets transformed into _typst_ code via _pandoc_ and then rendered by using _typst_. This lets us: * Have the same look/feel on generated formulas for both languages. * Avoid having to write lots of boilerplate _LaTeX_ to make rendering for that language work. * Have the same logic to render formulas for both languages, except with a small preparation step for _LaTeX_. ## Controlling PPI _presenterm_ lets you define how many Pixels Per Inch (PPI) you want in the generated images. This is important because as opposed to images that you manually include in your presentation, where you control the exact dimensions, the images generated on the fly will have a fixed size. Configuring the PPI used during the conversion can let you adjust this: the higher the PPI, the larger the generated images will be. Because as opposed to most configurations this is a very environment-specific config, the PPI parameter is not part of the theme definition but is instead has to be set in _presenterm_'s [config file](../../configuration/introduction.md): ```yaml typst: ppi: 400 ``` The default is 300 so adjust it and see what works for you. ## Image paths If you're including an image inside a _typst_ snippet, you must: * Use absolute paths, e.g. `#image("/image1.png")`. * Place the image in the same or a sub path of the path where the presentation is. That is, if your presentation file is at `/tmp/foo/presentation.md`, you can place images in `/tmp/foo`, and `/tmp/foo/bar` but not under `/tmp/bar`. This is because of the absolute path rule above: the path will be considered to be relative to the presentation file's directory. ## Controlling the image size You can also set the generated image's size on a per code snippet basis by using the `+width` modifier which specifies the width of the image as a percentage of the terminal size. ~~~markdown ```typst +render +width:50% $f(x) = x + 1$ ``` ~~~ ## Customizations The colors and margin of the generated images can be defined in your theme: ```yaml typst: colors: background: ff0000 foreground: 00ff00 # In points horizontal_margin: 2 vertical_margin: 2 ``` presenterm-0.15.1/docs/src/features/code/mermaid.md000064400000000000000000000040421046102023000203030ustar 00000000000000## Mermaid [mermaid](https://mermaid.js.org/) snippets can be converted into images automatically in any code snippet tagged with the `mermaid` language and a `+render` tag: ~~~markdown ```mermaid +render sequenceDiagram Mark --> Bob: Hello! Bob --> Mark: Oh, hi mark! ``` ~~~ **This requires having [mermaid-cli](https://github.com/mermaid-js/mermaid-cli) installed**. Note that because the mermaid CLI will spin up a browser under the hood, this may not work in all environments and can also be a bit slow (e.g. ~2 seconds to generate every image). Mermaid graphs are rendered asynchronously by a number of threads that can be configured in the [configuration file](../../configuration/settings.md#snippet-rendering-threads). This configuration value currently defaults to 2. The size of the rendered image can be configured by changing: * The `mermaid.scale` [configuration parameter](../../configuration/settings.md#mermaid-scaling). * Using the `+width:%` attribute in the code snippet. For example, this diagram will take up 50% of the width of the window and will preserve its aspect ratio: ~~~markdown ```mermaid +render +width:50% sequenceDiagram Mark --> Bob: Hello! Bob --> Mark: Oh, hi mark! ``` ~~~ It is recommended to change the `mermaid.scale` parameter until images look big enough and then adjust on an image by image case if necessary using the `+width` attribute. Otherwise, using a small scale and then scaling via `+width` may cause the image to become blurry. ## Theme The theme of the rendered mermaid diagrams can be changed through the following [theme](../themes/introduction.md) parameters: * `mermaid.background` the background color passed to the CLI (e.g., `transparent`, `red`, `#F0F0F0`). * `mermaid.theme` the [mermaid theme](https://mermaid.js.org/config/theming.html#available-themes) to use. ## Always render diagrams If you don't want to use `+render` every time, you can configure which languages get this automatically via the [config file](../../configuration/settings.md#auto_render_languages). presenterm-0.15.1/docs/src/features/commands.md000064400000000000000000000072301046102023000175560ustar 00000000000000# Comment commands _presenterm_ uses "comment commands" in the form of HTML comments to let the user specify certain behaviors that can't be specified by vanilla markdown. ## Pauses Pauses allow the sections of the content in your slide to only show up when you advance in your presentation. That is, only after you press, say, the right arrow will a section of the slide show up. This can be done by the `pause` comment command: ```html ``` ## Font size The font size can be changed by using the `font_size` command: ```html ``` This causes the remainder of the slide to use the font size specified. The font size can range from 1 to 7, 1 being the default. > [!note] > This is currently only supported in the [_kitty_](https://sw.kovidgoyal.net/kitty/) terminal and only as of version > 0.40.0. See the notes on font sizes on the [introduction page](introduction.md#font-sizes) for more information on > this. ## Jumping to the vertical center The command `jump_to_middle` lets you jump to the middle of the page vertically. This is useful in combination with slide titles to create separator slides: ```markdown blablabla Farming potatoes === ``` This will create a slide with the text "Farming potatoes" in the center, rendered using the slide title style. ## Explicit new lines The `newline`/`new_line` and `newlines`/`new_lines` commands allow you to explicitly create new lines. Because markdown ignores multiple line breaks in a row, this is useful to create some spacing where necessary: ```markdown hi mom bye ``` ## Incremental lists Using `` in between each bullet point a list is a bit tedious so instead you can use the `incremental_lists` command to tell _presenterm_ that **until the end of the current slide** you want each individual bullet point to appear only after you move to the next slide: ```markdown * this * appears * one after * the other * this appears * all at once ``` ## Number of lines in between list items The `list_item_newlines` option lets you configure the number of new lines in between list items in the remainder of a slide. This can be helpful to "unpack" a list that only has a few entries and you want it to take up more space in a slide. This can also be configured for all lists via the [`options.list_item_newlines` option](../configuration/options.md#list_item_newlines). ```markdown * this * is * more * spaced ``` ## Including external markdown files By using the `include` command you can include the contents of an external markdown file as if it was part of the original presentation file: ```markdown ``` Any files referenced by an included file will have their paths relative to that path. e.g. if you include `foo/bar.md` and that file contains an image `tar.png`, that image will be looked up in `foo/tar.png`. ## No footer If you don't want the footer to show up in some particular slide for some reason, you can use the `no_footer` command: ```html ``` ## Skip slide If you don't want a specific slide to be included in the presentation use the `skip_slide` command: ```html ``` ## Text alignment The text alignment for the remainder of the slide can be configured via the `alignment` command, which can use values: `left`, `center`, and `right`: ```markdown left alignment, the default centered right aligned ``` presenterm-0.15.1/docs/src/features/exports.md000064400000000000000000000036201046102023000174600ustar 00000000000000# Exporting presentations Presentations can be exported to PDF and HTML, to allow easily sharing the slide deck at the end of a presentation. ## PDF Presentations can be converted into PDF by using [weasyprint](https://pypi.org/project/weasyprint/). Follow their [installation instructions](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html) since it may require you to install extra dependencies for the tool to work. > [!note] > If you were using _presenterm-export_ before it was deprecated, that tool already required _weasyprint_ so it is > already installed in whatever virtual env you were using and there's nothing to be done. After you've installed _weasyprint_, run _presenterm_ with the `--export-pdf` parameter to generate the output PDF: ```bash presenterm --export-pdf examples/demo.md ``` The output PDF will be placed in `examples/demo.pdf`. Alternatively you can use the `--output` flag to specify where you want the output file to be written to. > [!note] > If you're using a separate virtual env to install _weasyprint_ just make sure you activate it before running > _presenterm_ with the `--export-pdf` parameter. > [!note] > If you have [uv](https://github.com/astral-sh/uv) installed you can simply run: > ```bash > uv run --with weasyprint presenterm --export-pdf examples/demo.md > ``` ## HTML Similarly, using the `--export-html` parameter allows generating a single self contained HTML file that contains all images and styles embedded in it. As opposed to PDF exports, this requires no extra dependencies: ```bash presenterm --export-html examples/demo.md ``` The output file will be placed in `examples/demo.html` but this behavior can be configured via the `--output` flag just like for PDF exports. # Configurable behavior See the [settings page](../configuration/settings.md#presentation-exports) to see all the configurable behavior around presentation exports. presenterm-0.15.1/docs/src/features/images.md000064400000000000000000000052301046102023000172200ustar 00000000000000# Images Images are supported and will render in your terminal as long as it supports either the [iterm2 image protocol](https://iterm2.com/documentation-images.html), the [kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/), or [sixel](https://saitoha.github.io/libsixel/). Some of the terminals where at least one of these is supported are: * [kitty](https://sw.kovidgoyal.net/kitty/) * [iterm2](https://iterm2.com/) * [WezTerm](https://wezfurlong.org/wezterm/index.html) * [foot](https://codeberg.org/dnkl/foot) Sixel support is experimental so it needs to be explicitly enabled via the `sixel` configuration flag: ```bash cargo build --release --features sixel ``` > [!note] > This feature flag is only needed if your terminal emulator _only_ supports sixel. Many terminals support the kitty or > iterm2 protocols so using this flag is often not required to get images to render successfully. --- Things you should know when using image tags in your presentation's markdown are: * Image paths are relative to your presentation path. That is a tag like `![](food/potato.png)` will be looked up at `$PRESENTATION_DIRECTORY/food/potato.png`. * Images will be rendered by default in their original size. That is, if your terminal is 300x200px and your image is 200x100px, it will take up 66% of your horizontal space and 50% of your vertical space. * The exception to the point above is if the image does not fit in your terminal, it will be resized accordingly while preserving the aspect ratio. * If your terminal does not support any of the graphics protocol above, images will be rendered using ascii blocks. It ain't great but it's something! * Remote images are not supported [by design](https://github.com/mfontanini/presenterm/issues/213#issuecomment-1950342423). ## tmux If you're using tmux, you will need to enable the [allow-passthrough option](https://github.com/tmux/tmux/wiki/FAQ#what-is-the-passthrough-escape-sequence-and-how-do-i-use-it) for images to work correctly. ## Image size The size of each image can be set by using the `image:width` or `image:w` attributes in the image tag. For example, the following will cause the image to take up 50% of the terminal width: ```markdown ![image:width:50%](image.png) ``` The image will always be scaled to preserve its aspect ratio and it will not be allowed to overflow vertically nor horizontally. ## Protocol detection By default the image protocol to be used will be automatically detected. In cases where this detection fails, you can set it manually via the `--image-protocol` parameter or by setting it in the [config file](../configuration/settings.md#preferred-image-protocol). presenterm-0.15.1/docs/src/features/introduction.md000064400000000000000000000151501046102023000204760ustar 00000000000000# Introduction This guide teaches you how to use _presenterm_. At this point you should have already installed _presenterm_, otherwise visit the [installation](../install.md) guide to get started. ## Quick start Download the demo presentation and run it using: ```bash git clone https://github.com/mfontanini/presenterm.git cd presenterm presenterm examples/demo.md ``` # Presentations A presentation in _presenterm_ is a single markdown file. Every slide in the presentation file is delimited by a line that contains a single HTML comment: ```html ``` Presentations can contain most commonly used markdown elements such as ordered and unordered lists, headings, formatted text (**bold**, _italics_, ~strikethrough~, `inline code`, etc), code blocks, block quotes, tables, etc. ## Introduction slide By setting a front matter at the beginning of your presentation you can configure the title, sub title, author and other metadata about your presentation. Doing so will cause _presenterm_ to create an introduction slide: ```yaml --- title: "My _first_ **presentation**" sub_title: (in presenterm!) author: Myself --- ``` All of these attributes are optional and should be avoided if an introduction slide is not needed. Note that the `title` key can contain arbitrary markdown so you can use bold, italics, `` tags, etc. ### Multiple authors If you're creating a presentation in which there's multiple authors, you can use the `authors` key instead of `author` and list them all this way: ```yaml --- title: Our first presentation authors: - Me - You --- ``` ## Slide titles Any [setext header](https://spec.commonmark.org/0.30/#setext-headings) will be considered to be a slide title and will be rendered in a more slide-title-looking way. By default this means it will be centered, some vertical padding will be added and the text color will be different. ~~~markdown Hello === ~~~ > [!note] > See the [themes](themes/introduction.md) section on how to customize the looks of slide titles and any other element > in a presentation. ## Ending slides While other applications use a thematic break (`---`) to mark the end of a slide, _presenterm_ uses a special `end_slide` HTML comment: ```html ``` This makes the end of a slide more explicit and easy to spot while you're editing your presentation. See the [configuration](../configuration/options.md#implicit_slide_ends) if you want to customize this behavior. If you really would prefer to use thematic breaks (`---`) to delimit slides, you can do that by enabling the [`end_slide_shorthand`](../configuration/options.md#end_slide_shorthand) options. ## Colored text `span` HTML tags can be used to provide foreground and/or background colors to text. There's currently two ways to specify colors: * Via the `style` attribute, in which only the CSS attributes `color` and `background-color` can be used to set the foreground and background colors respectively. Colors used in both CSS attributes can refer to [theme palette colors](themes/definition.md#color-palette) by using the `palette:` or `p:colored text! ``` Alternatively, can you can define a class that contains a foreground/background color combination in your theme's palette and use it: ```markdown colored text! ``` > [!note] > Keep in mind **only `span` tags are supported**. ## Font sizes The [_kitty_](https://sw.kovidgoyal.net/kitty/) terminal added in version 0.40.0 support for a new protocol that allows TUIs to specify the font size to be used when printing text. _presenterm_ is one of the first applications supports this protocol in various places: * Themes can specify it in the presentation title in the introduction slide, in slide titles, and in headers by using the `font_size` property. All built in themes currently set font size to 2 (1 is the default) for these elements. * Explicitly by using the `font_size` comment command: ```markdown # Normal text # Larger text ``` Terminal support for this feature is verified when _presenterm_ starts and any attempt to change the font size, be it via the theme or via the comment command, will be ignored if it's not supported. # Key bindings Navigation within a presentation should be intuitive: jumping to the next/previous slide can be done by using the arrow keys, _hjkl_, and page up/down keys. Besides this: * Jumping to the first slide: `gg`. * Jumping to the last slide: `G`. * Jumping to a specific slide: `G`. * Exit the presentation: `c`. You can check all the configured keybindings by pressing `?` while running _presenterm_. ## Configuring key bindings If you don't like the default key bindings, you can override them in the [configuration file](../configuration/settings.md#key-bindings). # Modals _presenterm_ currently has 2 modals that can provide some information while running the application. Modals can be toggled using some key combination and can be hidden using the escape key by default, but these can be configured via the [configuration file key bindings](../configuration/settings.md#key-bindings). ## Slide index modal This modal can be toggled by default using `control+p` and lets you see an index that contains a row for every slide in the presentation, including its title and slide index. This allows you to find a slide you're trying to jump to quicklier rather than scanning through each of them. [![asciicast](https://asciinema.org/a/1VgRxVIEyLrMmq6OZ3oKx4PGi.svg)](https://asciinema.org/a/1VgRxVIEyLrMmq6OZ3oKx4PGi) ## Key bindings modal The key bindings modal displays the key bindings for each of the supported actions and can be opened by pressing `?`. # Hot reload Unless you run in presentation mode by passing in the `--present` parameter, _presenterm_ will automatically reload your presentation file every time you save it. _presenterm_ will also automatically detect which specific slide was modified and jump to it so you don't have to be jumping back and forth between the source markdown and the presentation to see how the changes look like. [![asciicast](https://asciinema.org/a/bu9ITs8KhaQK5OdDWnPwUYKu3.svg)](https://asciinema.org/a/bu9ITs8KhaQK5OdDWnPwUYKu3) presenterm-0.15.1/docs/src/features/layout.md000064400000000000000000000064131046102023000172740ustar 00000000000000# Layouts _presenterm_ currently supports a column layout that lets you split parts of your slides into column. This allows you to put text on one side, and code/images on the other, or really organize markdown into columns in any way you want. This is done by using commands, just like `pause` and `end_slide`, in the form of HTML comments. This section describes how to use those. ## Wait, why not HTML? While markdown _can_ contain HTML tags (beyond comments!) and we _could_ represent this using divs with alignment, I don't really want to: 1. Deal with HTML and all the implications this would have. e.g. nesting many divs together and all the chaos that would bring to the rendering code. 2. Require people to write HTML when we have such a narrow use-case for it here: we only want column layouts. Because of this, _presenterm_ doesn't let you use HTML and instead has a custom way of specifying column layouts. ## Column layout The way to specify column layouts is by first creating a layout, and then telling _presenterm_ you want to enter each of the column in it as you write your presentation. ### Defining layouts Defining a layout is done via the `column_layout` command, in the form of an HTML comment: ```html ``` This defines a layout with 2 columns where: * The total number of "size units" is `3 + 2 = 5`. You can think of this as the terminal screen being split into 5 pieces vertically. * The first column takes 3 out of those 5 pieces/units, or in other words 60% of the terminal screen. * The second column takes 2 out of those 5 pieces/units, or in other words 40% of the terminal screen. You can use any number of columns and with as many units you want on each of them. This lets you decide how to structure the presentation in a fairly straightforward way. ### Using columns Once a layout is defined, you just need to specify that you want to enter a column before writing any text to it by using the `column` command: ```html ``` Now all the markdown you write will be placed on the first column until you either: * Reset the layout by using the `reset_layout` command. * The slide ends. * You jump into another column by using the `column` command again. ## Example The following example puts all of this together by defining 2 columns, one with some code and bullet points, another one with an image, and some extra text at the bottom that's not tied to any columns. ~~~markdown Layout example ============== This is some code I like: ```rust fn potato() -> u32 { 42 } ``` Things I like about it: 1. Potato 2. Rust 3. The image on the right ![](examples/doge.png) _Picture by Alexis Bailey / CC BY-NC 4.0_ Because we just reset the layout, this text is now below both of the columns. ~~~ This would render the following way: ![](../assets/layouts.png) ## Other uses Besides organizing your slides into columns, you can use column layouts to center a piece of your slide. For example, if you want a certain portion of your slide to be centered, you could define a column layout like `[1, 3, 1]` and then only write content into the middle column. This would make your content take up the center 60% of the screen. presenterm-0.15.1/docs/src/features/slide-transitions.md000064400000000000000000000015571046102023000214360ustar 00000000000000# Slide transitions Slide transitions allow animating your presentation every time you move from a slide to the next/previous one. See the [configuration page](../configuration/settings.md#slide-transitions) to learn how to configure transitions. The following animations are supported: ## `fade` Fade the current slide into the next one. [![asciicast](https://asciinema.org/a/RvxLw0FHOopjdF4ixWbCkWuSw.svg)](https://asciinema.org/a/RvxLw0FHOopjdF4ixWbCkWuSw) ## `slide_horizontal` Slide horizontally to the next/previous slide. [![asciicast](https://asciinema.org/a/T43ttxPWZ8TsM2auTqNZSWrmZ.svg)](https://asciinema.org/a/T43ttxPWZ8TsM2auTqNZSWrmZ) ## `collapse_horizontal` Collapse the current slide into the center of the screen horizontally. [![asciicast](https://asciinema.org/a/VB8i3kGMvbkbiYYPpaZJUl2dW.svg)](https://asciinema.org/a/VB8i3kGMvbkbiYYPpaZJUl2dW) presenterm-0.15.1/docs/src/features/speaker-notes.md000064400000000000000000000045171046102023000205420ustar 00000000000000## Speaker notes Starting on version 0.10.0, _presenterm_ allows presentations to define speaker notes. The way this works is: * You start an instance of _presenterm_ using the `--publish-speaker-notes` parameter. This will be the main instance in which you will present like you usually do. * Another instance should be started using the `--listen-speaker-notes` parameter. This instance will only display speaker notes in the presentation and will automatically change slides whenever the main instance does so. For example: ```bash # Start the main instance presenterm demo.md --publish-speaker-notes # In another shell: start the speaker notes instance presenterm demo.md --listen-speaker-notes ``` [![asciicast](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J.svg)](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J) See the [speaker notes example](https://github.com/mfontanini/presenterm/blob/master/examples/speaker-notes.md) for more information. ### Defining speaker notes In order to define speaker notes you can use the `speaker_notes` comment command: ```markdown Normal text More text ``` When running this two instance setup, the main one will show "normal text" and "more text", whereas the second one will only show "this is a speaker note" on that slide. ### Multiline speaker notes You can use multiline speaker notes by using the appropriate YAML syntax: ```yaml ``` ### Multiple instances On Linux and Windows, you can run multiple instances in publish mode and multiple instances in listen mode at the same time. Each instance will only listen to events for the presentation it was started on. On Mac this is not supported and only a single listener can be used at a time. ### Enabling publishing by default You can use the `speaker_notes.always_publish` key in your config file to always publish speaker notes. This means you will only ever need to use `--listen-speaker-notes` and you will never need to use `--publish-speaker-notes`: ```yaml speaker_notes: always_publish: true ``` ### Internals This uses UDP sockets on localhost to communicate between instances. The main instance sends events every time a slide is shown and the listener instances listen to them and displays the speaker notes for that specific slide. presenterm-0.15.1/docs/src/features/themes/definition.md000064400000000000000000000254461046102023000214030ustar 00000000000000# Theme definition This section goes through the structure of the theme files. Have a look at some of the [existing themes](https://github.com/mfontanini/presenterm/tree/master/themes) to have an idea of how to structure themes. ## Root elements The root attributes on the theme yaml files specify either: * A specific type of element in the input markdown or rendered presentation. That is, the slide title, headings, footer, etc. * A default to be applied as a fallback if no specific style is specified for a particular element. ## Alignment _presenterm_ uses the notion of alignment, just like you would have in a GUI editor, to align text to the left, center, or right. You probably want most elements to be aligned left, _some_ to be aligned on the center, and probably none to the right (but hey, you're free to do so!). The following elements support alignment: * Code blocks. * Slide titles. * The title, subtitle, and author elements in the intro slide. * Tables. ### Left/right alignment Left and right alignments take a margin property which specifies the number of columns to keep between the text and the left/right terminal screen borders. The margin can be specified in two ways: #### Fixed A specific number of characters regardless of the terminal size. ```yaml alignment: left margin: fixed: 5 ``` #### Percent A percentage over the total number of columns in the terminal. ```yaml alignment: left margin: percent: 8 ``` Percent alignment tends to look a bit nicer as it won't change the presentation's look as much when the terminal size changes. ### Center alignment Center alignment has 2 properties: * `minimum_size` which specifies the minimum size you want that element to have. This is normally useful for code blocks as they have a predefined background which you likely want to extend slightly beyond the end of the code on the right. * `minimum_margin` which specifies the minimum margin you want, using the same structure as `margin` for left/right alignment. This doesn't play very well with `minimum_size` but in isolation it specifies the minimum number of columns you want to the left and right of your text. ## Colors Every element can have its own background/foreground color using hex notation: ```yaml default: colors: foreground: "ff0000" background: "00ff00" ``` ## Default style The default style specifies: * The margin to be applied to all slides. * The colors to be used for all text. ```yaml default: margin: percent: 8 colors: foreground: "e6e6e6" background: "040312" ``` ## Intro slide The introductory slide will be rendered if you specify a title, subtitle, or author in the presentation's front matter. This lets you have a less markdown-looking introductory slide that stands out so that it doesn't end up looking too monotonous: ```yaml --- title: Presenting from my terminal sub_title: Like it's 1990 author: John Doe --- ``` The theme can specify: * For the title and subtitle, the alignment and colors. * For the author, the alignment, colors, and positioning (`page_bottom` and `below_title`). The first one will push it to the bottom of the screen while the second one will put it right below the title (or subtitle if there is one) For example: ```yaml intro_slide: title: alignment: left margin: percent: 8 author: colors: foreground: black positioning: below_title ``` ## Footer The footer currently comes in 3 flavors: ### Template footers A template footer lets you put text on the left, center and/or right of the screen. The template strings can reference `{current_slide}` and `{total_slides}` which will be replaced with the current and total number of slides. Besides those special variables, any of the attributes defined in the front matter can also be used: * `title`. * `sub_title`. * `event`. * `location`. * `date`. * `author`. Strings used in template footers can contain arbitrary markdown, including `span` tags that let you use colored text. A `height` attribute allows specifying how tall, in terminal rows, the footer is. The text in the footer will always be placed at the center of the footer area. The default footer height is 2. ```yaml footer: style: template left: "My **name** is {author}" center: "_@myhandle_" right: "{current_slide} / {total_slides}" height: 3 ``` Do note that: * Only existing attributes in the front matter can be referenced. That is, if you use `{date}` but the `date` isn't set, an error will be shown. * Similarly, referencing unsupported variables (e.g. `{potato}`) will cause an error to be displayed. If you'd like the `{}` characters to be used in contexts where you don't want to reference a variable, you will need to escape them by using another brace. e.g. `{{potato}} farms` will be displayed as `{potato} farms`. #### Footer images Besides text, images can also be used in the left/center/right positions. This can be done by specifying an `image` key under each of those attributes: ```yaml footer: style: template left: image: potato.png center: image: banana.png right: image: apple.png # The height of the footer to adjust image sizes height: 5 ``` Images will be looked up: * First, relative to the presentation file just like any other image. * If the image is not found, it will be looked up relative to the themes directory. e.g. `~/.config/presenterm/themes`. This allows you to define a custom theme in your themes directory that points to a local image within that same location. Images will preserve their aspect ratio and expand vertically to take up as many terminal rows as `footer.height` specifies. This parameter should be adjusted accordingly if taller-than-wider images are used in a footer. See the [footer example](https://github.com/mfontanini/presenterm/blob/master/examples/footer.md) as a showcase of how a footer can contain images and colored text. ![](../../assets/example-footer.png) ### Progress bar footers A progress bar that will advance as you move in your presentation. This will by default use a block-looking character to draw the progress bar but you can customize it: ```yaml footer: style: progress_bar # Optional! character: 🚀 ``` ### None No footer at all! ```yaml footer: style: empty ``` ## Slide title Slide titles, as specified by using a setext header, has the following properties: * `padding_top` which specifies the number of rows you want as padding before the text. * `padding_bottom` which specifies the number of rows you want as padding after the text. * `separator` which specifies whether you want a horizontal ruler after the text (and the `padding_bottom`): ```yaml slide_title: padding_bottom: 1 padding_top: 1 separator: true ``` ## Headings Every header type (h1 through h6) can have its own style composed of: * The prefix you want to use. * The colors, just like any other element: ```yaml headings: h1: prefix: "██" colors: foreground: "rgb_(48,133,195)" h2: prefix: "▓▓▓" colors: foreground: "rgb_(168,223,142)" ``` ## Code blocks The syntax highlighting for code blocks is done via the [syntect](https://github.com/trishume/syntect) crate. The list of all the supported built-in _syntect_ themes is the following: * base16-ocean.dark * base16-eighties.dark * base16-mocha.dark * base16-ocean.light * InspiredGitHub * Solarized (dark) * Solarized (light) Besides those and thanks to the work done on the awesome [bat tool](https://github.com/sharkdp/bat), _presenterm_ has access to not only the built-in _syntect_'s built-in themes but also the ones in _bat_. Run `bat --list-themes` to see a list of all of them. Code blocks can also have an optional vertical and horizontal padding so your code is not too close to its bounding rectangle: ```yaml code: theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 ``` #### Custom highlighting themes Besides the built-in highlighting themes, you can drop any `.tmTheme` theme in the `themes/highlighting` directory under your [configuration directory](../../configuration/introduction.md) (e.g. `~/.config/presenterm/themes/highlighting` in Linux) and they will be loaded automatically when _presenterm_ starts. ## Block quotes For block quotes you can specify a string to use as a prefix in every line of quoted text: ```yaml block_quote: prefix: "▍ " ``` ## Mermaid The [mermaid](https://mermaid.js.org/) graphs can be customized using the following parameters: * `mermaid.background` the background color passed to the CLI (e.g., `transparent`, `red`, `#F0F0F0`). * `mermaid.theme` the [mermaid theme](https://mermaid.js.org/config/theming.html#available-themes) to use. ```yaml mermaid: background: transparent theme: dark ``` ## Alerts GitHub style markdown alerts can be styled by setting the `alert` key: ```yaml alert: # the base colors used in all text in an alert base_colors: foreground: red background: black # the prefix used in every line in the alert prefix: "▍ " # the style for each alert type styles: note: color: blue title: Note icon: I tip: color: green title: Tip icon: T important: color: cyan title: Important icon: I warning: color: orange title: Warning icon: W caution: color: red title: Caution icon: C ``` ## Extending themes Custom themes can extend other custom or built in themes. This means it will inherit all the properties of the theme being extended by default. For example: ```yaml extends: dark default: colors: background: "000000" ``` This theme extends the built in _dark_ theme and overrides the background color. This is useful if you find yourself _almost_ liking a built in theme but there's only some properties you don't like. ## Color palette Every theme can define a color palette, which includes a list of pre-defined colors and a list of background/foreground pairs called "classes". Colors and classes can be used when styling text via `` HTML tags, whereas colors can also be used inside themes to avoid duplicating the same colors all over the theme definition. A palette can de defined as follows: ```yaml palette: colors: red: "f78ca2" purple: "986ee2" classes: foo: foreground: "ff0000" background: "00ff00" ``` Any palette color can be referenced using either `palette:` or `p:`. This means now any part of the theme can use `p:red` and `p:purple` where a color is required. Similarly, these colors can be used in `span` tags like: ```html this is red this is foo-colored ``` These colors can used anywhere in your presentation as well as in other places such as in [template footers](#template-footers) and [introduction slides](../introduction.md#introduction-slide). presenterm-0.15.1/docs/src/features/themes/introduction.md000064400000000000000000000070601046102023000217640ustar 00000000000000# Themes _presenterm_ tries to be as configurable as possible, allowing users to create presentations that look exactly how they want them to look like. The tool ships with a set of [built-in themes](https://github.com/mfontanini/presenterm/tree/master/themes) but users can be created by users in their local setup and imported in their presentations. ## Setting themes There's various ways of setting the theme you want in your presentation: ### CLI Passing in the `--theme` parameter when running _presenterm_ to select one of the built-in themes. ### Within the presentation The presentation's markdown file can contain a front matter that specifies the theme to use. This comes in 3 flavors: #### By name Using a built-in theme name makes your presentation use that one regardless of what the default or what the `--theme` option specifies: ```yaml --- theme: name: dark --- ``` #### By path You can define a theme file in yaml format somewhere in your filesystem and reference it within the presentation: ```yaml --- theme: path: /home/me/Documents/epic-theme.yaml --- ``` #### Overrides You can partially/completely override the theme in use from within the presentation: ```yaml --- theme: override: default: colors: foreground: "beeeff" --- ``` This lets you: 1. Create a unique style for your presentation without having to go through the process of taking an existing theme, copying somewhere, and changing it when you only expect to use it for that one presentation. 2. Iterate quickly on styles given overrides are reloaded whenever you save your presentation file. # Built-in themes A few built-in themes are bundled with the application binary, meaning you don't need to have any external files available to use them. These are packed as part of the [build process](https://github.com/mfontanini/presenterm/blob/master/build.rs) as a binary blob and are decoded on demand only when used. Currently, the following themes are supported: * A set of themes based on the [catppuccin](https://github.com/catppuccin/catppuccin) color palette: * `catppuccin-latte` * `catppuccin-frappe` * `catppuccin-macchiato` * `catppuccin-mocha` * `dark`: A dark theme. * `gruvbox-dark`: A theme inspired by the colors used in [gruvbox](https://github.com/morhetz/gruvbox). * `light`: A light theme. * `terminal-dark`: A theme that uses your terminals color and looks best if your terminal uses a dark color scheme. This means if your terminal background is e.g. transparent, or uses an image, the presentation will inherit that. * `terminal-light`: The same as `terminal-dark` but works best if your terminal uses a light color scheme. * `tokyonight-storm`: A theme inspired by the colors used in [toyonight](https://github.com/folke/tokyonight.nvim). ## Trying out built-in themes All built-in themes can be tested by using the `--list-themes` parameter: ```bash presenterm --list-themes ``` This will run a presentation where the same content is rendered using a different theme in each slide: [![asciicast](https://asciinema.org/a/zeV1QloyrLkfBp6rNltvX7Lle.svg)](https://asciinema.org/a/zeV1QloyrLkfBp6rNltvX7Lle) # Loading custom themes On startup, _presenterm_ will look into the `themes` directory under the [configuration directory](../../configuration/introduction.md) (e.g. `~/.config/presenterm/themes` in Linux) and will load any `.yaml` file as a theme and make it available as if it was a built-in theme. This means you can use it as an argument to the `--theme` parameter, use it in the `theme.name` property in a presentation's front matter, etc. presenterm-0.15.1/docs/src/install.md000064400000000000000000000057771046102023000156230ustar 00000000000000# Installing _presenterm_ _presenterm_ works on Linux, macOS, and Windows and can be installed in different ways: #### Binary The recommended way to install _presenterm_ is to download the latest pre-built version for your system from the [releases page](https://github.com/mfontanini/presenterm/releases). #### cargo-binstall If you're a [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) user: ```bash cargo binstall presenterm ``` #### From source Alternatively, build from source by downloading [rust](https://www.rust-lang.org/) and running: ```bash cargo install --locked presenterm ``` ## Latest unreleased version The latest unreleased version can be installed either in binary form or by building it from source. #### Binary The nightly pre-build binary can be downloaded from [github](https://github.com/mfontanini/presenterm/releases/tag/nightly). Keep in mind this is built once a day at midnight UTC so if you need code that has been recently merged you may have to wait a few hours. #### From source ```bash cargo install --locked --git https://github.com/mfontanini/presenterm ``` # Community maintained packages The community maintains packages for various operating systems and linux distributions and can be installed in the following ways: ## macOS Install the latest version in macOS via [brew](https://formulae.brew.sh/formula/presenterm) by running: ```bash brew install presenterm ``` The latest unreleased version can be built via brew by running: ```bash brew install --head presenterm ``` ## Nix To install _presenterm_ using the Nix package manager run: ```bash nix-env -iA nixos.presenterm # for nixos nix-env -iA nixpkgs.presenterm # for non-nixos ``` #### NixOS Add the following to your `configuration.nix` if you are on NixOS ```nix environment.systemPackages = [ pkgs.presenterm ]; ``` #### Flakes Alternatively if you're a Nix user using flakes you can run: ```shell nix run nixpkgs#presenterm # to run from nixpkgs nix run github:mfontanini/presenterm # to run from github repo ``` For more information see [nixpkgs](https://search.nixos.org/packages?channel=unstable&show=presenterm&from=0&size=50&sort=relevance&type=packages&query=presenterm). ## Arch Linux _presenterm_ is available in the [official repositories](https://archlinux.org/packages/extra/x86_64/presenterm/). You can use [pacman](https://wiki.archlinux.org/title/pacman) to install as follows: ```bash pacman -S presenterm ``` #### Binary Alternatively, you can use any AUR helper to install the upstream binaries: ```bash paru/yay -S presenterm-bin ``` #### From source ```bash paru/yay -S presenterm-git ``` ## Windows #### Scoop Install the [latest version](https://scoop.sh/#/apps?q=presenterm&id=a462289f824b50f180afbaa6d8c7c1e6e0952e3a) via scoop by running: ```powershell scoop install main/presenterm ``` #### Winget Alternatively, you can install via [WinGet](https://github.com/microsoft/winget-cli) by running: ```powershell winget install --id=mfontanini.presenterm -e ``` presenterm-0.15.1/docs/src/internals/parse.md000064400000000000000000000110471046102023000172510ustar 00000000000000# Parsing and rendering This document goes through the internals of how we take a markdown file and finish rendering it into the terminal screen. ## Parsing Markdown file parsing is done via the [comrak](https://github.com/kivikakk/comrak) crate. This crate parses the markdown file and gives you back an AST that contains the contents and structure of the input file. ASTs are a logical way of representing the markdown file but this structure makes it a bit hard to process. Given our ultimate goal is to render this input, we want it to be represented in a way that facilitates that. Because of this we first do a pass on this AST and construct a list of `MarkdownElement`s. This enum represents each of the markdown elements in a flattened, non-recursive, way: * Inline text is flattened so that instead of having a recursive structure you have chunks of text, each with their own style. So for example the text "**hello _my name is ~bob~_**" which would look like a 3 level tree (I think?) in the AST, gets transformed to something like `[Bold(hello), ItalicsBold(my name is), ItalicsBoldStrikethrough(bob)]` (names are completely not what they are in the code, this is just to illustrate flattening). This makes it much easier to render text because we don't need to walk the tree and keep the state between levels. * Lists are flattened into a single `MarkdownElement::List` element that contains a list of items that contain their text, prefix ("\*" for bullet lists), and nesting depth. This also simplifies processing as list elements can also contain formatted text so we would otherwise have the same problem as above. This first step then produces a list of elements that can easily be processed. ## Building the presentation The format above is _nicer_ than an AST but it's still not great to be used as the input to the code that renders the presentation for various reasons: * The presentation needs to be styled, which means we need to apply a theme on top of it to transform it. Putting this responsibility in the render code creates too much coupling: now the render needs to understand markdown _and_ how themes work. * The render code tends to be a bit annoying: we need to jump around in the screen, print text, change colors, etc. If we add the responsibility of transforming the markdown into visible text to the render code itself, we end up having a mess of UI code mixed with the markdown element processing. * Some elements can't be printed as-is. For example, a list item has text and a prefix, so we don't want the render code to be in charge of understanding and executing those transformations. Because of this, we introduce a step in between parsing and rendering where we build a presentation. A presentation is made up of a list of slides and each slide is made up of render operations. Render operations are the primitives that the render code understands to print text on the screen. These can be the following, among others: * Render text. * Clear the screen. * Set the default colors to be used. * Render a line break. * Jump to the middle of the screen. This allows us to have a simple model where the logic that takes markdown elements and a theme and chooses _how_ it will be rendered is in one place, and the logic that takes those instructions and executes them is elsewhere. So for example, this step will take a bullet point and concatenate is suffix ("\*" for bullet points for example), turn that into a single string and generate a "render text" operation. This has the nice added bonus that the rendering code doesn't have to be fiddling around with string concatenation or other operations that could take up CPU cycles: it just takes these render operations and executes them. Not that performance matters here but it's nice to get better performance for free. ## Render a slide The rendering code is straightforward and simply takes the current slide, iterates all of its rendering operations, and executes those one by one. This is done via the [crossterm](https://github.com/crossterm-rs/crossterm) crate. The only really complicated part is fitting text into the screen. Because we apply our own margins, we perform word splitting and wrapping around manually, so there's some logic that takes the text to be printed and the width of the terminal and splits it accordingly. Note that this piece of code is the only one aware of the current screen size. This lets us forget in previous steps about how large the screen is and simply delegate that responsibility to this piece. ## Entire flow ![](../assets/parse-flow.png) presenterm-0.15.1/docs/src/introduction.md000064400000000000000000000012611046102023000166560ustar 00000000000000# presenterm [presenterm][github] lets you create presentations in markdown format and run them from your terminal, with support for image and animated gif support, highly customizable themes, code highlighting, exporting presentations into PDF format, and plenty of other features. ## Demo This is how the [demo presentation][demo-source] looks like: ![demo] **A few other example presentations can be found [here][examples]**. [github]: https://github.com/mfontanini/presenterm/ [demo]: ./assets/demo.gif [demo-source]: https://github.com/mfontanini/presenterm/blob/master/examples/demo.md [examples]: https://github.com/mfontanini/presenterm/tree/master/examples presenterm-0.15.1/examples/README.md000064400000000000000000000044511046102023000151750ustar 00000000000000Examples === This section contains a few example presentations that display different features and styles you can use in your own. In order to run the presentations locally, first [install presenterm](https://mfontanini.github.io/presenterm/guides/installation.html), clone this repo, and finally run: ```shell presenterm examples/.md ``` # Demo [Source](/examples/demo.md) This is the main demo presentation, which showcases most features and uses the default dark theme. This is how it looks like when rendered: ![](/docs/src/assets/demo.gif) # Code [Source](/examples/code.md) This example contains some piece of code and showcases some different styling properties to make it look a bit different than how it looks like by default by using: * Use left alignment for code blocks. * No background for code blocks. [![asciicast](https://asciinema.org/a/irNPKwEkPZzFbQP6jIKfVL30b.svg)](https://asciinema.org/a/irNPKwEkPZzFbQP6jIKfVL30b) # Footer [Source](/examples/footer.md) This example uses a template-style footer, which lets you place some text on the left, center, and right of every slide. A few template variables, such as `current_slide` and `total_slides` can be used to reference properties of the presentation. ![](../docs/src/assets/example-footer.png) # Columns [Source](/examples/columns.md) This example shows how column layouts and pauses interact with each other. Note that the image shows up as pixels because asciinema doesn't support these and it will otherwise look like a normal image if your terminal supports images. [![asciicast](https://asciinema.org/a/x2tTDt0BIesvOXeal3UpdzMHp.svg)](https://asciinema.org/a/x2tTDt0BIesvOXeal3UpdzMHp) # Speaker notes [Source](/examples/speaker-notes.md) This example shows how to use speaker notes. [![asciicast](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J.svg)](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J) # Custom introduction slides [Source](/examples/custom-intro-slides.md) This example various custom introduction slides that contain images placed in different layouts. Note that the images looks pixelated because of asciinema but they will otherwise look normal in your terminal. [![asciicast](https://asciinema.org/a/sBeAMJbpBxqKA2gF2RI3MmLT7.svg)](https://asciinema.org/a/sBeAMJbpBxqKA2gF2RI3MmLT7) presenterm-0.15.1/examples/code.md000064400000000000000000000033341046102023000151510ustar 00000000000000--- theme: override: code: alignment: left background: false --- Code styling === This presentation shows how to: * Left-align code blocks. * Have code blocks without background. * Execute code snippets. ```rust pub struct Greeter { prefix: &'static str, } impl Greeter { /// Greet someone. pub fn greet(&self, name: &str) -> String { let prefix = self.prefix; format!("{prefix} {name}!") } } fn main() { let greeter = Greeter { prefix: "Oh, hi" }; let greeting = greeter.greet("Mark"); println!("{greeting}"); } ``` Column layouts === The same code as the one before but split into two columns to split the API definition with its usage: # The `Greeter` type ```rust pub struct Greeter { prefix: &'static str, } impl Greeter { /// Greet someone. pub fn greet(&self, name: &str) -> String { let prefix = self.prefix; format!("{prefix} {name}!") } } ``` # Using the `Greeter` ```rust fn main() { let greeter = Greeter { prefix: "Oh, hi" }; let greeting = greeter.greet("Mark"); println!("{greeting}"); } ``` Snippet execution === Run code snippets from the presentation and display their output dynamically. ```python +exec /// import time for i in range(0, 5): print(f"count is {i}") time.sleep(0.5) ``` Snippet execution - `stderr` === Output from `stderr` will also be shown as output. ```bash +exec echo "This is a successful command" sleep 0.5 echo "This message redirects to stderr" >&2 sleep 0.5 echo "This is a successful command again" sleep 0.5 man # Missing argument ``` presenterm-0.15.1/examples/columns.md000064400000000000000000000005631046102023000157200ustar 00000000000000# Columns and pauses Columns and pauses can interact with each other in useful ways: ![](../examples/doge.png) After this pause, the text on the left will show up This is useful for various things: * Lorem. * Ipsum. * Etcetera. presenterm-0.15.1/examples/custom-intro-slides.md000064400000000000000000000012251046102023000201600ustar 00000000000000 ![](doge.png) Custom introduction slides ==== John Doe ![](doge.png) Custom introduction slides ==== John Doe Custom introduction slides ==== John Doe ![](doge.png) presenterm-0.15.1/examples/demo.md000064400000000000000000000065061046102023000151670ustar 00000000000000--- title: Introducing _presenterm_ author: Matias --- Customizability --- _presenterm_ allows configuring almost anything about your presentation: * The colors used. * Layouts. * Footers, including images in the footer. This is an example on how to configure a footer: ```yaml footer: style: template left: image: doge.png center: 'Colored _footer_' right: "{current_slide} / {total_slides}" height: 5 palette: classes: noice: foreground: red ``` Headers --- Markdown headers can be used to set slide titles like: ```markdown Headers ------- ``` # Headers Each header type can be styled differently. ## Subheaders ### And more Code highlighting --- Highlight code in 50+ programming languages: ```rust // Rust fn greet() -> &'static str { "hi mom" } ``` ```python # Python def greet() -> str: return "hi mom" ``` ------- Code snippets can have different styles including no background: ```cpp +no_background +line_numbers // C++ string greet() { return "hi mom"; } ``` Dynamic code highlighting --- Dynamically highlight different subsets of lines: ```rust {1-4|6-10|all} +line_numbers #[derive(Clone, Debug)] struct Person { name: String, } impl Person { fn say_hello(&self) { println!("hello, I'm {}", self.name) } } ``` Snippet execution --- Code snippets can be executed on demand: * For 20+ languages, including compiled ones. * Display their output in real time. * Comment out unimportant lines to hide them. ```rust +exec # use std::thread::sleep; # use std::time::Duration; fn main() { let names = ["Alice", "Bob", "Eve", "Mallory", "Trent"]; for name in names { println!("Hi {name}!"); sleep(Duration::from_millis(500)); } } ``` Images --- Images and animated gifs are supported in terminals such as: * kitty * iterm2 * wezterm * ghostty * Any sixel enabled terminal ![](doge.png) _Picture by Alexis Bailey / CC BY-NC 4.0_ Column layouts --- Use column layouts to structure your presentation: * Define the number of columns. * Adjust column widths as needed. * Write content into every column. ```rust fn potato() -> u32 { 42 } ``` ![](doge.png) --- Layouts can be reset at any time. ```python print("Hello world!") ``` Text formatting --- Text formatting works including: * **Bold text**. * _Italics_. * **_Bold and italic_**. * ~Strikethrough~. * `Inline code`. * Links [](https://example.com/) * Colored text. * Background color can be changed too. More markdown --- Other markdown elements supported are: # Block quotes > Lorem ipsum dolor sit amet. Eos laudantium animi ut ipsam beataeet > et exercitationem deleniti et quia maiores a cumque enim et > aspernatur nesciunt sed adipisci quis. # Alerts > [!caution] > Github style alerts # Tables | Name | Taste | | ------ | ------ | | Potato | Great | | Carrot | Yuck | The end --- presenterm-0.15.1/examples/doge.png000064400000000000000000003011111046102023000153330ustar 00000000000000PNG  IHDR!W kzTXtRaw profile type exifxڥYvE17z Z5-Y?lLDsuOM)k+ϟS/>?+u̇UF+? 7z| }n룞H_ }]`|KoK0Ӈ~}O D1~=@šoz%GS~w?KVy׿rg+|o߈? sj_____~w*F*|-{)+~$ݺ9KQՋR~eYl۰k}^x *B|/6r"k 5f=,nrn;oGq1-oW`濂OY\!(<2?k$YQVt;?BOZ_ D:0Y!T3HCLa9l2 iA-ޏx:`F&r,J)S?55jhSι[yXRɥZƚ\͵Z[uR˭Zm#{鵷sp?0 34efcQ>+ʪ;nc]w}cR:SN=3.v[n;~f_m5Zx֟jߗ0IVHXpxU (蠜f)eN99Y9ۦt,k߹sQe7W/y 9_fySֶhh}PAFh丮Wpgo#@7wmջ+fao%?6 ?ngDip'(aCX;7FgXg#zZfs@'aҤggAH5fQ,۾dYO(ہ',0iVˍ+vNb^N2k'm84K*>UJcۂƭS)FYrv;wZqzJJ'g_^L쇠(`%u?>}[-tSJg-$ң.V% Ӏ8&sҮxAFhC5K$6Ӣ҉o]t^ɗ F ho[{3,}>}ߒbn>蜲g[XJbҤxb]U+nVtHH B3b6LWC};RG_3__ms)oXS{T88r>;mɗeiR CufMzkTG}9U3j/pN=Ws3W)(>kjxh@=Jw4!nuqNBE=}Ƽ^:o7ޞXÇ5Ix\Z&h@0 Y3P"ytˑx*TR%{?A=w+Fb* FNESEmaiNǤdr%FRʑ><-F+O MXBI"+= L,L*TZ P96*^DMR?٣9}T8rZ~4% O!Lt v?g١BE1)~Re.T]"ktRYT3EG?K tz,XW:sM z))IᝳR5"OaT'HI.h(u%&`t@X\F$"]=:!5k x:eH@NmɽK"rx7vrS|4h9b@rFnNO/xox '|=t9Фf`h#Vz! p G-LFw!]LUn̔4`vTF1!!K-oRB@`NfNE\q9t <5*b5{l46W7TeR &=UKf v׉" *&pAyrAfY9EY6C"jq"cz;yDƍ 'WMm2Ҡ@<"+<] QaFF5X>Po*3SH5RdTP./RM0g,ReSvﱵFɆBAlrbqꢭ7o_|}{¤k;.;sH\6|.Vk ~mWd YFӦ0dJUpƺyTڤ˦%1@d"Dx~9w ?yejG9ഴqi xiR.ԈB0[Cj>˲DߐHك)r^ -Ԙ Oo@%^@(QxJH 3F riy+(T4@PF'Xk ҧGGyۥ D\0o,wbհ@)^G ~)?4F 4S<ᦺ7вFz/?QDV<hˍ3JwQ0O~Ɠٟ Ē)bC=ٮ9Q CBO'of1ybxnLc #Y"8ջIkzb]B<2EV#p`h dqZUd?z? }(1,( CQ5bj0ZkEԜ<*~:- qS=+ q]=O $/x|(­GxVMJ+Y*6]4P<ͭ$yz^qX7|д7Dp0թ/HǣIr%hKȌKqk"K[օĩ P;v]t6ш6K%|(Ar\p +mP tѐMS&R3ʙRHJ7"Z51TyI Pi&FYAVc<&Y=Rxh6u*7L5A(g|Gө D|VQ1lwҢ}O#@ n7pqP6ofY ydp:cT %vÔRVt"Uᇣ)@ᖽY7C돧p=2!?]τ/ Zv# BØZd,HlqwBOU*# &JRh<[y>LQVk+cFjGKyMW2@CrzSyQKt>Jϻ8{fJڶ#)@ aЀEF6SJ{$.ޔf BrJ~xV4x 0h;?pSi sӾ;PEdMh4Af!nlj!OcT2u-hXGy ,$Vm/ς&_H9Ġ;4.M7RaZH[ntnϤ,PVxP!za&k83j7ծ,_ދW"4PGs X p3X[L4ZgU#tDiϮy> 5DT64ɷ Lju5iBBz$ CH u!QSQp?^Ya';ux ACg 1/ITBm%Ik,DK쵬ʠi 񊚮t*+lڜ1 .9#xY؊W)dl-%r@9of5 Jrn  >.`/!K[j`+yVuV_}0iJȮBgO]C)N7SE0UX <a30'~jpDOpٴGPjh{ !vݚN@ 됊jrW}ܻ~joX޵XXҜ;|OMM,1IYBj {_BLB`Tuh6mBBWphM֩9W')Ͻq:lG 3G\ j4u- ]-"kvH[Ho.c!`' ڠ+Q98 c PYvF(<&XY!4mmNh05r/YBY$Fx)}(Rb?c3o:?5w$bnԴ `Vu8$NiT|,Zht1hN9CKH YQ0Y~ XM rW`Ò]"j4*}A ذqG }oqDa?a1hLX+ 0HB`8#dFk v{Q}u0V_{ۘ[ 3LtFi]ֆNi1I$㝥CYCtdX"*,tlBrӴV#P,.=(8 R쀯aZH>Po.P~n,nPh0!qCӒtfq¹!NvV '@GĈMz8=J&}#Aea<_Q`=קP ?2AGH*ʆ@޻1د#=LGi 2𨥴MRVJٮxe $AH]!뛅 B[( ߲t[GrL 4P% bp4E'%ePr:ZrnM&сT*t |-hoC3-9~gWɡ E9 6m! e `}f$7w9hx3zhpA]@IG!TE$gV- Bpŵa|BRE< Y45'tL\ mHBV/8#Ӿ^박6'Kh`OcPU;sg =M}6?<@{kX% Mwa!AIaeG7ɻ2Kq:Ut^H(2kThH]x'k0)hm !Ě7.^Mxܩtsè =?oH&HirTh(1uYuM@DI/7QbR)EX<=Jh4ڢ9iKH*UhBjÞy+i/ ^ ']UqQ*%1g+b 1Q?=[Erel(##T\&4ԉhңI[Q ia7\V #P K[-,;~QK:i舯4?r\uEL;%2^ 5 [8%xnr婑:Z#ҤR|0"%Ӥ*cE6t}P!O -A j HYQ{<Vi#l xվ^$ƥ98 $y0>x/H߀ЈAxJZjؑykNq]:N*Ų..v!Lut(N_@ BnZZ[nWD0HA|KP;Da&U*@43rsҮ]sr2SLjA]:< stЯJȫhdVEAVmgw[\0'kYDA)B"znivUDJUm|ulxc[' C`&r˚aEMv8ͣH 6Cv-yb z\I<-'ok*Tdd)o˭I` A<%*A)Y2=ч&ǩo@I4 ff(ƈ5((H 50Yy +3&*Dq=.GXKoǕ[,8!4$SzAhtnPX-9)3Nb9i3 x%| LҺx< :+~. WĵguS!ALCͨcC Jv:[W(6I8NڰE|9F/:ovhA=b4Y+`ܣ,qQA@TMnL6(SkFPA6'I༤Msx5hxPsHC:FtHEED pRM,ET 1 څ\ڡa=yDoD!Ӕt tџIYk6EQ[CqͫILܚ8` l `-X@H,)U QVKw+Tbmd$rm_k7j⯭Y4*D;c"r/*i:8.,\-4sԯB<!>(Hs@wA_?iބP["Eр4vv8MqRhf5+! =fYF=5=0P$do!T8>x5K㵳ּIFfm +B@­C7teО )t[KޏJ>o>eK&n|6JG,hbJ&#-@შ:ăHq<!Nl\;'~0d.: xWNOV/m#dy!I)S*^gJ%hK [C]ჰR1h%ԁ(:]Tzw>xYNui9{p՜E h&F4TpƣI38]LoՑ1r!W"}:,!pl0A׉v)z߽[@_D6|j׉iCCPICC profilex}=H@_SR ␡:Yq*BZu0 GbYWWAquqRtZxp܏wwQe5hmfRI1_C#(>YƬ$;]gsxMNv |L^ok# \\5e ٔ])HS(3< WZ8}U88FJ=rn~z iTXtXML:com.adobe.xmp PLTEGpL‚}߽V۸w޼DZضxj;۹y‡RϦ_HK޾ݾМ̦aݾǞQОНϝ۹uOضu˚̙֩ԲoɢXUӤٷrȡWěTֲkxFŃרŌսXAIЫe?}CrDyCV}}_S9eW9XN3ѾpƭoƯuǚͽ­vϵxϲrDzyìrͯn©i˞ėǶkϹʺoͻ˷Ѹ|ȷɣTƫjξeͶ}Կջu˹Ѽ˭jι|ijvƲ~ʱr̋ɴįz̦XϔŏΫdçeĔAѯhɴ}ԣָxGӵtϜzdžȕşRʨ_˳y°~ȪeŞLϩ^٫hbزfĚG:Ã˱vǤZ\Ԛ_m߿}˖ɏԲm޳ڿMУĊҭb5ŧ`p|1ݻvڼ|ҪX2ёѠH?ZUE֮^ФP<ܶnǁ$"٢¡Xͧ:5)ɼˡN:Ƞ֦Op߶g4˝Gܱ߬_طrS›bF@2x.+#٫VǘAի=RKJKxW0:DۧWAiEYUYÓ@*KV4<V*=@t@W'uiOw삊cǪ 5:ɯ;ݽc V$i.EJJJ*DrQ]0b|ӬNL.HJDT:Grs ^x܀tm%VGhtn*n|[mpE,wOZ )L ]|w&I1kzٌ=*{>v@tf D\^Z6 .dkTrZnWrxxai3|]mt:ϠNӚaE$%Ճĭ(@6oڴisFRI/Cӷfk՗XHnnnK 6g"DdhElFcF}Y jxl?_~-!WbaJ@ު.#yMMMj˚#e.Y puH(5V}ؿ=p;|MeeeQTK֯ z$DRPQQhUa]́^oЙZ8%Z^nYEN eQ}CXjʠrhxkccSchususLŒtMžw+~bKq3pGSBG"ݻUzr 9vw}Ex/R;@)S#-Kb/($".K}~ȱZ@>By\olJ&b=f&YB@G ͪteC-{4 ?b\\c]噐]:i/HRa/[9aE_\Y sqf'N ).AHx @iimjhuAM*@ uȦMFk}3m{|:D"PZ[k_?.I[-Vp.kru˿!믵577t_4iT<$ZY~Iͣ^*@6 QHaQ/ ]‚ f?X5 ZHmcOlT"DeHGX[!k&y;HX"}L*xH%L}iT%Thҽ{eޠ06474|a1R/DZO Ĭ=G^YviTrGd$EЧ+m$=$Hg)ރʼ勮]~֭H늳3:XqO7UQp=`Faabտ1Kx`ТҀ@E˫_ lm F8Ű%کCۭճ(sY4RRG+Vw?C΄ eWvEF@%T[eg6`u$HDeZJ7 Bҵծ-MO*1K) aJ47O/pVuiMP@ 8Ǚ3g93MMO7} HB>^vF%eH{RƱR!_1YuHEx4u=*p`4=} Wp;wIkScsU)<"m'BDxN4j|czk:%$/-EWvF~SNqqt;߿عH"D#Ju,*/ v5iv3Cu|E3Μq:K?5Mc1tmSCr@|9Zwnڔqx}pr19 .QKHaaT/]eϘ"L 9Ӓc!rft>zm|8qǮ-[cb$NgOR=_635D~P_ع uǫ- _5ĽW ~̢=?[xl&XI%fUy/,.Xa"+pV%d#/w$[}P Z ,JuEEY2mx%FcM6zUYIί Vv/ G}<C*AGk0)*Z45v6QJ 0$j]&E$8gʞ]ZI<8*(F-vl)R 4FT_FωΣ@:G\m#=P|pJBJvR&c**rB}_W[_[+Y>5yxNr>ȏ:_ g*f7Vlɑ ~'][ ہT+zɈxOы/=ľbcibT} Bqijgk*1(%qΪG=`*ؔQNcg}&0KRık˖0 0 {"0 8U!2iBgVz~N9ksHgXyT9_aeFx cURK`uZ%\B~>K.\L}lxG\_)N" hՎ;PpV_jM.I&EA q6jU+%f?UzXf=@V48$???`s) [* [*d3irHFx0,)' 8ƭxảO6n,XTIWgv y "s,Dq_J :C xJr[| |z+h줱Qa$t:Iv(Ȗcj@څRJn$}&njd0͛"e @.(!C@!pt0=ؔyV>elM 驳늊tvݬMTxsr EၠjOOG򨆫yȑM #e*rҏB8)3'R zE2Jg`75l_+E#ǜ`IF.T@/Ap?ӲRH|sFFMN}>z0 c=N7o"VӛngQqv,JR&ulV%hUT@dĩUQQa/c?{E"U(UM+'[P|"Ow.OOhmrw+r cv_++UVĊ"^HE)uw1kNXdhd>KPxd(U 7qqe9"gu$;DSmm{mV,GZZ<#WR*:oo$bor%)dkzi8jF,a4<F W,M?5Y@*r{}Vw7ng>f 4^A(ufYQJLS"Vi]WB!j,<Z%6R r 2iB 65=}D^'! FOoW?@y힔GJUV4kvH ċg[uZIųUE!Iҍ0R AK6u6=(/At@Q2k X ,r ڲU^ts+4f΂:˫[Gn]zʪzkM>DPTo*X1rm]j4}R3]AH FT&e"VgQѹkBE IԩSgk|*OxIqvHrkratDo4!&,[Q},鈳G]:-/+䞔GNeER`؇DfjS}=x%A!]enć"'_?KY@1ۭC&&]`xn 2`":o <R)^вxO%335.40L&ږ׌1)L/ >"5;(M NttS^^ixHP)~?%kcVQQ*ì^ȲcHbAJwtѫ8y Us^łĪaOj\Uq&h$&mk1k겗PHua9%w@^>?@(Dyӣh6Hg!1hq ómvZL;i1\]օZuD*"\$UCjJ\ƨ= ""C/VqVb4F:ކ^YgUZ 8t.AN g_u} j=P^GY|aZhcFlT$Rİ5+ygH 1oo]TRYFXlo-q{R!̅[_Y2uOK*AQb2!-kU&c߉arp%&|d9 ͼpe 9to9/s&:GJK])!C.^B l1"f%4iKJ Gq[NuVk_0.OܳżLQH a==cƍBCǍ oPJQgEE>GJ. 3rŋ֊?{,ש$ ϷÔ}(zAA) (H(?p8J=uٖX`sc#U$Vx]%''N:VYYi^ia@($}ܸq>aaz o^Qy@#>/RlAI8doϙ32$k[+053z/ Yl)*E;;"dؕ imuNDN_,XPZ*!̚u V!fvY6f͞%A v IDATq=B>  _m_ΑGE!ɋgl),B3}N9oƿzv [LZM DhZ2rs\a] ΒGm}^t{$M!jdH sƟnYYťЗ獘6gN@@PGx@йqŰh["j78zY-wnnjDA"3ǎ 9ibAԅPz@l6}Ȱfl23ᰃ1UF97݄e\"2srks Ͱ^v7Öwr, K'D.2랕͕+B̜CR+nbkyYJ3[s"Ӈç 4iRȼ?_5u\Ԡ @ꛍ6{Z;P~foP¤[l/-f]@o Ͷ&f.Mkn4w־69Ynݲ M3e!ӿ_:vҐX߰0Z:/L-Ɠ)no->yL7i7WNߣ\BǠ>@l  Ok15d?+65$%%!l4kme?l1.׳\zq{@@ HeGiӦ-]k&L8%6,2. xĜ27\.tK]#8`!iWY~o`:W\zGBCCFGd#j"Cw\$~2dPȡ!ak0k'YX>c5{hĪfA|4lz>fWDUXlZuZ{Q=GqrpM5 z9td =#ೢ,$S1}@?eEu뮮(Bfy >pI Doll5+s΀g3*!FDKz:o[C*ɵ}$Q[ۯEN K?+rsv"K?akcʪLm_Iɶv޶7: Xe-1SX\nf kBMCH@Д0e~?m0>$Ǔ 3(&Ai=xk:7O}ۏxRQHoW$RHOp$'?O?@:u Z|muׯו`VuiAAz!nFh[h>'L"@BF;rq z:ûf1^6jyP1 n`(v Zv90MD$$lAo-O(ȣ[Cp k`% $'w3 =z7o^>}钒0ٽ'΍h穩bV}l^6ĽȾ#r^?,$'p„C 9sQlp Iz%F#f|!b2{d0A|jd ]fRP3pϗMGbJw}H$E  u䰠iN=};OުmuUu'O;D ޸mLsfۗ#&<=$`C('O>i#F 0 Ly9hic_#[癴}QˣҔ ZQ#Ls˕$븲@* vu۰Yu-uqɧ-X!tW~m7O]W哗K>(;GLw :-xFn1]]&K&BoC1}Ҽ#͟>g!#"tfF!"m\G$tVE L{o%3HDN:v$c3}%܆+|"=RXe~zzCqw k|{=˖MrwJNDNWm+.=`?^iX=-f3sWB6@xf29piO[:gҜGƦEb-"@,- B%*?30}1]gz~;!ǎpǖBU9d(O8ǭ}F6f-_7(O,=zǵ7V%%UU].A}xZo4LfY,$/,N" N 7i  Ӗ.1gޜFDP0Q[yoYm-CL =LEyZ=1L"i$9)D2T{</yΝ[woqG TĊЅYmF2{vV%ص=#~18% dL<ʘ?!k+_?qw}パ0o%mD5O݇P#Ybt*+Ix*+=Ԉ$GrMʶv,0cߟ_PԆϮ} ,<}i~MTGd6Xw[S6k~T:mףD!?xplȜ9cҥYz9qW_}WJ-nh@vWۢ&SΊ?VꝉT*؈ʃ/[*2 rU N䕾!Xϭ@/Dk߾u*²pŒ'<5vydqI1mG8>&G?XY:?6܉o^ٯ>y9B|՞κu|:>ۤz AC]STTU!+.$I&Hכ NPU:w b-XXxo]%w._u$RW"Ӌ "9?;Q#AgF$:v#@;ATVP"ʞvYt'eۭ=9.<>x~Mrn0dA! @➕pGfU]`$?(Q}bcnn+-]?Ǿm7>;׫n ๮ IH4xܝ#FGz,f6x۵JMfhH禕;F٬V>l矏\ȱ[ ynzȯ!p?U1ߑzxd9*++K ]_I<{44Éo|m߼qmEwvヌԵ,:TŖTnNeqvsVfIX`͚5~չm/>oGSEHw7"~zT]ҭj^sD&^'~ U~̠/& & bQ yhV .OEh(Ĩ65 ?}xl+|qǜmεo6h (GpJ~NY2u(u:l 136M,C[x9/X)-zW{W[k׾`톀Ӿ:<KP"pdu㒖NnYxt Ĩ `K xe Ν%ۜؽDlhl3Mܕ6!&iu r%:C4\;w`K2U w>| s<@CnEw=}WPG 8ݲ8po Zz`ճtmhihΕ)L+S7+OaO#KF6i*9:W+!*{ C 5:>L}/gMIvШ ~qS熅A.irPEHza \]\kZ'GєF.^'!k7JtsY۔;%xJ]AŜJpDp *ɤ[(&SmhҊRT1gˆ-2( @z3]`MO?ZpPU L9P]pB'6{H~pqnd(dϨJ(ĞIgsD.Jc0~sƗSYRUU}VTNLff,Tk0ernBޛiixRF(sij2,0?@Lj $Y\ṷS:K9WIw3gwts(F/C8.N+mmܼf»>맫N8ꅩNrm>.NYEaFS+e WDUHXlQy$ҥgdbB1E!nxWGs]fe +˃ }ggm^v.veB|яGoi|Efy/>nkPK%7QD.`0#)(9˹Sv%gيޓ~@ yMzC+#iAQPwӼb[%9蹮^QZ]?R go`|ֱ!Hu_6gP<}Yr$ $Zl ) F|3"8X0#.pNh:,Fk {<=gRHIrRF#e̋i^ i<ˋX yS PHtRR;׿%*]8]%UXػ},G yiIﵮixwd$Gߊ>@ e7oըՉyyW{(#vN_'}dwuʡ SfN   \lѐ!>>I870A(S$f52CzU\'rp-H>%1y>Y u׿{A<4wP .$r}t5"QcȋZ.l6Լg_uUI7S!\^89cؙ7( ,GF,znE \)ù7 /Lys[ I!߈L̫KSjU}[f>}\ǵv " 'u{?<.|_֮URat^5W,NH g\r<O|7@Fr\&;vt Ghhxϸ!F6laPɐq1@Bwt$ysI$2tv@noPye)bVX{{=~9cƺ|$ ]yQrcȂaR0/CEvd IDAT)bVZ5)bԊW\=ɓ1ӧĹ?0yvoJl@8 2qhEC|hW&MCS&cG8wP8L? ՟߻fƌvyrƌ'}˧K>w |<w餏=7Ƹzyu: -vvS[^4V^)oM#8wK]}8y;~vڡ &RbW Cu3q1yl}|w^sko $2|yZ _TH/,ViIͺ{O>dG@-ny6DGBO]tݰuCϞݺ;xg;rJU3ilyܵ ,uJiWC; -u_$V1)V!k<)PB kjF3{&N\lcߞ4oX$AaX<ޚHݟjM"O743.b`yky5;: nE;qjc;5fc;yD0eD"U™O3z.MDx~vQM0z ͳ2}FMS#/w8t?th>&NcN?b<-?wwD*ٯ <_|Y8bSS&'vy`8dX-c;|~D&2 j}| *Z;,w.c.:fLj޺ŬLfS?_yW~L+W-x^rӰ֮Y6rx8{#_ep [dA+Vb[F+:j`#[i)S~~(BG; GL= 3my4g\/V}۷nBRIVfn^?U4)YD ƅJet /RiZ3S*^p8wACcVX9uj…Oˆ&[i8` ΝpE!'h!\3~.gs"b>[j"[C_承fcJB$Y_3C1x͛P\'ч2 (s r tθ5kXT$EnWq "pnMIg&f a޽Ua)]{ߞ<D)9źE 2m "!Pn18:o^s*kuwuڭr黪zLH\۱w |H7^T#,+B}Fc8$q2>7G8dDd'1&`14DU Ow]OP;^m_KQ ûmUZqtBe=nVpe[Oڱ~E-\ODmT <~+Gek!ʌ`<(%(gdOv*] ;)ѣ'2`A8|"_|,8(?[R1jW0n5Jrʹ37xj\>Ictv>.!%"˖y%b:2-) 2ra{Ur\/2^羾E24'ΝH)lK;lNW/qDʤoϪ Jd7\JnIȳwώ-ѾE;U%pPp_g=UwmN/8`y֬t=W~)pk^lY91<;RbcCB.^]ò B=lg6Ly!2%sCdMq\xH2~:ɳx[,v!Õ!=[B bR ~mJK߶9%N8tۭww4'ywnu0|}}Nwȵ ; G.@f5}>ӧO?  )FBs"e|)DwA1`A~^·lyf"]x ?eLmo$Rx<;/w+DXa!@榄R >[!|`ȔO·:|ɇ&/@XԀl\\ V3)\BA^kTzi<_֪hyVw ng_޽uY/vݻtv>^ΤGd=s䋆LYzK)Hv(àvnOF+k@AΞϼ9/2y.z00(n"t2`PU -#ik>!gJ?OHll#ZȻy|YNMY AZWIAjs1UOP{2z[{Ⱥ-eϤY= փ|pc sά@(іK(3pa3ArNg yׯ6<_ %Lы|&)@ (Ν{iTamS; cJ XAJ 326Ri˴yxAtTt"+uCvUH'io΍*e߽ _ !*Q27ڑgQ5@T;(v`ھ"w0wLj(6CD3QJH^ڣO ysF4ju0/a*6a 4ɤAi r&!HY  sJ檔)_o2rKd-M 79C eB$ 0eIv玽NJ މM&;W~3EOر`Zwfys54wn0a>C,9!)Y vϢJ}R5_<) O )eWyoNK[nԌYcF2dF}|d=DW}N${CAdYeV3] Is̉eCHhGtd~pQSa=;^p.wo`eSs˲S$%֛& 9rP_2sO /QXb"4+Q^.fB& ɓ#h!#9CG!z38.b>r򢢣{}x8yZ#ݵ#ȭ[ouJʋqfugn?ϹH̾X.T"S7vLƎ3⭷4j[#+cUfMuD@`v!K2O?aa ǜ9#F L 8ZCˍjuD2iU:!IюkrN,tsw+|@,6x]9wߞ?j(ޫF 5 ];c ^EhԈ {srqD%ZM2a oehc(5h ,6*$zɑ/ʁr$ݮuy/,Dλ! ڻN+HO@d?3Kȳn|՟z Tc/MIM \\U.oy6s783}!wO }S'Og#b W~S$e#2$vY|9Ѭ:r5DM"wS306Mwd̝$I^6% ɖ,#˖,d[vU-j֚@Ihv0)!-p!{C2لi>93j084 :||^\pXb<>}{}0gK76ojo/s8[nXi*K< ߧWYX} !pX4nɥT:bK=?Go/ـC̾+OV @؇M&[~~ ;yLg)WZÓfk^o^'o!F@Jj)T`jbX`2e p@~s &}!20P w Rl^tvx7>̓K@Za_+" WxD!.י}_%ai/yd`iFţa ۠o`T.I x@Z^ 4IqN',F6XtYx si󙺁gtsL*Ľ/?d!+eݴ> DCSB8k%O=̗|]%WV/#NĜ ՠ63<_yZ T50%AI8rt׏;uצ/i^}[^W}s^2}|I"3'*Ix7bA>Ei=~ Foz BDnߞ6fPعq*>X̊]U[SG;m#J".u*N])wfdر+b,X1j, ;K*]YgT*9A5Qm?f>P>&|pw3qgmM.6P#j{1c*'rp7 9߰qp$U)9 Sn- & t]N먒YTu :'(Ld)4BpPS!?>`̚{ `Zbb؇J%8L%NgdBP~}޼|L<l^,Hl]2&Z-mdyZܛ`8lV[ Ѱ_">2>=}l?8ܹ'"x*Q&'zx a&>ǡ_ ^˳!IfxvkFW&zݝ_swogr|>`#\1ub;bݚL5lUњe9kF ]!Rp@)y*žx,w|l .q4Nb ,߹Ǻn5g@qYewљpݻCDM61 LʄE^Xei} 7L[~*g;ˮLOVPeRpq0s7zjC:^prIW)欈f IDATT.0P;t). 6r(O;X<ܳsg@C@7=;4\z~3\>^Xw 4d(b@ ÌXC $~_5wwݻwxnff&v4\Ã&_3o<>@qxYĉp<9+EbK8Ұ֦y<j΀o89Ǎ;վ>4H7- YYvșO=@hI"w`X]^(u|}ZgqY_s^83|    'JKBdϹxop<\:kNg<dᠤG ?;AI!O1XRZh; 8u r~8]ΝwZ]y|.XOd[9Ch f5} @k n무:J%+Y ׿߽}o ιD*lBـ+I MpTnu'h^p?LB !s%4'{dԩϋDѣ}j C,{ QoawoߺsԪk&OrV!ח3K(쥣Z@{{_[k$\CW I%48:/02d6PiSMCrxPJGX{X2}#vo$2LEpXG`wg߹|4Mday/>Sх3'}q;^+@^ ^~"Y4rfQ豖<=:ʻ֤%HqNhzr`߽y<I4Tr]g5Z;wݯnݻ_|~ wbFv{dk/D0|l$Ǖ%>FTIX,n8w5X>٥QT֦U1@خ)؀|I[aC g%n{z߶Q>$W-fDpJʍ5|UUK"!G\4̈́ cs޼{ ]K@$#HxI1@!#u,L$BAB\%:KTjX d *fFI k:e!|$M+@XR_9F?/y<:+/["rZg~|vcg=[gؔr׾󌁶ܹ.߼{έz2yo\*iCo\tř.|A@r}\<ԖlKD&L.0 /:"D:aC-pQ Pa:4bZy~$RZ٬Z> $xȆօ`lxQO΋Ӽ//޹u6hܾx߀o/]'Y x檬ZGLѠZ6ѶZBBa1蟛:GQqB$v*z)7 (%ъb3}>VV-|^T>z~0,"DNx2) g23/~ː)w^|/^zūR_ Xǟ}v?/?_O.2.)7S$e=T)9L<.k3+Rwj󻳡DTiGY9g=]a,ۿ~ϒ$S-vyv)L%pbg7;NP"Tr{_i&0,Bu5k_^{jйC{xQt@$֣6KS ¨W$҂5ZdpՈRͼ/ 566W HA9ѽGJ;^4%=֙Ew- ' /xpz2w15v4^`g/g~}~]/p…˷na".IdLs&ψ>Sg|yB8  S,+|*@^e_߳G^<{OYs9?_yW'/VoZ;GX{3ĥxZ7(g…/p7>XXϟyw^gsV싧__AC\e [e2jI$rXL `of-= q= -k5n-u39VW7ǦǹWC_ػw/4,ztn+|F Jݻ{)>sx\\Nܛ&}?~t̾R|/?Í!Xξu2lGGDPIXؘ-̴kQ΂6S,BؕV5IG@>u!myd) hm~][|-7o~6xAr殳kuV< .Fj@k/""zdM/:C4&lkȃm癖7AܭN!}a;H[C]]ߚ„wOrJ4!ʧjmzBD"xY%IpZ/XM@ tߑO>yиU&nB 00Nà m#u45D4 lRꌱJ`I[u!r;296=#d&uoXd+hjTO;k6H?uCrxذ ,DLۏ^3/٦|re%P @MO˗ _2nz$iPV[0[1,(·e3ʵsuRv^c!P 2P 'D Y[j*a6_t]O.h { z貸[7Sgp˳/gp%}Ձ3+OoW/|!e[7/Esx4lKPkPY% >Ng˺RJ*U5zni-Nd1Y+.}f~Gω=ѓ{n"s^>P70aQdIt/a>"-THwnRBU6/\8{%ӃON v˫Sm\Ŋ"(J%CI%IS(4ٖynƞIc|{MF^O#7 P}Xpwɓ[W H -lʗD Hؽ1oXcdžn8-/3yMΗ>~˫|K^;^LLgp?7>mׯ_HidTajKjF<(SD$# UaQ6'A#4p8X>X@(n.&[[z[+uN3 _b D֭oN 4r]ܟstYooͻB Պ" ң")ZrժgQ|@/_?m|rtfRT2*|kCGD Q#10lQZ'ՍFS w  >XRk4:8t)#qyO~@K#z:,0XΘ;,p;TsaDDabB=l:|/T:'8 @:yлzo F_\H4&l_'W0y bڱCWxUrL߼|K/<!&hOoݺN ,7`7f) K0=ˌ|2= ac:a" j!`/Ea [4!ddE\L|CBcf.xŊ+{أUt\WcA5D q,pf.^wH|'KS=ß\g{tux J5j \.>%|5m s5(ISdDc'UQ!BXx '5~Hw>Zh Y=+X#U(QU6:h-U@W\t:ķG@p~Jh\>??~l$kpgӍsCY:RKwS,Zh_tǤ52e]X!*;(j~fbs[Rtx E t-] ΩK$k:˟H>pXT.D7A\x˟<޸7xcF1S oc.#qXul3ap6!ט*3/@ v{8}hh ddLE.!9j`vfԫ|d;XB/>r*y޸- Ƃx2(>56uиtb-W鮞gKu~٩~9܍YJ]dn,er Vw"LG7MIX^Zx|0 dtTxXe)lL{AX_)#q˗=cDCRd.zt@ZG <@q:yzDK<1;<6;<H:+2FqzRX@^xW$]Vcxf9V3ln'"VW^AǼ֮m?9j}Z%HWN7mg;qk %љ0eӝ{Qg3<)EBO6gj@_8*x@ =~eŷE^T^m0x,P}VKKUSU[٭ÙՍcuOV7.ݘ @yD {{v$wdCAEhs(̜}iOd7lx:j*Ĭ%#z1$y?F[{{xĝuk: qqE&­@bJ$U ˅&lvZð͛_!k{ { ?&\aiلm<`'O\[T|g偕+xWv 6LVg!7@>C#óPluxZ%YxѲ#|[\@5>)d^Ĵ#˚J's &@n`|z|Âu;U=ŗByEd"ʐsG+zカ4.>r  :5k"x@dx5rH#Sд {[0p, q84ؙH5`˹К5%3=Ԙd;<6 @G풉+0o5Cw0v͙!p~ MpYעbPmj}|,\'KU:BNVnxtT1 lR ExF CnqcI|^u'MG 3`0Y#,`'WFwSd(c&X;R7h"KR"iW(TwQzͻ#<U8L;ٯ\ѭjz>syI-)R~޷u~7K:_!lzr -&?r֭۱Vq`AePh AAǥ9rZY ʰQ J 8m%O藌Ϣ#!rZU)Nj }Xk¾'u9}Ppf QkkQ&K^>78E򮉯Y1)=(|Ƕmm[R'#G,cA2XZZ{32{٦2c/&eQ$*(R|DLVAEވ@㙸rWΆfFqP2Ćj3el#K#:~EÝxٹ$\ԿB,:1ß{5#su\;zkZ{߾ڑ#Loule0UZZBEѥv(u2{&6jRlAdYsaE.-z&9KKkxJʌr Rr`%ԮEщtӣ8_H!'lsy°WE;e>5\;G^&*9,:R:0^ "4 waJG `@U0=Ġ&mq9q|q5~^[pw=3qˁX6h>{e)՘yDW>h&˗ PgEwp xGzxAJHcGux<~S,xX\=`fC7$I4VT !?3H^{{90ձ݄sV{?4EM%]S:jNRgVh ҉XmwL䉕[:#MML'iHNNirFż#"̌Vj;k2/s!63#護@CO0ʢ׷xczׄWVޝhUwmn&"U!n9)/G# \Gε)l 7뗂 DD(X] ݥ x_X +C!7C7㷔[DZ+ZCGṞ^ν**Pi?ޮ{:CA oYl5UH 9Ga!Oy\47[N٦fSna!  G{޹& 77(B1f16əX\bmi-pfԈŒ~4|x ;OQ+R?xCH^(sPI$C^q(D,"DL_:y BczIfwvǫH"/~l֏sWk8n6~+JhGjx!" [x|7WrH >yT"VCB !@#_|5!r1`okZ Or]xy}>BtUKD7ӧE"ScSǝLW'l!fw@os't߁j;qljll@F@楬Bު2eyӣ|re[C**ˀG9(袻'd8F.[@aDOuKu, H;@~W-B@dtf&|x)-RnYְdmV dwZ6ҽKĭRD񡡊rQlj~Oeqp2xz{"x0h0@#dvR{+ɷCe?/@zp 6265ppKBiN92cҪW73xDE62ȎFz|1!DˬηqZ VNj[^q+Y|*^bb]::z-DE)XȑeSLkf1DR.1b/m .0 \NFD"ka@(Dۑ䠺%<\wV9k xB<]\pN^8ݣǯAa!f:7UOFnXo#Ku.k͚lFd0B;`C4HvV5' (D33=:x((?MKl-D$0?@LP(=Be67d+Iڻ?Ș o$'a#R ux,>'zBּąn3BVQ oTgUx~xn8  Q6 vS;s\ IB^ZH;yMpb8_͚KpŦ"JQ|fg#޼=xՍ`$ɎXpycƠD:~BoL:-SNwu7{ǦMܼ;FU&& ?l+Ⱦ$ 0 lP,.qqZl4t ݈ϳj3l@ŽįG}|ytz3ulHvq[÷q$S&!)u`!}-WR "kXm94 LO !$3܎7@^!SpŷFYJlR7O}ۋ <8k#DDrŘa!#eTvL$~% 1 $NGgR`_jX[R,`sxZkn:6~v0 d7Ha+OT ~ӣY:kkɚB+N mnj)Xh"5on2pE91*/Wx oI!v^vC" ;<T4v"ͭZMv lib2FÑ`fi@ɴet_> xK]DՁf85ި-SY(kb{Oj!Z^e|GHe"k*][I# fCE=&DXZ #gLvvLY~9~G7yZ({L ZTGZɊٻ+r09Fz?"X蜟CT \w i!Ӿ .29DDz ƒ?wS` #ە< JuHVp:546; ;Kр 'ҡPuQ y3[ 83̡8tmԩcWN-4ԩopR3b]YI!CXȊO,n-̿S[<|D> 'Ru̦xp[.z*qXa0^)8YX?75^LG-@l]0PBYq}^Q~?~Ο:lG4BiwEk8ĺ[-f4uSH 0e>7Y] g —zWRyV1<>i7.L?5u|X?G=~=u}'Nfo%IYe_Аv3~q<"]| ޙjIKdlln.cꤥjꞞU+Ե/`BX"f z6R V94A FcSc)X\;oU'~C߅#ܩ@ FFkF/.vXUxcڏ17շQd,>Ր+o?PX'oF[L6?J a$i#tQallzƧ-^ ׇ P-a̭;`"Vy*a PL/:Is_c1l3K2S0_ 7҂_q9UL [-1a!e:&V{Ek{ FN6ge9ݔXE\9]#  <@Ssjy)Yn: eQTmj^7͵E+d_wڀMBD9qr;&DG/yRL>G@y1ϸR33|>_.HfrK E@/z؆q9XBd6$|c0kVccVtRo ^^|pDb+@GpD^ua=v.b:x+>&, 2O~_enNX`#\\*m\2 IG\P_fCX"AU ZY~ۨp j&/_;A=+Pe$"7hAV`!e+m8mLA`=NY&Bi A (/~i2dVOCNp/%\co `|b9wQ-S-ڑ,l[\Y׋ IDAT ]P;J)۳*ozR}Q+d!Էw,P V ?P$4z%"e9-/~a~wXh܀6cucVxpN[͇}r J $vbxx<C\ h$4]m2j*1We]SBd@+ "£}Y:YQԳGo٣Hk*+wL2wRR}ʬ C_d!łyss4Jrh*^10d0dSaA@nj}֭t:-18ab0K \#NNX@Cq*Yr5 CvY2(D4!aLg>1C|&` >fWgx(67C퓙h.[IJqկ;h{YmRfdu CZv,Xk%M@6 ^JSM#0' \gU%R9"TZ##1G@[ 9!*Qښr+f'ۂxdŹ$\$cD&q%.ϋp?L[)6nQ* zJuAcV$ߴW>zpǪ+BV0+**ÏR+PL1 %`e9.DaOFNQ@桳$s<'y0KB(O(Gyd́7ۄUSU] "ŸQsoAj1sYk(F+hQn,\l1+)Ս\gaJ#~ƨD/ÿ0\|j[a>i2+ǏP+3r9ʇ+]q1%Hke= 7F-r:3)!3 UG1䲴xtߘKüg>e%cbkr6r`gHN#,h, ajܪZOו]<"*(?7 [x4 yYy^)ᕘNM&?6N7|ЊiR;@x)y(x۬^hQ+ Knvo.*5\ b}ww?JD}|#Uwyb'Z,Ǵ LcJ#M:gat.Ǧ>qؗI )&y +N/JJVE:v{ ;I-% {4[-82Ts:` tcd3MDSEE{֞9Sln lJ;O}^c"lAbB~ L&: nNSTq tzJdRFH[}VjDG-U{zlk9= % *4DoyE֊+ kX, V/h1 ӧ(Doq/رSGT$"FC h_[ܶJb% |WtP"҉[4 7ZJB0k;N'4Rd5-AB3|P|BAI&3b$Q5pnܰ&apJmCm)Rp Wp8و&lówr)ٽǰb0ng+f@_NǬJjav62K<i9w'3)"Gxߧx'%2\4hfI)8LF0 nkÆ yҁA8aX_ ¦xE/o{nϯXV"^l /xYUcW.EƍjջrWGMO5w8F}%<6?_SlfcU0Ǭ c)<()?$h~LS!r"uwÀi{RmV?18Q#}:_ycbfNB3EgVb!Ϛs.Y`P"KIALn ?7EFW 1LJ}L^PE*tAmnt&K:̖P<)-hDk*J%`B^%oH@ҟn[ذRtjm"ּ­r:S<@zd"Eg"D# gCJXFvMqr-GaQܙy<55;T;yb"q.64p%}L Z$KH2uVbc%M#!ͧD\W0u:Kd12N(}(<_ʮ]Gʞ]ԡO2"޶?&9cѾn+f&u_R`q͠GY)e~^8xrToOT}6B\. x(:F9I,xF\;[/& ;i (D;xmDlA hn,\dCJnZ V>7kѡED%<~Sz`"li҅cGjprv05[#~P@SPl5fggvr:ڒa.K"+.j&U}x=nYFTcP >S0!ҚӜ;仟|Sa-bjbl,I 9'-"U4NJ,ŭ$6s|I(vkPW,0gnqCwI _}l۶ml?ݹ8ytٟDL@ĄNSw@JZJ̣ʦuQxhؔ+F]zsȞpk84AŐv1Lx0NVs]Ѩ XQ^gpA'}yBt.FceG+" og:Ym }'D$*, JJ~*m\D({yn_ڈ`dAY1_MYErYySx{˞z7 䟉d"[8ӭŅ@M.YImy+{5<l!0y{d<[pd(I4ʭ֬鰗xߖL# fPt MRJjr/]~ŋpZEZ<!%66i֛9;Ǟ={ÇYk=Y,5}bb!2oA+ca!ˑ Iu%gɖsck)19;RT8ei,'(Ǖ Ysu>W݆ tu*Y @\Yn-zD)XnLzt6Hn',K?}f2z nUOoj2gqmw4odl!p8S &-$:`@"J7Q*o\!munͰwy]ge=ޓY?5J߲0k>L\&=bVO{lv_6 ϪZS7faM3/<aj۞?$ <^?߂~@Dm(}88aN#UЫn=Vࡣ:^WTRQ L ƕJt@.*| G&kHq ̛\`~C "o!+An:cMJHyY{k @~n?8;MBGk,DJ`,w(2"#t!ت6fSݟ5{OnXIX%hMN{|KB\K&w,] * {h4'Mig/ٝ=?༞,mNdT[.Y9P<ΙGѣG_yy,@hƎhP.-ƇL<\6j9bwƑzwx@Xe~ǏW7 !W&̞'sm+;:zzÜC: *նPDJb4v|o@PniH}jh$;*%66 %;wVDO>7{J.sb2SխV{}{ :5eR]ţ \>.cZB)ϟ tY{TuUy#,*.l\Eӊt6U$TEa( v(Y^$HT! , TRKtd066-)/%-vg:~ !TӿB (Qbˣ9bۤP 0݃"e]]ginh'H;jbu[ *QLć~xT)JU{uaZ˾Y8tAEz 4\Z 'uF9  ky @#> 6A{xKP:;a9w.)pH~IRAt:+ug$d Cf ^!;Hp qXS:*ӤJl@$2c3B1>p#~r[)(fÛ鱗.+/N6jq F3[wh܎t/{E륁ԗ8gE!;1-) wIMMAI8x$%M>:DGE+-wNLQK3=p`:Y_ {_,|FcaMܪȢ  B8,lFk/H+C\M~)'rqK6 \"CY_a9ҰLC>=LR1;XX4ԝۉ;š'y5LJ\Ri(;K3GBu+@GY: V ;+"}2ֳ;641<>tL þ}@$ҥ㜸{]{^'4Y#U ߓ^V}8&0 WJ \ DaqSxT{KKn>y1Ӛ&[nIW}!LsK̵8?7q{r80ފhuv&޾[dS$]ԥA8,6$ ذ4S^Jv.@j!DgzV h*gebxb^KCWÊ$xrLҸ+e8#3uGmjiãߎUR6.bAЕ!U g:[u@}qc4&l(*.ټA5~HRrDq&)$BG.l(~Q<,hL) (3+1vp`-AA7XJ :$ f'LsyFB!(v^YZ֧8i3a?bHXmwK,ػ|qR9n9IWn\ۨ"'HzDlAA'nєGz;AX^"';MdiFV1>ɛ+ax]G7ɫ$/9Rp&9.%1gWRz&X}u:Ek|; ~۬C]"2R (*$E^MAx <.?L@B~}xbR!jB4OVSXطdWE\KԮ"KOqX~A7e!8׾r,DJ,Y$d}N[eIU(}gE}6 xcF+"֢[q%hN!*NM)Z%1Gm)Kqy`u_V/u#Z(6*?"y95ł7>>t*ŠN<gW~[zvӦMPTD@2lI@rNIDM5kyblg4dQr`ĝE& f` p9˳ˍE@3hH˓6- iR^.M_܁J#yR|P K`8&"{ /T&9 eZpPȽ/J.Q݋-KH8ZC=؇ RHg'T˛YǕF K? SpPSLuܙv#ʡpTwIթΤμh$k?y)z.!Da]VN1pX&"(Qokc,D*mkEr Gb6ߡR1tKȊp>#+S/ӗ~yR=WlfZ;DJ'P(1 xO/2-` P qZsjcu0L@s0,T!- xͷ#0QjW{ \t9+ ʠ!$ew2S65{{K6@ 2jˀن{r8l'M%X2Ob 11X쑟u1]srNR57hpx;@ڈ%rb "; E? Y{Y U</ 4*C:76[du@X#=`,cndY!z]k]hFP0?AګgYxAXoM8b<ȋ8tR-\ :AX'<[q'if"5T)J^x9BX]XFL3=4䶸ljk9RXn %V6t_-Ҹ*pDj@4x~,^g&rz5znI=%wTMrЮ&ZF 0V!4L蕌|݊¹`R2E%.b'i-Qs\!@ji< (Mw=z!, 8#$7oBVomH2p[XkW`Бՠ=d#5m̂G'nga*TS:Fj;c:TM1"{s҄vEKh!U pA$|"@ U/;řQQq2 mL|d$Y\j@`}jg1껷`|MEl9񴀬YXn,|d@~A@ӰJg!yJTKLs@8aŘ. tƓ[y/>c&D^{4E{ ;SӀHǤ|XX+F|%D?ed`{#phėѽ[[nZ!xȏg6-["""0X1=21@䤅%LymX'x^QGb42DF*`-"f1ٴy:jTB$mR6M)OaQZ;Vq9-%X>QCR21R}T&HȽn weyg;JAJڰ<'+[?ƃduQ1lm^4 >/R*Pp"9uE-.6`ti!wB$qNuz)FJRN`VEQP!b'φ/&SW{2t74dZW=D)_Q𒍑$i7$g! "ܮVoeq%dO -aa Ç~0(4ub3C;X4*!ABVꭨ [5;KUcBHi R)n笰y;$y%M\N-}Q5Dᰑشr<(`o"Ej\{VzgYվ)6݋:#^xB.?j&k]f-[7KK kDVcJR,qYu : '^;D? $/{Tlah2Vw+#`InBX Hr$+L.d.5Q\7B0w؈ȭ{ =8\zQ -+D$b1뱁魉*l0rx5k6ۥqT. xȯfDGM*t@/e&x%m/'A+yWXDV]8ɍ]jաySAkN0NWmxwKDB eOeZ"rvIYB}+aEZpקeU J/ {,4w-AΞB@ զ1D>b'<.!ɋjضf+"%U^q{U^$*) bM^G_O}NL&rCA=H_s 0C`ef:Ts"W=a"Ab&k36L 3i1z,zkj 2l Hkdnk4 pr}-z%GI#tA\uIN6*8g8䑩j-rR GE*3Uh[W,R$.?Jr\|뒵$Z4~1է\we$ZGux^ekq3 tZ&ip! q g}eOf:@B'o(Z.koQsP  eHtkHذubk^ j(gw\6""ib:-FXH$5ǎeJ22<ŗJAM@pd//fDC9D gW Y]~/C:lEK|nud# 3"IUɰ`I:> ]ݪ' cO/o<Dcqqos ofF@Zz!5%Tw5Xxf/>Ds)@ˡ7nc199%Innlnfyq <[Fڛ(Kml.\rä@_*=z$'$0 5`GBF(?gwBAuW8Y`}aLƈO 92}wcq1fcY2Gk%iL b)ym$A7NjTwQ0;r$+[ӎO`e#F$5Rѵdx:F $Hq(XIM:+ׇz{o@%9lljn>W ]{au!9_0f: JDKߞLf@YdȲ a".wBﯗW$?*'}>m =d4Ptd|L6#(TV%yNjiڌ6ڐK7B:[և|4VO"vGyz|cc&yW1"D/ \]pC[ӡU%4n5垬 wXq0/ \R^˫{mvqLTٚ ġ( O0wT%UD33wqGN Z{r!\" MH@^>|CRϏni^Q$4z8Gł=@wc"2O<Z ֒R"T1:'Ee(5@upHh;KKj.y=ͩun~*ɎZ'Ww2Q)yw|:=oLHe}Lŋ[.O^ Sx,]!Q@DlݲEa=4<㯁?p RZK+1kbxv!W8"O\i č y* ^xHKz̵kdnW^Z?_<عӺ4 {; QJj8"3C@^~h@62)22 }ŊM^WxAɑ qmmq,HV <ΠHt)1T,wՠlz!".B^/$8m*6(d-l;e٥ŐUW2/189gyBEE6f_,#;H,5T09'!],D"rUyx!'O Hy7*`?ǷlWEJF;50j\yOҖ44H3:>ghLKʰJ Ɉ&[z%ɻwaLM{`"6;Hk9%ʮ .b*]6 cRHj%pY<+j#qN(m޺ycZZZ.<Հp1w䙿{X !f,bE|yS8 ktgݻA#,h%c7b=KRw=DDgVoʮ swa#K3#FkCBMy%ŪK#S+ bDȋV1zF3mxqn_oV0%M)W-{-4zɇXB\ x^C]G60" tOUsr5 eތ? j0r`L[YGz E}i\QhLaK֖{_,Hg>egRLW[KK3#ҹyKmӑyB7޹_|S4zg%r'm̋VV,Npmc"r;(*nvݥ/n0 Tؤ!{ᴠ AݐWY_ΝR)9ׅB4(;)/56gw1,[x$BYX֖o놷v\(.v̠=eDhT( ;# 4poMu>Mj,67Xpm';KBh=FO#0*~$I5*D,aRC{$\wB֒ˡټ/ʌUki/L׉AYX佻 0VM]']K[G-33ss7nېNjz24Oq!Xo,b;u'D@8!<0=}oZxba#Xn""ziʋ+O/jp/{$0FA wAo 6mlW"/~\[K%vrZ#-A-DHx҂pY*N$|cp}tM$"b!3]w$#y˳wʽ{Sh Z[q-L1nn5Pd% ($gԖGj?yQeFYߩ=&džH,Eoz q@ 5`eC%@밣%5V V[2-|Y tT$2~NKpp!t\ i*x.\}g%/!ܖojWiܿY-jJvDENMNawkr1wgя*A'Ƴ>[>)z' ^@75z%o/,,0f{񪁰gHk{!..{ 'x/ƿƆO=6t1ulFM<)>*IID6**-c%OPH+YY\ _2?Oб?JPtMDܙ Q@xaǫ7 YZ39t`nr+ !;7 R 3e}RK/ʼnm]3AA}e8v(ebXaaC+C*\p4FfZM)mz=+:Pn*/%j'RvIo@CX^<|?%~KE^]iܿxz  ^FHFpxH(" (?@kGgM $W4 p(mbd^)rG-a46A;z=.Y).]V "AXN]Ť2zP"eԅ(-tՁ}x_}@ֿCBa"5`jN,fFҮaӫLD׿KsɠuDJ왛 L@GcPx qC`k&.Jp'kXZ^F~Ӆ~XCz7v/K@B;M4b-Z[l,@)+&+x9{9I&;XpDWo}p׌( ~.L^wGWoV%ik!B^ 9[ Olv\XP튖,P5kqX#&c[y*4\X 0dGDv[_@lk˅d-_FӑGjζl0.Jɴ9,8h-:E%^RE UmqE ! t@bHwZH;}`62g !F90>zq&@V7"EqV+R{xU$ndJSs Vy!S%]La1\"уqg76xe=$uZO6C9X@:@qփB~[A_O_zmͽ7޹&Aed" ,duO<薁`D\#D<J4xm &! /64]B&%LfG']E\-9u/}P3!-QQB(7(09j apԗcs R b6F݆y[vN _c}虤R}B Kaʮ] ZjzC~]VW_#? s.2/yPbXXeU3kt\ =tQt (&S]D8\s…cg 2PgH9bGE)H0saz.ErV❏K~ `hb5LK.FR\HW<=(]vh-=v:0ѭ@"CXRFMtF#Xx%?к^,r! _~iuCwGgf"p0g!wIbZ [/]2xHp< \b1<=Z@!j8Po8{՜O@H24/@񯁑8/o[K];$)'98*1}&y^.pn:'';8Sb{[!ҭ7$+:+H|lz )W^;58".][Ќ" \FD}Ӌ_@Վ gV.]*~B:W+ox,o 62P+-G>wM0ݧ΋+U>֟~ڶ ?#>yn aO b ^{9րibZATTv"V+Ʌ{!t>?3?.p\=r =uiI.[nxD O=`c:j%]^pi4*e:AG3ێec݁DI}^_B/ /3^ntmnl`2%6qH*!1lrgTQdFfgrgC tXD͓Ƃi8Zp2 L8[#;#J~y B@@@cftf}.ۖHy[h[o}]͆V5G0|Z}\R yP)* n攤7"bc;ةx',<~7k&\#5u| 3B^'"⧺CPݑGvpv`ؕՖfLfȃR!J^bZ>%Uig vǨ2j%`s b9|B &YPu׳/}7]_<0πC`"ϙrvuh!i#_1- )k@l/+^ 7%b"PH9vȍL^ x`8&/A\ON`t HvtmYGߎaoCBYXy/XI!`&t$ -!*ML3L.y𕀼Հ /6:p NTfå\f! M~_w_DW9© +0+bJP+eM.?rBSd']8tDG<O?kk?ɃRwUy/}-_[o͇ Z,WUX~WjGaiansJ ya7C7:v Xv-ME=]3xAXx+$U0빚Er{zĜ{6rCq[3{x V$@!/K訔rSՍ,ߓ!.K,7Z.}āS{ )d)ltď) 22SEǍE-a)SE >u^\I!usrB.F ]ht^@z2x^ nab]]3N@1v hq"(H@C.YIR oݼڗ=L=ګ,@lfe4k;e`deєn/rpeQCH6'C&T P$CFI.U]LLxG~ĄKKmJyR ,`O %,^B0Kr0RQE餠zH|OdW7~)!Ç~J8>÷c@Z&¦s5!e"!jw' ̿04 i)r霩GAey$cA>GCƥ6.?@2שH{ULՒ~kDyPB7*pAڼq-ۼ>v, HWǩ:|0n'3/ 9;$/gf?~?`%/xa>7;a SO_BNDX##W X+h(Ll @pAzx '^4 2*S/&у wJyn# 2KKK@nVif ]CPAM. ðS9H ykň,0hb!r 77u,RrO .̩vg"IS8{ X1y^߫()df3u{ !>!zme4ao\ǣ4̈́B8tl.LqP~uן<އ粠o6^|Hq JکM+p9wD]0iQiV0@\CqG"nE@!{,ZPߏAqa!ML{DwBU5Iu.>TswV-X+G|{B>ϙ!x`+njO!/TvYָ<@?B> 2&Dj|9^ܴid/hB3yA$4,p"[J0ؽ6bcƨ/YcG=ׯ{Pc%>0D[TwΥӝ@^R n&zbta~t;@H|A;*)cQ: f$`ΉޕީBPpK_ie)aÇ4xM ]r:q'r>po" &7?-=~UE4Ʃaby$v0M%,w,lN [793wDaĜ8..53&a[}3]s3u[T{msi~+!`k2%Iy:od4$cHNN|cdhp-ڙ7g >8cZǛ VIM+./@P 4 @' f𔎂 ԝS{Eg:0W? &Wjz {9##9U%KQQDXܻj.'*[y $#tL!@œqZȫcb !G/g"&dݶ7q_ʑ4c<=Jj,}tb/-G40<ƍm*D#9C9rl M RN"RpKlV ӽNnyW薅YT(%P5)^#DU +|K( _u5a;HK`@QLpʁѺ^4tw}o<I<^*U8p׉b|2GR,#i.;XܣsyK1 ݸZA0@6cD^ꇙd=G- &W[)"Sa@^mɶ2uމ )֭>] ug܇Ϝ5%@F X ցJ1D #0n5J()ç<(s,)Snbe#e6SEj)T@$Qީb  sKp׀ ⠰#{}މֳtջsG* 4!"l_tƋ̚ҫv!!D DYu=UY-NDمC%'>:QMY21@0l?T/|{?T<~/;]}RZwPml* 4l!/>է s($ TL.XzJ4āЅϰ^ pܶ}ⵎH=b}٪@i CNh0靈 $@Qrʥ[$/;?۷w<|ؒ$\KwUwb l.Ε?gXbȯ}\^V"+;g~_S7ǟf>NZ$8jmȈN@I} HYEYh&(j1 }cHH߀J?}[zm eB"PXKjrԋ˂ %$"Z+sUnR4K驩")yn4RK?G+s\ΪѪ*0Z(nePi|= D;SUVpKu KKwޒ_Y".R*y 96*0"%H3rު-Zl؂|yhjjeQh brq"8Q wQI1kJCBL7ZLӌ<1Kh|&ݙ7?#&ىc"\-I]]gk!^=FJ91cY>+9;z(<=.IL|Y_ٰy&wdIYBdUsiYx@Ael?WfDڌOciI]^%y" r0E-ג&1?Tqk?a`LŐm [ "h)|@IRKHH%(h"CCjURZ,Z|T:t%|}:{x|{/_zݺ5bb dGLlT )z{\Φ2ӎFZ2Ei<yƍJ"NH*wP) SJNtcqj\㏟(C>!.K<&L6FDđ $ fZ2x#"LUh2tE+JH/'O4hT ݜXAj'۶ޏ+^۵vIAvJ;v&f}1:j\ڛY8s>wT{]j7.Huň;x&fһzUd`.2.yWߙ:%6>f?{KIdhXXKe1`O0IÚhlDpQQI?vL4%VaBvm+{؀[= "7^e0-V%B57|Sމ5D=ޛ ,KF6IquWb3]3ViqJ\ؘ$-pZ]zE]$ĜwI#uH42A5WkB=OQ$$4`)jɄphn{ʀx~?G.|9 ϊt;[(gKC직d u$TZ:o_s͵%GgR,gʗO`>D*[.Ii,LҌ/]ܶ.7EdG%;d42E_̅<{Ktïjy ٓlpR"N.R+ki (*eau~>Z{xwH,J̗s {soל$Um??GdA$BH@ 8V(ًm}x>I)bJA_b=qZ. $G$3YWr,Ύ8P䢾bo)ҵ K21O[;94.!aF2'hBmck6=^pOj/ftX'FUJB(oӐ6;ZMeq+2K5E/^Ҷ5<~[Kk/%F&75jůC+C^l՚֜g*gC[O ݛW*Ϟ=R$T5tԍHժ='K5:%DD(HAvK5'NKP_>Ƽ+Nݾ#$Az5R5Yxn:VVtI9x~@,%QJy=$myO"8IF :995j]9 ؝9[õBGK˶W|%%j-ɘȦ-x ϵ/k+`љ0EURՔ<tjU(ubGK;LY?y  .+]m:RRsò./j,׉j)wz6oʊ$mk?fVe;99Epoug#/ˋ:M/)a/įP+WN7N2GO{| qY,.l^ˣIQ&eؕ լmkZ^x/߄WP)[4 aD 圌Y;v;k #NtVV֖Jj E~ثW{S2{]+/W Rd%qBʛI)*">HN;pQLc, dx. _A/tp}UÒr}͚\e+C{ +]/=1 WZ[6n,^պ8;:G;7`j*JS/P _BOG*xTbh(ffg֑IHUԵG~<]5+;px*4*:hk+tl];*c!E67)@J#(x"+R%q}@.:iZmˆppѵXB(P] fee!(˃>Ќ#Wccc||: RUmhIiRXWe%~bڣDŸٜF4k۷ ?@\ӺbC "^[P˅_gߺ":mmM+Idn#k[xud: `8w!*8"^-U;-szd4r2u^"#'aNCg%),utET-'!Cu98ĉMi6= WU}\D(#7B'%?V=O PdK?>/SPَpl5.ɝ88d oH1S61,a+eq:ibVT6UVl 5l.joH<Ҵ@pUJݘ2`(b':p^x>xeH>)?ebsC)ȓ\$KT5 o\q`%<> A~,mP/_'Szhy)92#29" @I B&$i )qˇosGfRWyPWY tr}e,&If4^yy懈8K)Os4|j*GVvK><S(W5ue$H#yÚ y\\Chy||˶ׯ>EHssO6=ϝkm~):aC;-38^ J1s DhmB CJS˯0Z:r /V |]yאx =،N1kaWa׆&&CpdS nA> Vց$xPFwGd 4F <ϣ"Qx .$+.孛7 w" D0(61*&BV "@(rŒ˧N9%w\JCJv9[@{8p}(%[5[<%@@ m#HS(J a"{ @Z^fݏE%]puqv?|=[ 8;79j-Nija?Q^ Ȱ1 =Z[s+鴰Tr̙᜙k<]a9]T@Eniܨa榖,S9"^&+,5ia+l U:㑬=EJ'xV!ql{~,b[*/5l{I|KH.+~M\OrXn0䜔toݺŖ1mJy<.riC:#&`*TTT5)L.Cȉ^aZJAdeqG+)ըY #iX&<D6 oQU'bT|CI(m\1OK'DDK-tf Hq}@Q= 'J* 󈸐ni~dWrlc \y\ "$^3%x\3 *H` TT`Vb/u1wT#`,H*0nS($uDT+HRfut9CuՖi GK=m{ݭy/vȗrD7o%gT6n!f {m8!<o}ʽ^͵1HeuEΖIjlօ$@ YYɒBRW:~5D%qnL.b$uoBpV@t a~VLFyd1Nm6R̭!UU95}s:Xy`'#5Ư~r ֐(:NũxᗪRޑѱj7RJR)FWŦ6)~+/-X@U| (De[scGBլB/ΩIC% F,w7#yzm# ɭF2s۱e Cm"YPCrvFƝ8Bw]  D2jIKRMΩ%bmez)Esr\]\\\\ݝ\\S#0@T9zLҤU<]ボwwf#w--e$j'_g$<+hf|'xŮxlϮD,$jMT``_~2CXp.lC: J$e8V+L*{LzEA-u%@Y.sqrg˳֍Rв2qay2122;*3CLIak:Cؙ#0V-ccX VnW YUe ETR@aU"b(Vk$$M-5+@F\]6nd)وKYY+ IajuGt`N֩x}][ *y$#6( 53洘. n R=c&CX݋2X:Yчjj  c.Kv͵kK&p'k TYm` ȡT!|&JEZWk]]&9;k~7r{ cEJ_*-kڎ~'-5x:^iSULWHݮ@ఀ{T>h% Z#]##38-:, 9je9G*kbsP}%yҠHZ@@p CdRpsXﮭ)|˅{]+eΒp\Flqx}7 L <4jZjS:|&LNۖFnA"): i ,Cbb22"#3s8-!tvv K%%t |/f4 32q->@C$EEL$ 0Wi@ڴQopḘoZo&Bc^*ֶ5-R،,^FUF诺bbuw.X, qL[po9^X":n8QN^AG8}ZHb-reC%" $%A9/)w"zު0.8BCYI)YWr_}1v׆E/d'Fʃ1G\8O!Imf5w#5M?ZŽa8ޯ]t`PP Ggg1H9zmiVN" S4ATE@d_Pv6$qq"b04ȹ ̠H g&$"B<C!4H.1o:$):|?%"$$&112#4i-Ձ/^Omiˋ KnipK>VmiŖAZ߸1oҔn#m|-W|))I:,!'iXX XZ$ajzRXN|F o;#4 J18|o)88$1d2Uwsc( Hޣ"BblLY Mɞ9딿qtanq ҋ{}m6 >h?h<' 5I)> ҮDY+Rt3u BI\mjL@#@2Fe@W>bC.-΍fm&@bnb1~W @e`m "?g"&>C>;o+a!Gz{;j<}_ЋDr% xE$zAR!i?? pu;۠J-6u;x%5,  .6=gׂom&h\X;/~k6g285o[3OҎpchK!088qv@:ccCReA ~+6k?$Ep*:#> |:+΀\UL%}{gh@ReC 7Fc[;=T}cO AbCbd3 _ߥ?dznL W $K^ͧ&~F0`֖,hcTLNlg+GOC]'@ ih@DQ(rWRw)%F#8bj1-? utܺ' -7OO G*x"^Ct6@ #@{ARXJ'%I ,WSໜ=P0vnrJ7dgfƧCBG2|?Mffb{޽7-.{([GQE4̱@w #rutYCC};tB}=^BM,sqMB3 "Y"c(:FRy{@WdfDr Y[d:J@CCw- <"D=b swUFFo\O(2y{xo=B Pzn(K;c\2Bƶ(+`51If "&x}83'* RUUd|z Ck CEMMOkAtg\U(?1MMYԝVGDV.Evkrnwԃ"}@k;HAS s45 #'o6lX2//y֦L&9 oW?މ A<|Y)gdYY{)8 k  Ύz>`3ԋ0 gtW^v!¡8!70%Ԥ&=bk yfwLҀ <]EbŻMS  EwߴXY RڐD_ʹ6<ёq 24]ޡ0$鬧7 RƉCjqoË*v#_;D΋z|eWHbV4""*B0&f2:GO!Gܴ) pĄp&j(r}d_PΌ!"DowOϡa!P.˓t "23XE_8M7'eޱaL!6m3#'_Z"6iiѦDY|8@4)+.芻:>0 "Ȯ;w $>`q:tXԐa2Z=fe>Dɒ@t:ꥀ. "kZWyf$ (dtԛ<Ĥ&&9$&9&.սwG$W3#9%E]")'5Cp]4&U))]0ނ4𻽽cpógi|}+fc.褔O,3zΚeGJx\WAEU;yjrrIIbs\!'.e 9$2;,,:! :@b C Y{ZgB|efc2".h|-՛~x+Ỽ4"tuuŌ>("3\vEo>v\I@a0WNN!()Q˵+kB&M@`O<ΐI"<0ZX{Yb"w $Dz"$7݃c\=u(4G$o )5 K}_X`Xt߅ )J g8Xv{]Ϭ@e?qZⳌdj}0*!ė|寿~qdIw!/ZL9 ÏTOUȊqy('Paz;ۻi@T{|#{fle]yw L0V<~x$#d1'vMUqLuVm U۷Da^^Vʃ!}CAV! `,R:OPdY(|cn|X8Шl/>Fe;<#>d*OCHkXy{oy{[}ɝ;%5C*#Y'AO %,ԋ皂ýL2Ȅ\"gO9Z%q1)ώM3E2qol =ّuz;Adg*h2{:pi֭3F)೮³w7MZߥ=uj]`*" ^?,$dn/ӎL$(|~XYUwvvFfQ "[w5{;Y52?m?_/:yq2Uv3_v7\}A)RsguK*Abqגm9P_̌AAqA!9]w&{/yg~֐g_g"g~woܿg#E25X@~(iǎٙA!ɱٙՓwvw'֭>OO  Ƈ~~*ZS4FeHV5Q+ KilP$D;ޙ-PsKf񫹅w}:07=͍©)% ԃ!⨷gx^؝7s⣂b3s P|m 2ȲSq8DF-$! _-. @Y^fv}gtPݾM]]=|] ]G~zOC7???58x<>Yw΋5̦46n*߳>mT|QKsq?Hn|݇ze Nx> @fyj4y]Ulno?~2I Xo H(/FD`feڼh3_%? "v@@n )Zeattk/Q̢酭ϗh~:&zo!~@4A0NTJ:s W ԍ]z߯Pfy,u9Hx[wmy,_) rL-rU ^|E#&~+=)G!w޷)p_BH׽Sqф?dzZ\X>ܿq}|P\09OۃQA1Wc? %w=_E?A$j æWog ¦JOfooter _styling_' right: "{current_slide} / {total_slides}" height: 5 palette: classes: noice: foreground: red --- First slide === The important bit in this presentation is the **footer at the bottom**. Second slide === _nothing to see here_ presenterm-0.15.1/examples/speaker-notes.md000064400000000000000000000023371046102023000170210ustar 00000000000000Speaker Notes === `presenterm` supports speaker notes. You can use the following HTML comment throughout your presentation markdown file: ```markdown ``` And you can run a separate instance of `presenterm` to view them. Usage === Run the following two commands in separate terminals. The `--publish-speaker-notes` argument will render your actual presentation as normal, without speaker notes: ``` presenterm --publish-speaker-notes examples/speaker-notes.md ``` The `--listen-speaker-notes` argument will render only the speaker notes for the current slide being shown in the actual presentation: ``` presenterm --listen-speaker-notes examples/speaker-notes.md ``` As you change slides in your actual presentation, the speaker notes presentation slide will automatically navigate to the correct slide. presenterm-0.15.1/executors.yaml000064400000000000000000000063511046102023000150060ustar 00000000000000--- bash: filename: script.sh commands: - ["bash", "$pwd/script.sh"] hidden_line_prefix: "/// " c++: filename: snippet.cpp commands: - ["g++", "-std=c++20", "-fdiagnostics-color=always", "$pwd/snippet.cpp", "-o", "$pwd/snippet"] - ["$pwd/snippet"] hidden_line_prefix: "/// " c: filename: snippet.c commands: - ["gcc", "$pwd/snippet.c", "-fdiagnostics-color=always", "-o", "$pwd/snippet"] - ["$pwd/snippet"] hidden_line_prefix: "/// " fish: filename: script.fish commands: - ["fish", "$pwd/script.fish"] hidden_line_prefix: "/// " go: filename: snippet.go environment: GO11MODULE: off commands: - ["go", "run", "$pwd/snippet.go"] hidden_line_prefix: "/// " haskell: filename: snippet.hs commands: - ["runhaskell", "-w", "$pwd/snippet.hs"] java: filename: Snippet.java commands: - ["java", "$pwd/Snippet.java"] hidden_line_prefix: "/// " js: filename: snippet.js commands: - ["node", "$pwd/snippet.js"] hidden_line_prefix: "/// " julia: filename: snippet.jl commands: - ["julia", "$pwd/snippet.jl"] hidden_line_prefix: "/// " jsonnet: filename: snippet.jsonnet commands: - ["jsonnet", "$pwd/snippet.jsonnet"] hidden_line_prefix: "## " kotlin: filename: snippet.kts commands: - ["kotlinc", "-script", "$pwd/snippet.kts"] hidden_line_prefix: "/// " lua: filename: snippet.lua commands: - ["lua", "$pwd/snippet.lua"] nushell: filename: snippet.nu commands: - ["nu", "$pwd/snippet.nu"] perl: filename: snippet.pl commands: - ["perl", "$pwd/snippet.pl"] php: filename: snippet.php commands: - ["php", "-f", "$pwd/snippet.php"] hidden_line_prefix: "/// " python: filename: snippet.py commands: - ["python", "-u", "$pwd/snippet.py"] hidden_line_prefix: "/// " alternative: uv: filename: "snippet.py" commands: - ["uv", "run", "--script", "-q", "$pwd/snippet.py"] r: filename: snippet.R commands: - ["Rscript", "$pwd/snippet.R"] ruby: filename: snippet.rb commands: - ["ruby", "$pwd/snippet.rb"] rust-script: filename: snippet.rs environment: CARGO_TERM_COLOR: "always" commands: - ["rust-script", "--debug", "$pwd/snippet.rs"] hidden_line_prefix: "# " rust: filename: snippet.rs commands: - ["rustc", "--crate-name", "presenterm_snippet", "$pwd/snippet.rs", "-o", "$pwd/snippet", "--color", "always"] - ["$pwd/snippet"] hidden_line_prefix: "# " alternative: rust-script: filename: snippet.rs environment: CARGO_TERM_COLOR: "always" commands: - ["rust-script", "--debug", "$pwd/snippet.rs"] rust-script-pedantic: filename: snippet.rs environment: RUSTFLAGS: "--deny warnings" CARGO_TERM_COLOR: "always" commands: - ["rust-script", "--debug", "$pwd/snippet.rs"] sh: filename: script.sh commands: - ["sh", "$pwd/script.sh"] hidden_line_prefix: "/// " zsh: filename: script.sh commands: - ["zsh", "$pwd/script.sh"] hidden_line_prefix: "/// " csharp: filename: snippet.cs commands: - ["dotnet-script", "$pwd/snippet.cs"] hidden_line_prefix: "/// " fsharp: filename: snippet.fsx commands: - ["dotnet", "fsi", "$pwd/snippet.fsx"] hidden_line_prefix: "/// " presenterm-0.15.1/flake.lock000064400000000000000000000151321046102023000140320ustar 00000000000000{ "nodes": { "android-nixpkgs": { "inputs": { "devshell": "devshell", "flake-utils": "flake-utils_2", "nixpkgs": [ "flakebox", "nixpkgs" ] }, "locked": { "lastModified": 1732566114, "narHash": "sha256-rgFf/qq7vR4KKRfZ55jnWN+d7WjE8Qhz6BYywsbD79w=", "owner": "tadfisher", "repo": "android-nixpkgs", "rev": "4ecd9e0da0a955a180a429196f59a8716d1dd138", "type": "github" }, "original": { "owner": "tadfisher", "repo": "android-nixpkgs", "rev": "4ecd9e0da0a955a180a429196f59a8716d1dd138", "type": "github" } }, "crane": { "locked": { "lastModified": 1739936662, "narHash": "sha256-x4syUjNUuRblR07nDPeLDP7DpphaBVbUaSoeZkFbGSk=", "owner": "ipetkov", "repo": "crane", "rev": "19de14aaeb869287647d9461cbd389187d8ecdb7", "type": "github" }, "original": { "owner": "ipetkov", "repo": "crane", "rev": "19de14aaeb869287647d9461cbd389187d8ecdb7", "type": "github" } }, "devshell": { "inputs": { "nixpkgs": [ "flakebox", "android-nixpkgs", "nixpkgs" ] }, "locked": { "lastModified": 1728330715, "narHash": "sha256-xRJ2nPOXb//u1jaBnDP56M7v5ldavjbtR6lfGqSvcKg=", "owner": "numtide", "repo": "devshell", "rev": "dd6b80932022cea34a019e2bb32f6fa9e494dfef", "type": "github" }, "original": { "owner": "numtide", "repo": "devshell", "type": "github" } }, "fenix": { "inputs": { "nixpkgs": [ "flakebox", "nixpkgs" ], "rust-analyzer-src": "rust-analyzer-src" }, "locked": { "lastModified": 1744958318, "narHash": "sha256-L0a9BKIgHAD9mqum0VoXjBUDwnCV16/Q1AQg3a8cEnw=", "owner": "nix-community", "repo": "fenix", "rev": "4cc256372df88f061c5156b8ca4ed6d5b01fb1a7", "type": "github" }, "original": { "owner": "nix-community", "repo": "fenix", "type": "github" } }, "flake-utils": { "inputs": { "systems": "systems" }, "locked": { "lastModified": 1731533236, "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { "owner": "numtide", "repo": "flake-utils", "type": "github" } }, "flake-utils_2": { "inputs": { "systems": "systems_2" }, "locked": { "lastModified": 1731533236, "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { "owner": "numtide", "repo": "flake-utils", "type": "github" } }, "flake-utils_3": { "inputs": { "systems": [ "flakebox", "systems" ] }, "locked": { "lastModified": 1731533236, "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { "owner": "numtide", "repo": "flake-utils", "type": "github" } }, "flakebox": { "inputs": { "android-nixpkgs": "android-nixpkgs", "crane": "crane", "fenix": "fenix", "flake-utils": "flake-utils_3", "nixpkgs": "nixpkgs", "systems": "systems_3" }, "locked": { "lastModified": 1747060781, "narHash": "sha256-O1JJkZihr6cQ1E4k9HzDG3Dc3eIBrhnKvkYfvhAogPg=", "owner": "rustshop", "repo": "flakebox", "rev": "47f7fe6aa0951ee984800f3e339c0c54f4a4e862", "type": "github" }, "original": { "owner": "rustshop", "repo": "flakebox", "rev": "47f7fe6aa0951ee984800f3e339c0c54f4a4e862", "type": "github" } }, "nixpkgs": { "locked": { "lastModified": 1744440957, "narHash": "sha256-FHlSkNqFmPxPJvy+6fNLaNeWnF1lZSgqVCl/eWaJRc4=", "owner": "nixos", "repo": "nixpkgs", "rev": "26d499fc9f1d567283d5d56fcf367edd815dba1d", "type": "github" }, "original": { "owner": "nixos", "ref": "nixos-24.11", "repo": "nixpkgs", "type": "github" } }, "root": { "inputs": { "flake-utils": "flake-utils", "flakebox": "flakebox" } }, "rust-analyzer-src": { "flake": false, "locked": { "lastModified": 1744878314, "narHash": "sha256-iPHZkar3ebiF0rT6VLorSXIQCG7kAOmAsfuTahCzgS8=", "owner": "rust-lang", "repo": "rust-analyzer", "rev": "ed737b545e8db5d9c78fcaba73baed0f34e5b3f8", "type": "github" }, "original": { "owner": "rust-lang", "ref": "nightly", "repo": "rust-analyzer", "type": "github" } }, "systems": { "locked": { "lastModified": 1681028828, "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", "owner": "nix-systems", "repo": "default", "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", "type": "github" }, "original": { "owner": "nix-systems", "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" } } }, "root": "root", "version": 7 } presenterm-0.15.1/flake.nix000064400000000000000000000026701046102023000137030ustar 00000000000000{ description = "A terminal slideshow tool"; inputs = { flakebox = { url = "github:rustshop/flakebox?rev=47f7fe6aa0951ee984800f3e339c0c54f4a4e862"; }; flake-utils.url = "github:numtide/flake-utils"; }; outputs = { self, flake-utils, flakebox }: flake-utils.lib.eachDefaultSystem (system: let projectName = "presenterm"; flakeboxLib = flakebox.lib.${system} { config = { github.ci.buildOutputs = [ ".#ci.${projectName}" ]; }; }; buildPaths = [ "build.rs" "Cargo.toml" "Cargo.lock" ".cargo" "src" "themes" "bat" "executors.yaml" ]; buildSrc = flakeboxLib.filterSubPaths { root = builtins.path { name = projectName; path = ./.; }; paths = buildPaths; }; multiBuild = (flakeboxLib.craneMultiBuild { }) (craneLib': let craneLib = (craneLib'.overrideArgs { pname = projectName; src = buildSrc; nativeBuildInputs = [ ]; }); in { ${projectName} = craneLib.buildPackage { }; }); in { packages.default = multiBuild.${projectName}; legacyPackages = multiBuild; devShells = flakeboxLib.mkShells { }; } ); } presenterm-0.15.1/rustfmt.toml000064400000000000000000000002161046102023000144740ustar 00000000000000version = "Two" unstable_features = true use_small_heuristics = "Max" max_width = 120 imports_granularity = "Crate" normalize_comments = true presenterm-0.15.1/scripts/parse-changelog.sh000075500000000000000000000011551046102023000171630ustar 00000000000000#!/usr/bin/env bash set -e script_dir=$(dirname "$0") root_dir="${script_dir}/../" if [ $# -ne 1 ]; then echo "Usage: $0 " exit 1 fi version=$1 changelog="${root_dir}/CHANGELOG.md" if ! grep "^# ${version}" "$changelog" >/dev/null; then echo "Version ${version} not found in changelog" exit 1 fi releases=$(grep -e "^# " -n "$changelog") version_line=$(echo "$releases" | grep "$version" | cut -d : -f 1) next_line=$(echo "$releases" | grep "$version" -A 1 -m 1 | tail -n 1 | cut -d : -f 1) let next_line=("$next_line" - 1) sed -n "${version_line},${next_line}p" "$changelog" | tail -n +3 presenterm-0.15.1/scripts/test-pdf-generation.sh000075500000000000000000000011671046102023000200060ustar 00000000000000#!/bin/bash set -e script_dir=$(dirname "$0") root_dir=$(realpath "${script_dir}/../") echo "Creating python env" env_dir=$(mktemp -d) trap 'rm -rf "${env_dir}"' EXIT python -mvenv "${env_dir}/pyenv" source "${env_dir}/pyenv/bin/activate" echo "Installing presenterm-export==0.2.0" pip install presenterm-export echo "Running presenterm..." rm -f "${root_dir}/examples/demo.pdf" cargo run -q -- --export-pdf "${root_dir}/examples/demo.md" if test -f "${root_dir}/examples/demo.pdf"; then echo "PDF file created successfully" rm -f "${root_dir}/examples/demo.pdf" else echo "PDF file does not exist" exit 1 fi presenterm-0.15.1/scripts/validate-config-file-schema.sh000075500000000000000000000006611046102023000213340ustar 00000000000000#!/bin/bash set -euo pipefail script_dir=$(dirname "$0") root_dir="${script_dir}/../" current_schema=$(mktemp) cargo run --features json-schema -q -- --generate-config-file-schema >"$current_schema" diff=$(diff --color=always -u "${root_dir}/config-file-schema.json" "$current_schema") if [ $? -ne 0 ]; then echo "Config file JSON schema differs:" echo "$diff" exit 1 else echo "Config file JSON schema is up to date" fi presenterm-0.15.1/src/code/execute.rs000064400000000000000000000345541046102023000156200ustar 00000000000000//! Code execution. use super::snippet::{SnippetExecutorSpec, SnippetRepr}; use crate::{ code::snippet::{Snippet, SnippetLanguage}, config::{LanguageSnippetExecutionConfig, SnippetExecutorConfig}, }; use once_cell::sync::Lazy; use os_pipe::PipeReader; use std::{ collections::{BTreeMap, HashMap}, fmt::{self, Debug}, fs::File, io::{self, BufRead, BufReader, Read, Write}, path::{Path, PathBuf}, process::{self, Child, Stdio}, sync::{Arc, Mutex}, thread, }; use tempfile::TempDir; static EXECUTORS: Lazy> = Lazy::new(|| serde_yaml::from_slice(include_bytes!("../../executors.yaml")).expect("executors.yaml is broken")); /// Allows executing code. pub struct SnippetExecutor { executors: BTreeMap, cwd: PathBuf, } impl SnippetExecutor { pub fn new( custom_executors: BTreeMap, cwd: PathBuf, ) -> Result { let mut executors = EXECUTORS.clone(); executors.extend(custom_executors); for (language, config) in &executors { Self::validate_executor_config(language, &config.executor)?; for alternative in config.alternative.values() { Self::validate_executor_config(language, alternative)?; } } Ok(Self { executors, cwd }) } pub(crate) fn language_executor( &self, language: &SnippetLanguage, spec: &SnippetExecutorSpec, ) -> Result { let language_config = self .executors .get(language) .ok_or_else(|| UnsupportedExecution(language.clone(), "no executors found".into()))?; let config = match spec { SnippetExecutorSpec::Default => language_config.executor.clone(), SnippetExecutorSpec::Alternative(name) => { language_config.alternative.get(name).cloned().ok_or_else(|| { UnsupportedExecution(language.clone(), format!("alternative executor '{name}' is not defined")) })? } }; Ok(LanguageSnippetExecutor { hidden_line_prefix: language_config.hidden_line_prefix.clone(), config, cwd: self.cwd.clone(), }) } pub(crate) fn hidden_line_prefix(&self, language: &SnippetLanguage) -> Option<&str> { self.executors.get(language).and_then(|lang| lang.hidden_line_prefix.as_deref()) } fn validate_executor_config( language: &SnippetLanguage, executor: &SnippetExecutorConfig, ) -> Result<(), InvalidSnippetConfig> { if executor.filename.is_empty() { return Err(InvalidSnippetConfig(language.clone(), "filename is empty")); } if executor.commands.is_empty() { return Err(InvalidSnippetConfig(language.clone(), "no commands given")); } for command in &executor.commands { if command.is_empty() { return Err(InvalidSnippetConfig(language.clone(), "empty command given")); } } Ok(()) } } impl Default for SnippetExecutor { fn default() -> Self { Self::new(Default::default(), PathBuf::from("./")).expect("initialization failed") } } impl Debug for SnippetExecutor { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "SnippetExecutor {{ .. }}") } } #[derive(Clone, Debug)] pub(crate) struct LanguageSnippetExecutor { hidden_line_prefix: Option, config: SnippetExecutorConfig, cwd: PathBuf, } impl LanguageSnippetExecutor { /// Execute a piece of code asynchronously. pub(crate) fn execute_async(&self, snippet: &Snippet) -> Result { let script_dir = self.write_snippet(snippet)?; let state: Arc> = Default::default(); let output_type = match snippet.attributes.representation { SnippetRepr::Image => OutputType::Binary, _ => OutputType::Lines, }; let reader_handle = CommandsRunner::spawn( state.clone(), script_dir, self.config.commands.clone(), self.config.environment.clone(), self.cwd.clone(), output_type, ); let handle = ExecutionHandle { state, reader_handle }; Ok(handle) } /// Executes a piece of code synchronously. pub(crate) fn execute_sync(&self, snippet: &Snippet) -> Result<(), CodeExecuteError> { let script_dir = self.write_snippet(snippet)?; let script_dir_path = script_dir.path().to_string_lossy(); for mut commands in self.config.commands.clone() { for command in &mut commands { *command = command.replace("$pwd", &script_dir_path); } let (command, args) = commands.split_first().expect("no commands"); let child = process::Command::new(command) .args(args) .envs(&self.config.environment) .current_dir(&self.cwd) .stderr(Stdio::piped()) .spawn() .map_err(|e| CodeExecuteError::SpawnProcess(command.clone(), e))?; let output = child.wait_with_output().map_err(CodeExecuteError::Waiting)?; if !output.status.success() { let error = String::from_utf8_lossy(&output.stderr).to_string(); return Err(CodeExecuteError::Running(error)); } } Ok(()) } fn write_snippet(&self, snippet: &Snippet) -> Result { let hide_prefix = self.hidden_line_prefix.as_deref(); let code = snippet.executable_contents(hide_prefix); let script_dir = tempfile::Builder::default().prefix(".presenterm").tempdir().map_err(CodeExecuteError::TempDir)?; let snippet_path = script_dir.path().join(&self.config.filename); let mut snippet_file = File::create(snippet_path).map_err(CodeExecuteError::TempDir)?; snippet_file.write_all(code.as_bytes()).map_err(CodeExecuteError::TempDir)?; Ok(script_dir) } } /// An invalid executor was found. #[derive(thiserror::Error, Debug)] #[error("invalid snippet execution for '{0:?}': {1}")] pub struct InvalidSnippetConfig(SnippetLanguage, &'static str); /// Execution for a language is unsupported. #[derive(thiserror::Error, Debug)] #[error("cannot execute code for '{0:?}': {1}")] pub struct UnsupportedExecution(SnippetLanguage, String); /// An error during the execution of some code. #[derive(thiserror::Error, Debug)] pub(crate) enum CodeExecuteError { #[error("error creating temporary directory: {0}")] TempDir(io::Error), #[error("error spawning process '{0}': {1}")] SpawnProcess(String, io::Error), #[error("error creating pipe: {0}")] Pipe(io::Error), #[error("error waiting for process to run: {0}")] Waiting(io::Error), #[error("error running process: {0}")] Running(String), } /// A handle for the execution of a piece of code. #[derive(Debug)] pub(crate) struct ExecutionHandle { pub(crate) state: Arc>, #[allow(dead_code)] reader_handle: thread::JoinHandle<()>, } /// Consumes the output of a process and stores it in a shared state. struct CommandsRunner { state: Arc>, script_directory: TempDir, } impl CommandsRunner { fn spawn( state: Arc>, script_directory: TempDir, commands: Vec>, env: HashMap, cwd: PathBuf, output_type: OutputType, ) -> thread::JoinHandle<()> { let reader = Self { state, script_directory }; thread::spawn(move || reader.run(commands, env, cwd, output_type)) } fn run(self, commands: Vec>, env: HashMap, cwd: PathBuf, output_type: OutputType) { let mut last_result = true; for command in commands { last_result = self.run_command(command, &env, &cwd, output_type); if !last_result { break; } } let status = match last_result { true => ProcessStatus::Success, false => ProcessStatus::Failure, }; self.state.lock().unwrap().status = status; } fn run_command( &self, command: Vec, env: &HashMap, cwd: &Path, output_type: OutputType, ) -> bool { let (mut child, reader) = match self.launch_process(command, env, cwd) { Ok(inner) => inner, Err(e) => { let mut state = self.state.lock().unwrap(); state.status = ProcessStatus::Failure; state.output.extend(e.to_string().into_bytes()); return false; } }; let _ = Self::process_output(self.state.clone(), reader, output_type); match child.wait() { Ok(code) => code.success(), _ => false, } } fn launch_process( &self, mut commands: Vec, env: &HashMap, cwd: &Path, ) -> Result<(Child, PipeReader), CodeExecuteError> { let (reader, writer) = os_pipe::pipe().map_err(CodeExecuteError::Pipe)?; let writer_clone = writer.try_clone().map_err(CodeExecuteError::Pipe)?; let script_dir = self.script_directory.path().to_string_lossy(); for command in &mut commands { *command = command.replace("$pwd", &script_dir); } let (command, args) = commands.split_first().expect("no commands"); let child = process::Command::new(command) .args(args) .envs(env) .current_dir(cwd) .stdin(Stdio::null()) .stdout(writer) .stderr(writer_clone) .spawn() .map_err(|e| CodeExecuteError::SpawnProcess(command.clone(), e))?; Ok((child, reader)) } fn process_output( state: Arc>, mut reader: os_pipe::PipeReader, output_type: OutputType, ) -> io::Result<()> { match output_type { OutputType::Lines => { let reader = BufReader::new(reader); for line in reader.lines() { let mut state = state.lock().unwrap(); state.output.extend(line?.into_bytes()); state.output.push(b'\n'); } Ok(()) } OutputType::Binary => { let mut buffer = Vec::new(); reader.read_to_end(&mut buffer)?; state.lock().unwrap().output.extend(buffer); Ok(()) } } } } #[derive(Clone, Copy)] enum OutputType { Lines, Binary, } /// The state of the execution of a process. #[derive(Clone, Default, Debug)] pub(crate) struct ExecutionState { pub(crate) output: Vec, pub(crate) status: ProcessStatus, } /// The status of a process. #[derive(Clone, Debug, Default)] pub(crate) enum ProcessStatus { #[default] Running, Success, Failure, } impl ProcessStatus { /// Check whether the underlying process is finished. pub(crate) fn is_finished(&self) -> bool { matches!(self, ProcessStatus::Success | ProcessStatus::Failure) } } #[cfg(test)] mod test { use super::*; use crate::code::snippet::{SnippetAttributes, SnippetExec}; #[test] fn shell_code_execution() { let contents = r" echo 'hello world' echo 'bye'" .into(); let snippet = Snippet { contents, language: SnippetLanguage::Shell, attributes: SnippetAttributes { execution: SnippetExec::Exec(Default::default()), ..Default::default() }, }; let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); let handle = executor.execute_async(&snippet).expect("execution failed"); let state = loop { let state = handle.state.lock().unwrap(); if state.status.is_finished() { break state; } }; let expected = b"hello world\nbye\n"; assert_eq!(state.output, expected); } #[test] fn shell_code_execution_captures_stderr() { let contents = r" echo 'This message redirects to stderr' >&2 echo 'hello world' " .into(); let snippet = Snippet { contents, language: SnippetLanguage::Shell, attributes: SnippetAttributes { execution: SnippetExec::Exec(Default::default()), ..Default::default() }, }; let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); let handle = executor.execute_async(&snippet).expect("execution failed"); let state = loop { let state = handle.state.lock().unwrap(); if state.status.is_finished() { break state; } }; let expected = b"This message redirects to stderr\nhello world\n"; assert_eq!(state.output, expected); } #[test] fn shell_code_execution_executes_hidden_lines() { let contents = r" /// echo 'this line was hidden' /// echo 'this line was hidden and contains another prefix /// ' echo 'hello world' " .into(); let snippet = Snippet { contents, language: SnippetLanguage::Shell, attributes: SnippetAttributes { execution: SnippetExec::Exec(Default::default()), ..Default::default() }, }; let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); let handle = executor.execute_async(&snippet).expect("execution failed"); let state = loop { let state = handle.state.lock().unwrap(); if state.status.is_finished() { break state; } }; let expected = b"this line was hidden\nthis line was hidden and contains another prefix /// \nhello world\n"; assert_eq!(state.output, expected); } #[test] fn built_in_executors() { SnippetExecutor::new(Default::default(), PathBuf::from("./")).expect("invalid default executors"); } } presenterm-0.15.1/src/code/highlighting.rs000064400000000000000000000233421046102023000166140ustar 00000000000000use crate::{ code::snippet::SnippetLanguage, markdown::{ elements::{Line, Text}, text_style::{Color, TextStyle}, }, theme::CodeBlockStyle, }; use flate2::read::ZlibDecoder; use once_cell::sync::Lazy; use serde::Deserialize; use std::{cell::RefCell, collections::BTreeMap, fs, path::Path, rc::Rc}; use syntect::{ LoadingError, easy::HighlightLines, highlighting::{Style, Theme, ThemeSet}, parsing::SyntaxSet, }; static SYNTAX_SET: Lazy = Lazy::new(|| { let contents = include_bytes!("../../bat/syntaxes.bin"); bincode::deserialize(contents).expect("syntaxes are broken") }); static BAT_THEMES: Lazy = Lazy::new(|| { let contents = include_bytes!("../../bat/themes.bin"); let theme_set: LazyThemeSet = bincode::deserialize(contents).expect("syntaxes are broken"); theme_set }); // This structure mimic's `bat`'s serialized theme set's. #[derive(Debug, Deserialize)] struct LazyThemeSet { serialized_themes: BTreeMap>, } pub struct HighlightThemeSet { themes: RefCell>>, } impl HighlightThemeSet { /// Construct a new highlighter using the given [syntect] theme name. pub fn load_by_name(&self, name: &str) -> Option { let mut themes = self.themes.borrow_mut(); // Check if we already loaded this one. if let Some(theme) = themes.get(name).cloned() { Some(SnippetHighlighter { theme }) } // Otherwise try to deserialize it from bat's themes else if let Some(theme) = self.deserialize_bat_theme(name) { themes.insert(name.into(), theme.clone()); Some(SnippetHighlighter { theme }) } else { None } } /// Register all highlighting themes in the given directory. pub fn register_from_directory>(&mut self, path: P) -> Result<(), LoadingError> { let Ok(metadata) = fs::metadata(&path) else { return Ok(()); }; if !metadata.is_dir() { return Ok(()); } let themes = ThemeSet::load_from_folder(path)?; let themes = themes.themes.into_iter().map(|(name, theme)| (name, Rc::new(theme))); self.themes.borrow_mut().extend(themes); Ok(()) } fn deserialize_bat_theme(&self, name: &str) -> Option> { let serialized = BAT_THEMES.serialized_themes.get(name)?; let decoded: Theme = bincode::deserialize_from(ZlibDecoder::new(serialized.as_slice())).ok()?; let decoded = Rc::new(decoded); Some(decoded) } } impl Default for HighlightThemeSet { fn default() -> Self { let themes = ThemeSet::load_defaults(); let themes = themes.themes.into_iter().map(|(name, theme)| (name, Rc::new(theme))).collect(); Self { themes: RefCell::new(themes) } } } /// A snippet highlighter. #[derive(Clone)] pub(crate) struct SnippetHighlighter { theme: Rc, } impl SnippetHighlighter { /// Create a highlighter for a specific language. pub(crate) fn language_highlighter(&self, language: &SnippetLanguage) -> LanguageHighlighter { let extension = Self::language_extension(language); let syntax = SYNTAX_SET.find_syntax_by_extension(extension).unwrap(); let highlighter = HighlightLines::new(syntax, &self.theme); LanguageHighlighter { highlighter } } fn language_extension(language: &SnippetLanguage) -> &'static str { use SnippetLanguage::*; match language { Ada => "adb", Asp => "asa", Awk => "awk", Bash => "sh", BatchFile => "bat", C => "c", CMake => "cmake", CSharp => "cs", Clojure => "clj", Cpp => "cpp", Crontab => "crontab", Css => "css", D2 => "txt", DLang => "d", Diff => "diff", Docker => "Dockerfile", Dotenv => "env", Elixir => "ex", Elm => "elm", Erlang => "erl", File => "txt", Fish => "fish", FSharp => "fsx", Go => "go", GraphQL => "graphql", Haskell => "hs", Html => "html", Java => "java", JavaScript => "js", Json => "json", Jsonnet => "jsonnet", Julia => "jl", Kotlin => "kt", Latex => "tex", Lua => "lua", Makefile => "make", Markdown => "md", Mermaid => "txt", Nix => "nix", Nushell => "txt", OCaml => "ml", Perl => "pl", Php => "php", Protobuf => "proto", Puppet => "pp", Python => "py", R => "r", Racket => "rkt", Ruby => "rb", Rust => "rs", RustScript => "rs", Scala => "scala", Shell => "sh", Sql => "sql", Swift => "swift", Svelte => "svelte", Tcl => "tcl", Terraform => "tf", Toml => "toml", TypeScript => "ts", Typst => "txt", // default to plain text so we get the same look&feel Unknown(_) => "txt", Verilog => "v", Vue => "vue", Xml => "xml", Yaml => "yaml", Zsh => "sh", Zig => "zig", } } } impl Default for SnippetHighlighter { fn default() -> Self { let themes = HighlightThemeSet::default(); themes.load_by_name("base16-eighties.dark").expect("default theme not found") } } pub(crate) struct LanguageHighlighter<'a> { highlighter: HighlightLines<'a>, } impl LanguageHighlighter<'_> { pub(crate) fn highlight_line(&mut self, line: &str, block_style: &CodeBlockStyle) -> Line { self.style_line(line, block_style) } pub(crate) fn style_line(&mut self, line: &str, block_style: &CodeBlockStyle) -> Line { let texts: Vec<_> = self .highlighter .highlight_line(line, &SYNTAX_SET) .unwrap() .into_iter() .map(|(style, tokens)| StyledTokens::new(style, tokens, block_style).apply_style()) .collect(); Line(texts) } } pub(crate) struct StyledTokens<'a> { pub(crate) style: TextStyle, pub(crate) tokens: &'a str, } impl<'a> StyledTokens<'a> { pub(crate) fn new(style: Style, tokens: &'a str, block_style: &CodeBlockStyle) -> Self { let has_background = block_style.background; let background = has_background.then_some(parse_color(style.background)).flatten(); let foreground = parse_color(style.foreground); let mut style = TextStyle::default(); style.colors.background = background; style.colors.foreground = foreground; Self { style, tokens } } pub(crate) fn apply_style(&self) -> Text { let text: String = self.tokens.split('\n').collect(); Text::new(text, self.style) } } /// A theme could not be found. #[derive(Debug, thiserror::Error)] #[error("theme not found")] pub struct ThemeNotFound; // This code has been adapted from bat's: https://github.com/sharkdp/bat fn parse_color(color: syntect::highlighting::Color) -> Option { if color.a == 0 { Some(match color.r { 0x00 => Color::Black, 0x01 => Color::DarkRed, 0x02 => Color::DarkGreen, 0x03 => Color::DarkYellow, 0x04 => Color::DarkBlue, 0x05 => Color::DarkMagenta, 0x06 => Color::DarkCyan, 0x07 => Color::Grey, n => Color::from_ansi(n)?, }) } else if color.a == 1 { None } else { Some(Color::new(color.r, color.g, color.b)) } } #[cfg(test)] mod test { use super::*; use strum::IntoEnumIterator; use tempfile::tempdir; #[test] fn language_extensions_exist() { for language in SnippetLanguage::iter() { let extension = SnippetHighlighter::language_extension(&language); let syntax = SYNTAX_SET.find_syntax_by_extension(extension); assert!(syntax.is_some(), "extension {extension} for {language:?} not found"); } } #[test] fn default_highlighter() { SnippetHighlighter::default(); } #[test] fn load_custom() { let directory = tempdir().expect("creating tempdir"); // A minimalistic .tmTheme theme. let theme = r#" potato Example Color Scheme settings settings "#; fs::write(directory.path().join("potato.tmTheme"), theme).expect("writing theme"); let mut themes = HighlightThemeSet::default(); themes.register_from_directory(directory.path()).expect("loading themes"); assert!(themes.load_by_name("potato").is_some()); } #[test] fn register_from_missing_directory() { let mut themes = HighlightThemeSet::default(); let result = themes.register_from_directory("/tmp/presenterm/8ee2027983915ec78acc45027d874316"); result.expect("loading failed"); } #[test] fn default_themes() { let themes = HighlightThemeSet::default(); // This is a bat theme assert!(themes.load_by_name("GitHub").is_some()); // This is a default syntect theme assert!(themes.load_by_name("InspiredGitHub").is_some()); } } presenterm-0.15.1/src/code/mod.rs000064400000000000000000000001451046102023000147220ustar 00000000000000pub(crate) mod execute; pub(crate) mod highlighting; pub(crate) mod padding; pub(crate) mod snippet; presenterm-0.15.1/src/code/padding.rs000064400000000000000000000023711046102023000155540ustar 00000000000000use std::iter; pub(crate) struct NumberPadder { width: usize, } impl NumberPadder { pub(crate) fn new(upper_bound: usize) -> Self { let width = upper_bound.checked_ilog10().map(|log| log as usize + 1).unwrap_or_default(); Self { width } } pub(crate) fn pad_right(&self, number: usize) -> String { let line_number_width = number.ilog10() as usize + 1; let number_padding = self.width - line_number_width; let mut output = String::with_capacity(self.width); output.extend(iter::repeat_n(' ', number_padding)); output.push_str(&number.to_string()); output } } #[cfg(test)] mod test { use super::*; use rstest::rstest; #[rstest] #[case(&[1, 2], &["1", "2"])] #[case(&[1, 9], &["1", "9"])] #[case(&[1, 10], &[" 1", "10"])] #[case(&[1, 10, 100], &[" 1", " 10", "100"])] fn right_padding(#[case] numbers: &[usize], #[case] expected: &[&str]) { let max = numbers.iter().max().expect("no numbers"); let padder = NumberPadder::new(*max); let rendered: Vec<_> = numbers.iter().map(|n| padder.pad_right(*n)).collect(); assert_eq!(rendered, expected); } #[test] fn zero_count() { NumberPadder::new(0); } } presenterm-0.15.1/src/code/snippet.rs000064400000000000000000000757121046102023000156410ustar 00000000000000use super::{ highlighting::{LanguageHighlighter, StyledTokens}, padding::NumberPadder, }; use crate::{ markdown::{ elements::{Percent, PercentParseError}, text::{WeightedLine, WeightedText}, text_style::{Color, TextStyle}, }, presentation::ChunkMutator, render::{ operation::{AsRenderOperations, BlockLine, RenderOperation}, properties::WindowSize, }, theme::{Alignment, CodeBlockStyle}, }; use serde::Deserialize; use std::{cell::RefCell, convert::Infallible, fmt::Write, ops::Range, path::PathBuf, rc::Rc, str::FromStr}; use strum::{EnumDiscriminants, EnumIter}; use unicode_width::UnicodeWidthStr; pub(crate) struct SnippetSplitter<'a> { style: &'a CodeBlockStyle, hidden_line_prefix: Option<&'a str>, } impl<'a> SnippetSplitter<'a> { pub(crate) fn new(style: &'a CodeBlockStyle, hidden_line_prefix: Option<&'a str>) -> Self { Self { style, hidden_line_prefix } } pub(crate) fn split(&self, code: &Snippet) -> Vec { let mut lines = Vec::new(); let horizontal_padding = self.style.padding.horizontal; let vertical_padding = self.style.padding.vertical; if vertical_padding > 0 { lines.push(SnippetLine::empty()); } self.push_lines(code, horizontal_padding, &mut lines); if vertical_padding > 0 { lines.push(SnippetLine::empty()); } lines } fn push_lines(&self, code: &Snippet, horizontal_padding: u8, lines: &mut Vec) { if code.contents.is_empty() { return; } let padding = " ".repeat(horizontal_padding as usize); let padder = NumberPadder::new(code.visible_lines(self.hidden_line_prefix).count()); for (index, line) in code.visible_lines(self.hidden_line_prefix).enumerate() { let mut line = line.replace('\t', " "); let mut prefix = padding.clone(); if code.attributes.line_numbers { let line_number = index + 1; prefix.push_str(&padder.pad_right(line_number)); prefix.push(' '); } line.push('\n'); let line_number = Some(index as u16 + 1); lines.push(SnippetLine { prefix, code: line, right_padding_length: padding.len() as u16, line_number }); } } } pub(crate) struct SnippetLine { pub(crate) prefix: String, pub(crate) code: String, pub(crate) right_padding_length: u16, pub(crate) line_number: Option, } impl SnippetLine { pub(crate) fn empty() -> Self { Self { prefix: String::new(), code: "\n".into(), right_padding_length: 0, line_number: None } } pub(crate) fn width(&self) -> usize { self.prefix.width() + self.code.width() + self.right_padding_length as usize } pub(crate) fn highlight( &self, code_highlighter: &mut LanguageHighlighter, block_style: &CodeBlockStyle, font_size: u8, ) -> WeightedLine { let mut line = code_highlighter.highlight_line(&self.code, block_style); line.apply_style(&TextStyle::default().size(font_size)); line.into() } pub(crate) fn dim(&self, dim_style: &TextStyle) -> WeightedLine { let output = vec![StyledTokens { style: *dim_style, tokens: &self.code }.apply_style()]; output.into() } pub(crate) fn dim_prefix(&self, dim_style: &TextStyle) -> WeightedText { let text = StyledTokens { style: *dim_style, tokens: &self.prefix }.apply_style(); text.into() } } #[derive(Debug)] pub(crate) struct HighlightContext { pub(crate) groups: Vec, pub(crate) current: usize, pub(crate) block_length: u16, pub(crate) alignment: Alignment, } #[derive(Debug)] pub(crate) struct HighlightedLine { pub(crate) prefix: WeightedText, pub(crate) right_padding_length: u16, pub(crate) highlighted: WeightedLine, pub(crate) not_highlighted: WeightedLine, pub(crate) line_number: Option, pub(crate) context: Rc>, pub(crate) block_color: Option, } impl AsRenderOperations for HighlightedLine { fn as_render_operations(&self, _: &WindowSize) -> Vec { let context = self.context.borrow(); let group = &context.groups[context.current]; let needs_highlight = self.line_number.map(|number| group.contains(number)).unwrap_or_default(); // TODO: Cow? let text = match needs_highlight { true => self.highlighted.clone(), false => self.not_highlighted.clone(), }; vec![ RenderOperation::RenderBlockLine(BlockLine { prefix: self.prefix.clone(), right_padding_length: self.right_padding_length, repeat_prefix_on_wrap: false, text, block_length: context.block_length, alignment: context.alignment, block_color: self.block_color, }), RenderOperation::RenderLineBreak, ] } } #[derive(Debug)] pub(crate) struct HighlightMutator { context: Rc>, } impl HighlightMutator { pub(crate) fn new(context: Rc>) -> Self { Self { context } } } impl ChunkMutator for HighlightMutator { fn mutate_next(&self) -> bool { let mut context = self.context.borrow_mut(); if context.current == context.groups.len() - 1 { false } else { context.current += 1; true } } fn mutate_previous(&self) -> bool { let mut context = self.context.borrow_mut(); if context.current == 0 { false } else { context.current -= 1; true } } fn reset_mutations(&self) { self.context.borrow_mut().current = 0; } fn apply_all_mutations(&self) { let mut context = self.context.borrow_mut(); context.current = context.groups.len() - 1; } fn mutations(&self) -> (usize, usize) { let context = self.context.borrow(); (context.current, context.groups.len()) } } pub(crate) type ParseResult = Result; pub(crate) struct SnippetParser; impl SnippetParser { pub(crate) fn parse(info: String, code: String) -> ParseResult { let (language, attributes) = Self::parse_block_info(&info)?; let code = Snippet { contents: code, language, attributes }; Ok(code) } fn parse_block_info(input: &str) -> ParseResult<(SnippetLanguage, SnippetAttributes)> { let (language, input) = Self::parse_language(input); let attributes = Self::parse_attributes(input)?; if attributes.width.is_some() && !matches!(attributes.representation, SnippetRepr::Render) { return Err(SnippetBlockParseError::NotRenderSnippet("width")); } Ok((language, attributes)) } fn parse_language(input: &str) -> (SnippetLanguage, &str) { let token = Self::next_identifier(input); // this always returns `Ok` given we fall back to `Unknown` if we don't know the language. let language = token.parse().expect("language parsing"); let rest = &input[token.len()..]; (language, rest) } fn parse_attributes(mut input: &str) -> ParseResult { let mut attributes = SnippetAttributes::default(); let mut processed_attributes = Vec::new(); while let (Some(attribute), rest) = Self::parse_attribute(input)? { let discriminant = SnippetAttributeDiscriminants::from(&attribute); if processed_attributes.contains(&discriminant) { return Err(SnippetBlockParseError::DuplicateAttribute("duplicate attribute")); } use SnippetAttribute::*; match attribute { ExecReplace(_) | Image | Render if attributes.representation != SnippetRepr::Snippet => { return Err(SnippetBlockParseError::MultipleRepresentation); } LineNumbers => attributes.line_numbers = true, Exec(spec) => { if !matches!(attributes.execution, SnippetExec::AcquireTerminal(_)) { attributes.execution = SnippetExec::Exec(spec); } } ExecReplace(spec) => { attributes.representation = SnippetRepr::ExecReplace; attributes.execution = SnippetExec::Exec(spec); } Id(id) => { attributes.id = Some(id); } Validate(spec) => { if matches!(attributes.execution, SnippetExec::None) { attributes.execution = SnippetExec::Validate(spec); } } Image => { attributes.representation = SnippetRepr::Image; attributes.execution = SnippetExec::Exec(Default::default()); } Render => attributes.representation = SnippetRepr::Render, AcquireTerminal(spec) => attributes.execution = SnippetExec::AcquireTerminal(spec), NoBackground => attributes.no_background = true, HighlightedLines(lines) => attributes.highlight_groups = lines, Width(width) => attributes.width = Some(width), ExpectedExecutionResult(result) => attributes.expected_execution_result = result, }; processed_attributes.push(discriminant); input = rest; } if attributes.highlight_groups.is_empty() { attributes.highlight_groups.push(HighlightGroup::new(vec![Highlight::All])); } Ok(attributes) } fn parse_attribute(input: &str) -> ParseResult<(Option, &str)> { let input = Self::skip_whitespace(input); let (attribute, input) = match input.chars().next() { Some('+') => { let token = Self::next_identifier(&input[1..]); let attribute = match token { "line_numbers" => SnippetAttribute::LineNumbers, "exec" => SnippetAttribute::Exec(SnippetExecutorSpec::default()), "exec_replace" => SnippetAttribute::ExecReplace(SnippetExecutorSpec::default()), "validate" => SnippetAttribute::Validate(SnippetExecutorSpec::default()), "image" => SnippetAttribute::Image, "render" => SnippetAttribute::Render, "no_background" => SnippetAttribute::NoBackground, "acquire_terminal" => SnippetAttribute::AcquireTerminal(SnippetExecutorSpec::default()), other => { let (attribute, parameter) = other .split_once(':') .ok_or_else(|| SnippetBlockParseError::InvalidToken(Self::next_identifier(input).into()))?; match attribute { "exec" => SnippetAttribute::Exec(SnippetExecutorSpec::Alternative(parameter.to_string())), "exec_replace" => { SnippetAttribute::ExecReplace(SnippetExecutorSpec::Alternative(parameter.to_string())) } "id" => SnippetAttribute::Id(parameter.to_string()), "validate" => { SnippetAttribute::Validate(SnippetExecutorSpec::Alternative(parameter.to_string())) } "acquire_terminal" => SnippetAttribute::AcquireTerminal(SnippetExecutorSpec::Alternative( parameter.to_string(), )), "width" => { let width = parameter.parse().map_err(SnippetBlockParseError::InvalidWidth)?; SnippetAttribute::Width(width) } "expect" => match parameter { "success" => { SnippetAttribute::ExpectedExecutionResult(ExpectedSnippetExecutionResult::Success) } "failure" | "fail" => { SnippetAttribute::ExpectedExecutionResult(ExpectedSnippetExecutionResult::Failure) } _ => { return Err(SnippetBlockParseError::InvalidToken( Self::next_identifier(input).into(), )); } }, _ => return Err(SnippetBlockParseError::InvalidToken(Self::next_identifier(input).into())), } } }; (Some(attribute), &input[token.len() + 1..]) } Some('{') => { let (lines, input) = Self::parse_highlight_groups(&input[1..])?; (Some(SnippetAttribute::HighlightedLines(lines)), input) } Some(_) => return Err(SnippetBlockParseError::InvalidToken(Self::next_identifier(input).into())), None => (None, input), }; Ok((attribute, input)) } fn parse_highlight_groups(input: &str) -> ParseResult<(Vec, &str)> { use SnippetBlockParseError::InvalidHighlightedLines; let Some((head, tail)) = input.split_once('}') else { return Err(InvalidHighlightedLines("no enclosing '}'".into())); }; let head = head.trim(); if head.is_empty() { return Ok((Vec::new(), tail)); } let mut highlight_groups = Vec::new(); for group in head.split('|') { let group = Self::parse_highlight_group(group)?; highlight_groups.push(group); } Ok((highlight_groups, tail)) } fn parse_highlight_group(input: &str) -> ParseResult { let mut highlights = Vec::new(); for piece in input.split(',') { let piece = piece.trim(); if piece == "all" { highlights.push(Highlight::All); continue; } match piece.split_once('-') { Some((left, right)) => { let left = Self::parse_number(left)?; let right = Self::parse_number(right)?; let right = right.checked_add(1).ok_or_else(|| { SnippetBlockParseError::InvalidHighlightedLines(format!("{right} is too large")) })?; highlights.push(Highlight::Range(left..right)); } None => { let number = Self::parse_number(piece)?; highlights.push(Highlight::Single(number)); } } } Ok(HighlightGroup::new(highlights)) } fn parse_number(input: &str) -> ParseResult { input .trim() .parse() .map_err(|_| SnippetBlockParseError::InvalidHighlightedLines(format!("not a number: '{input}'"))) } fn skip_whitespace(input: &str) -> &str { input.trim_start_matches(' ') } fn next_identifier(input: &str) -> &str { match input.split_once(' ') { Some((token, _)) => token, None => input, } } } #[derive(thiserror::Error, Debug)] pub enum SnippetBlockParseError { #[error("invalid code attribute: {0}")] InvalidToken(String), #[error("invalid highlighted lines: {0}")] InvalidHighlightedLines(String), #[error("invalid width: {0}")] InvalidWidth(PercentParseError), #[error("duplicate attribute: {0}")] DuplicateAttribute(&'static str), #[error("+exec_replace +image and +render can't be used together ")] MultipleRepresentation, #[error("attribute {0} can only be set in +render blocks")] NotRenderSnippet(&'static str), } #[derive(EnumDiscriminants)] enum SnippetAttribute { LineNumbers, Exec(SnippetExecutorSpec), ExecReplace(SnippetExecutorSpec), Validate(SnippetExecutorSpec), Image, Render, HighlightedLines(Vec), Width(Percent), NoBackground, AcquireTerminal(SnippetExecutorSpec), ExpectedExecutionResult(ExpectedSnippetExecutionResult), Id(String), } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) enum SnippetExecutorSpec { #[default] Default, Alternative(String), } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum ExpectedSnippetExecutionResult { #[default] Success, Failure, } /// A code snippet. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Snippet { /// The snippet itself. pub(crate) contents: String, /// The programming language this snippet is written in. pub(crate) language: SnippetLanguage, /// The attributes used for snippet. pub(crate) attributes: SnippetAttributes, } impl Snippet { pub(crate) fn visible_lines<'a, 'b>( &'a self, hidden_line_prefix: Option<&'b str>, ) -> impl Iterator + 'b where 'a: 'b, { self.contents.lines().filter(move |line| !hidden_line_prefix.is_some_and(|prefix| line.starts_with(prefix))) } pub(crate) fn executable_contents(&self, hidden_line_prefix: Option<&str>) -> String { if let Some(prefix) = hidden_line_prefix { self.contents.lines().fold(String::new(), |mut output, line| { let line = line.strip_prefix(prefix).unwrap_or(line); let _ = writeln!(output, "{line}"); output }) } else { self.contents.to_owned() } } } /// The language of a code snippet. #[derive(Clone, Debug, PartialEq, Eq, EnumIter, PartialOrd, Ord)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub enum SnippetLanguage { Ada, Asp, Awk, Bash, BatchFile, C, CMake, Crontab, CSharp, Clojure, Cpp, Css, D2, DLang, Diff, Docker, Dotenv, Elixir, Elm, Erlang, File, Fish, FSharp, Go, GraphQL, Haskell, Html, Java, JavaScript, Json, Jsonnet, Julia, Kotlin, Latex, Lua, Makefile, Mermaid, Markdown, Nix, Nushell, OCaml, Perl, Php, Protobuf, Puppet, Python, R, Racket, Ruby, Rust, RustScript, Scala, Shell, Sql, Swift, Svelte, Tcl, Terraform, Toml, TypeScript, Typst, Unknown(String), Xml, Yaml, Verilog, Vue, Zig, Zsh, } crate::utils::impl_deserialize_from_str!(SnippetLanguage); impl FromStr for SnippetLanguage { type Err = Infallible; fn from_str(s: &str) -> Result { use SnippetLanguage::*; let language = match s.to_lowercase().as_str() { "ada" => Ada, "asp" => Asp, "awk" => Awk, "bash" => Bash, "c" => C, "cmake" => CMake, "crontab" => Crontab, "csharp" => CSharp, "clojure" => Clojure, "cpp" | "c++" => Cpp, "css" => Css, "d2" => D2, "d" => DLang, "diff" => Diff, "docker" => Docker, "dotenv" => Dotenv, "elixir" => Elixir, "elm" => Elm, "erlang" => Erlang, "file" => File, "fish" => Fish, "fsharp" => FSharp, "go" => Go, "graphql" => GraphQL, "haskell" => Haskell, "html" => Html, "java" => Java, "javascript" | "js" => JavaScript, "json" => Json, "jsonnet" => Jsonnet, "julia" => Julia, "kotlin" => Kotlin, "latex" => Latex, "lua" => Lua, "make" => Makefile, "markdown" => Markdown, "mermaid" => Mermaid, "nix" => Nix, "nushell" | "nu" => Nushell, "ocaml" => OCaml, "perl" => Perl, "php" => Php, "protobuf" => Protobuf, "puppet" => Puppet, "python" => Python, "r" => R, "racket" => Racket, "ruby" => Ruby, "rust" => Rust, "rust-script" => RustScript, "scala" => Scala, "shell" | "sh" => Shell, "sql" => Sql, "svelte" => Svelte, "swift" => Swift, "tcl" => Tcl, "terraform" => Terraform, "toml" => Toml, "typescript" | "ts" => TypeScript, "typst" => Typst, "xml" => Xml, "yaml" => Yaml, "verilog" => Verilog, "vue" => Vue, "zig" => Zig, "zsh" => Zsh, other => Unknown(other.to_string()), }; Ok(language) } } /// Attributes for code snippets. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct SnippetAttributes { /// The way the snippet should be represented. pub(crate) representation: SnippetRepr, /// The way the snippet should be executed. pub(crate) execution: SnippetExec, /// Whether the snippet should show line numbers. pub(crate) line_numbers: bool, /// The groups of lines to highlight. pub(crate) highlight_groups: Vec, /// The width of the generated image. /// /// Only valid for +render snippets. pub(crate) width: Option, /// Whether to add no background to a snippet. pub(crate) no_background: bool, /// The expected execution result for a snippet. pub(crate) expected_execution_result: ExpectedSnippetExecutionResult, /// The identifier for a snippet. pub(crate) id: Option, } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) enum SnippetRepr { #[default] Snippet, Image, Render, ExecReplace, } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) enum SnippetExec { #[default] None, Exec(SnippetExecutorSpec), AcquireTerminal(SnippetExecutorSpec), Validate(SnippetExecutorSpec), } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct HighlightGroup(Vec); impl HighlightGroup { pub(crate) fn new(highlights: Vec) -> Self { Self(highlights) } pub(crate) fn contains(&self, line_number: u16) -> bool { for higlight in &self.0 { match higlight { Highlight::All => return true, Highlight::Single(number) if number == &line_number => return true, Highlight::Range(range) if range.contains(&line_number) => return true, _ => continue, }; } false } } /// A highlighted set of lines #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum Highlight { All, Single(u16), Range(Range), } #[derive(Debug, Deserialize)] pub(crate) struct ExternalFile { pub(crate) path: PathBuf, pub(crate) language: SnippetLanguage, pub(crate) start_line: Option, pub(crate) end_line: Option, } #[cfg(test)] mod test { use super::*; use Highlight::*; use rstest::rstest; fn parse_language(input: &str) -> SnippetLanguage { let (language, _) = SnippetParser::parse_block_info(input).expect("parse failed"); language } fn try_parse_attributes(input: &str) -> Result { let (_, attributes) = SnippetParser::parse_block_info(input)?; Ok(attributes) } fn parse_attributes(input: &str) -> SnippetAttributes { try_parse_attributes(input).expect("parse failed") } #[test] fn code_with_line_numbers() { let total_lines = 11; let input_lines = "hi\n".repeat(total_lines); let code = Snippet { contents: input_lines, language: SnippetLanguage::Unknown("".to_string()), attributes: SnippetAttributes { line_numbers: true, ..Default::default() }, }; let lines = SnippetSplitter::new(&Default::default(), None).split(&code); assert_eq!(lines.len(), total_lines); let mut lines = lines.into_iter().enumerate(); // 0..=9 for (index, line) in lines.by_ref().take(9) { let line_number = index + 1; assert_eq!(&line.prefix, &format!(" {line_number} ")); } // 10.. for (index, line) in lines { let line_number = index + 1; assert_eq!(&line.prefix, &format!("{line_number} ")); } } #[test] fn unknown_language() { assert_eq!(parse_language("potato"), SnippetLanguage::Unknown("potato".to_string())); } #[test] fn no_attributes() { assert_eq!(parse_language("rust"), SnippetLanguage::Rust); } #[test] fn one_attribute() { let attributes = parse_attributes("bash +exec"); assert_eq!(attributes.execution, SnippetExec::Exec(Default::default())); assert!(!attributes.line_numbers); } #[test] fn two_attributes() { let attributes = parse_attributes("bash +exec +line_numbers"); assert_eq!(attributes.execution, SnippetExec::Exec(Default::default())); assert!(attributes.line_numbers); } #[test] fn acquire_terminal() { let attributes = parse_attributes("bash +acquire_terminal +exec"); assert_eq!(attributes.execution, SnippetExec::AcquireTerminal(Default::default())); assert_eq!(attributes.representation, SnippetRepr::Snippet); assert!(!attributes.line_numbers); } #[test] fn image() { let attributes = parse_attributes("bash +image +exec"); assert_eq!(attributes.execution, SnippetExec::Exec(Default::default())); assert_eq!(attributes.representation, SnippetRepr::Image); assert!(!attributes.line_numbers); } #[test] fn invalid_attributes() { SnippetParser::parse_block_info("bash +potato").unwrap_err(); SnippetParser::parse_block_info("bash potato").unwrap_err(); } #[rstest] #[case::no_end("{")] #[case::number_no_end("{42")] #[case::comma_nothing("{42,")] #[case::brace_comma("{,}")] #[case::range_no_end("{42-")] #[case::range_end("{42-}")] #[case::too_many_ranges("{42-3-5}")] #[case::range_comma("{42-,")] #[case::too_large("{65536}")] #[case::too_large_end("{1-65536}")] fn invalid_line_highlights(#[case] input: &str) { let input = format!("bash {input}"); SnippetParser::parse_block_info(&input).expect_err("parsed successfully"); } #[test] fn highlight_none() { let attributes = parse_attributes("bash {}"); assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Highlight::All])]); } #[test] fn highlight_specific_lines() { let attributes = parse_attributes("bash { 1, 2 , 3 }"); assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Single(1), Single(2), Single(3)])]); } #[test] fn highlight_line_range() { let attributes = parse_attributes("bash { 1, 2-4,6 , all , 10 - 12 }"); assert_eq!( attributes.highlight_groups, &[HighlightGroup::new(vec![Single(1), Range(2..5), Single(6), All, Range(10..13)])] ); } #[test] fn multiple_groups() { let attributes = parse_attributes("bash {1-3,5 |6-9}"); assert_eq!(attributes.highlight_groups.len(), 2); assert_eq!(attributes.highlight_groups[0], HighlightGroup::new(vec![Range(1..4), Single(5)])); assert_eq!(attributes.highlight_groups[1], HighlightGroup::new(vec![Range(6..10)])); } #[test] fn parse_width() { let attributes = parse_attributes("mermaid +width:50% +render"); assert_eq!(attributes.representation, SnippetRepr::Render); assert_eq!(attributes.width, Some(Percent(50))); } #[test] fn invalid_width() { try_parse_attributes("mermaid +width:50%% +render").expect_err("parse succeeded"); try_parse_attributes("mermaid +width: +render").expect_err("parse succeeded"); try_parse_attributes("mermaid +width:50%").expect_err("parse succeeded"); } #[test] fn code_visible_lines() { let contents = r##"# fn main() { println!("Hello world"); # // The prefix is # . # } "## .to_string(); let expected = vec!["println!(\"Hello world\");"]; let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() }; assert_eq!(expected, code.visible_lines(Some("# ")).collect::>()); } #[test] fn code_executable_contents() { let contents = r##"# fn main() { println!("Hello world"); # // The prefix is # . # } "## .to_string(); let expected = r##"fn main() { println!("Hello world"); // The prefix is # . } "## .to_string(); let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() }; assert_eq!(expected, code.executable_contents(Some("# "))); } #[test] fn tabs_in_snippet() { let snippet = Snippet { contents: "\thi".into(), language: SnippetLanguage::C, attributes: Default::default() }; let lines = SnippetSplitter::new(&Default::default(), None).split(&snippet); assert_eq!(lines[0].code, " hi\n"); } #[rstest] #[case::exec("bash +exec:foo", SnippetExecutorSpec::Alternative("foo".to_string()))] #[case::exec_and_more("bash +exec:foo +line_numbers", SnippetExecutorSpec::Alternative("foo".to_string()))] #[case::exec_replace("bash +exec_replace:foo", SnippetExecutorSpec::Alternative("foo".to_string()))] #[case::exec_replace_and_more("bash +exec_replace:foo +line_numbers", SnippetExecutorSpec::Alternative("foo".into()))] fn alternative_executor(#[case] input: &str, #[case] spec: SnippetExecutorSpec) { let attributes = parse_attributes(input); assert_eq!(attributes.execution, SnippetExec::Exec(spec)); } #[test] fn acquire_terminal_alternative() { let attributes = parse_attributes("bash +acquire_terminal:foo"); assert_eq!(attributes.execution, SnippetExec::AcquireTerminal(SnippetExecutorSpec::Alternative("foo".into()))); } #[rstest] #[case::success("expect:success", ExpectedSnippetExecutionResult::Success)] #[case::failure("expect:failure", ExpectedSnippetExecutionResult::Failure)] #[case::fail("expect:fail", ExpectedSnippetExecutionResult::Failure)] fn parse_expect(#[case] input: &str, #[case] expected: ExpectedSnippetExecutionResult) { let attributes = parse_attributes(&format!("bash +{input}")); assert_eq!(attributes.expected_execution_result, expected); } } presenterm-0.15.1/src/commands/keyboard.rs000064400000000000000000000557541046102023000166520ustar 00000000000000use super::listener::{Command, CommandDiscriminants}; use crate::config::KeyBindingsConfig; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; use std::{fmt, io, iter, mem, str::FromStr, time::Duration}; /// A keyboard command listener. pub struct KeyboardListener { bindings: CommandKeyBindings, events: Vec, } impl KeyboardListener { pub fn new(bindings: CommandKeyBindings) -> Self { Self { bindings, events: Vec::new() } } /// Polls for the next input command coming from the keyboard. pub(crate) fn poll_next_command(&mut self, timeout: Duration) -> io::Result> { if poll(timeout)? { self.next_command() } else { Ok(None) } } /// Blocks waiting for the next command. pub(crate) fn next_command(&mut self) -> io::Result> { let mut events = mem::take(&mut self.events); let (command, events) = match read()? { // Ignore release events Event::Key(event) if event.kind == KeyEventKind::Release => (None, events), Event::Key(event) => { events.push(event); self.match_events(events) } Event::Resize(..) => (Some(Command::Redraw), events), _ => (None, vec![]), }; self.events = events; Ok(command) } fn match_events(&self, events: Vec) -> (Option, Vec) { match self.bindings.apply(&events) { InputAction::Emit(command) => (Some(command), Vec::new()), InputAction::Buffer => (None, events), InputAction::Reset => (None, Vec::new()), } } } enum InputAction { Buffer, Reset, Emit(Command), } pub struct CommandKeyBindings { bindings: Vec<(KeyBinding, CommandDiscriminants)>, } impl CommandKeyBindings { fn apply(&self, events: &[KeyEvent]) -> InputAction { let mut any_partials = false; for (binding, identifier) in &self.bindings { match binding.match_events(events) { BindingMatch::Full(context) => return Self::instantiate(identifier, context), BindingMatch::Partial => any_partials = true, BindingMatch::None => (), } } if any_partials { InputAction::Buffer } else { InputAction::Reset } } fn instantiate(discriminant: &CommandDiscriminants, context: MatchContext) -> InputAction { use CommandDiscriminants::*; let command = match discriminant { Redraw => Command::Redraw, Next => Command::Next, NextFast => Command::NextFast, Previous => Command::Previous, PreviousFast => Command::PreviousFast, FirstSlide => Command::FirstSlide, LastSlide => Command::LastSlide, GoToSlide => { match context { // this means the command is malformed and this should have been caught earlier // on. MatchContext::None => return InputAction::Reset, MatchContext::Number(number) => Command::GoToSlide(number), } } RenderAsyncOperations => Command::RenderAsyncOperations, Exit => Command::Exit, Suspend => Command::Suspend, Reload => Command::Reload, HardReload => Command::HardReload, ToggleSlideIndex => Command::ToggleSlideIndex, ToggleKeyBindingsConfig => Command::ToggleKeyBindingsConfig, CloseModal => Command::CloseModal, SkipPauses => Command::SkipPauses, }; InputAction::Emit(command) } fn validate_conflicts<'a>( bindings: impl Iterator, ) -> Result<(), KeyBindingsValidationError> { let mut bindings: Vec<_> = bindings.map(|binding| &binding.0).collect(); bindings.sort_by(|a, b| a.partial_cmp(b).unwrap()); for window in bindings.windows(2) { if window[0].iter().eq(window[1].iter().take(window[0].len())) { return Err(KeyBindingsValidationError::Conflict( KeyBinding(window[0].clone()), KeyBinding(window[1].clone()), )); } } Ok(()) } } impl TryFrom for CommandKeyBindings { type Error = KeyBindingsValidationError; fn try_from(config: KeyBindingsConfig) -> Result { let zip = |discriminant, bindings: Vec| bindings.into_iter().zip(iter::repeat(discriminant)); if !config.go_to_slide.iter().all(|k| k.expects_number()) { return Err(KeyBindingsValidationError::Invalid("go_to_slide", " matcher required")); } let bindings: Vec<_> = iter::empty() .chain(zip(CommandDiscriminants::Next, config.next)) .chain(zip(CommandDiscriminants::NextFast, config.next_fast)) .chain(zip(CommandDiscriminants::Previous, config.previous)) .chain(zip(CommandDiscriminants::PreviousFast, config.previous_fast)) .chain(zip(CommandDiscriminants::FirstSlide, config.first_slide)) .chain(zip(CommandDiscriminants::LastSlide, config.last_slide)) .chain(zip(CommandDiscriminants::GoToSlide, config.go_to_slide)) .chain(zip(CommandDiscriminants::Exit, config.exit)) .chain(zip(CommandDiscriminants::Suspend, config.suspend)) .chain(zip(CommandDiscriminants::HardReload, config.reload)) .chain(zip(CommandDiscriminants::ToggleSlideIndex, config.toggle_slide_index)) .chain(zip(CommandDiscriminants::ToggleKeyBindingsConfig, config.toggle_bindings)) .chain(zip(CommandDiscriminants::RenderAsyncOperations, config.execute_code)) .chain(zip(CommandDiscriminants::CloseModal, config.close_modal)) .chain(zip(CommandDiscriminants::SkipPauses, config.skip_pauses)) .collect(); Self::validate_conflicts(bindings.iter().map(|binding| &binding.0))?; Ok(Self { bindings }) } } #[derive(Debug, thiserror::Error)] pub enum KeyBindingsValidationError { #[error("invalid binding for {0}: {1}")] Invalid(&'static str, &'static str), #[error("conflicting keybindings: {0} and {1}")] Conflict(KeyBinding, KeyBinding), } #[derive(Clone, Debug, PartialEq, Eq)] enum BindingMatch { Full(MatchContext), Partial, None, } #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct KeyBinding(#[cfg_attr(feature = "json-schema", schemars(with = "String"))] Vec); crate::utils::impl_deserialize_from_str!(KeyBinding); impl KeyBinding { fn match_events(&self, mut events: &[KeyEvent]) -> BindingMatch { let mut output_context = MatchContext::None; for (index, matcher) in self.0.iter().enumerate() { let Some((context, rest)) = matcher.try_match_events(events) else { return BindingMatch::None; }; if !matches!(context, MatchContext::None) { output_context = context; } events = rest; // We ran all matchers but we have no events left; this is a partial match. if index != self.0.len() - 1 && events.is_empty() { return BindingMatch::Partial; } } // If there's more events than we need, this is an issue on the caller side. BindingMatch::Full(output_context) } fn expects_number(&self) -> bool { self.0.iter().any(|m| matches!(m, KeyMatcher::Number)) } } impl FromStr for KeyBinding { type Err = KeyBindingParseError; fn from_str(mut input: &str) -> Result { let mut matchers = Vec::new(); let mut has_numbers = false; while !input.is_empty() { let (matcher, rest) = KeyMatcher::parse(input)?; let is_number = matches!(matcher, KeyMatcher::Number); // We don't want more than one matcher if has_numbers && is_number { return Err(KeyBindingParseError::TooManyNumbers); } has_numbers = has_numbers || is_number; matchers.push(matcher); input = rest; } Ok(Self(matchers)) } } impl fmt::Display for KeyBinding { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { for matcher in &self.0 { write!(f, "{matcher}")?; } Ok(()) } } #[derive(Debug, thiserror::Error)] pub enum KeyBindingParseError { #[error("no input")] NoInput, #[error("not a valid key: {0}")] InvalidKey(char), #[error("too many number placeholders")] TooManyNumbers, #[error("invalid control sequence")] InvalidControlSequence, } #[derive(Clone, Debug, PartialEq, Eq, PartialOrd)] enum KeyMatcher { Key(KeyCombination), Number, } impl KeyMatcher { fn try_match_events<'a>(&self, events: &'a [KeyEvent]) -> Option<(MatchContext, &'a [KeyEvent])> { match self { Self::Key(combo) => Self::try_match_key(combo, events), Self::Number => Self::try_match_number(events), } } fn try_match_key<'a>(combo: &KeyCombination, events: &'a [KeyEvent]) -> Option<(MatchContext, &'a [KeyEvent])> { let event = events.first()?; let is_control = event.modifiers == KeyModifiers::CONTROL; if combo.key == event.code && combo.control == is_control { let rest = &events[1..]; Some((MatchContext::None, rest)) } else { None } } fn try_match_number(mut events: &[KeyEvent]) -> Option<(MatchContext, &[KeyEvent])> { let mut number = None; while let Some((head, rest)) = events.split_first() { let digit = match head.code { KeyCode::Char(c) if c.is_ascii_digit() => c.to_digit(10).expect("not a digit"), _ => break, }; let next = number.unwrap_or(0u32).checked_mul(10).and_then(|number| number.checked_add(digit)); match next { Some(n) => { number = Some(n); events = rest; } // if we overflow we're done None => return None, } } number.map(|number| (MatchContext::Number(number), events)) } fn parse(input: &str) -> Result<(Self, &str), KeyBindingParseError> { if let Some(input) = input.strip_prefix("") { Ok((Self::Number, input)) } else if let Some(input) = Self::try_match_input(input, &["') else { return Err(KeyBindingParseError::InvalidControlSequence); }; let matcher = Self::Key(KeyCombination { key, control: true }); Ok((matcher, input)) } else { let (key, input) = Self::parse_key_code(input)?; let matcher = Self::Key(KeyCombination { key, control: false }); Ok((matcher, input)) } } fn parse_key_code(input: &str) -> Result<(KeyCode, &str), KeyBindingParseError> { if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::PageUp, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::PageDown, input)) } else if let Some(input) = Self::try_match_input(input, &["", "", "", ""]) { Ok((KeyCode::Enter, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Home, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::End, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Left, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Right, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Up, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Down, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Esc, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Tab, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Backspace, input)) } else if let Some(input) = Self::try_match_input(input, &["').ok_or(KeyBindingParseError::InvalidControlSequence)?; let number: u8 = number.parse().map_err(|_| KeyBindingParseError::InvalidControlSequence)?; if number > 12 { Err(KeyBindingParseError::InvalidControlSequence) } else { Ok((KeyCode::F(number), rest)) } } else { let next = input.chars().next().ok_or(KeyBindingParseError::NoInput)?; // don't allow these as they create ambiguity if next == '<' || next == '>' { Err(KeyBindingParseError::InvalidKey(next)) } else if next.is_alphanumeric() || next.is_ascii_punctuation() || next == ' ' { let key = KeyCode::Char(next); Ok((key, &input[next.len_utf8()..])) } else { Err(KeyBindingParseError::InvalidKey(next)) } } } fn try_match_input<'a>(input: &'a str, aliases: &[&str]) -> Option<&'a str> { for alias in aliases { if let Some(input) = input.strip_prefix(alias) { return Some(input); } } None } } impl fmt::Display for KeyMatcher { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Number => write!(f, ""), Self::Key(combo) => { if combo.control { write!(f, " write!(f, "' '")?, KeyCode::Char(c) => write!(f, "{}", c)?, other => write!(f, "<{other:?}>")?, }; if combo.control { write!(f, ">")?; } Ok(()) } } } } #[derive(Clone, Debug, PartialEq, Eq)] enum MatchContext { Number(u32), None, } #[derive(Clone, Debug, PartialEq, Eq, PartialOrd)] struct KeyCombination { key: KeyCode, control: bool, } impl KeyCombination { #[cfg(test)] fn char(c: char) -> Self { Self { key: KeyCode::Char(c), control: false } } #[cfg(test)] fn control_char(c: char) -> Self { Self { key: KeyCode::Char(c), control: true } } } impl From for KeyCombination { fn from(key: KeyCode) -> Self { Self { key, control: false } } } #[cfg(test)] mod test { use super::*; use crossterm::event::KeyEventState; use rstest::rstest; trait KeyEventSource { fn into_event(self) -> KeyEvent; } impl KeyEventSource for KeyCode { fn into_event(self) -> KeyEvent { KeyEvent { code: self, modifiers: KeyModifiers::empty(), kind: KeyEventKind::Press, state: KeyEventState::NONE, } } } impl KeyEventSource for char { fn into_event(self) -> KeyEvent { KeyCode::Char(self).into_event() } } trait KeyEventExt { fn with_control(self) -> Self; } impl KeyEventExt for KeyEvent { fn with_control(mut self) -> Self { self.modifiers = KeyModifiers::CONTROL; self } } #[rstest] #[case::number("", vec![KeyMatcher::Number])] #[case::char("w", vec![KeyMatcher::Key(KeyCombination::char('w'))])] #[case::ctrl_char1("", vec![KeyMatcher::Key(KeyCombination::control_char('w'))])] #[case::ctrl_char2("", vec![KeyMatcher::Key(KeyCombination::control_char('w'))])] #[case::dot(".", vec![KeyMatcher::Key(KeyCombination::char('.'))])] #[case::dot(" ", vec![KeyMatcher::Key(KeyCombination::char(' '))])] #[case::multi("hi", vec![KeyMatcher::Key(KeyCombination::char('h')), KeyMatcher::Key(KeyCombination::char('i'))])] #[case::page_up1("", vec![KeyMatcher::Key(KeyCode::PageUp.into())])] #[case::page_up2("", vec![KeyMatcher::Key(KeyCode::PageUp.into())])] #[case::page_down1("", vec![KeyMatcher::Key(KeyCode::PageDown.into())])] #[case::page_down2("", vec![KeyMatcher::Key(KeyCode::PageDown.into())])] #[case::enter1("", vec![KeyMatcher::Key(KeyCode::Enter.into())])] #[case::enter2("", vec![KeyMatcher::Key(KeyCode::Enter.into())])] #[case::enter3("", vec![KeyMatcher::Key(KeyCode::Enter.into())])] #[case::home1("", vec![KeyMatcher::Key(KeyCode::Home.into())])] #[case::home2("", vec![KeyMatcher::Key(KeyCode::Home.into())])] #[case::end1("", vec![KeyMatcher::Key(KeyCode::End.into())])] #[case::end2("", vec![KeyMatcher::Key(KeyCode::End.into())])] #[case::left1("", vec![KeyMatcher::Key(KeyCode::Left.into())])] #[case::left2("", vec![KeyMatcher::Key(KeyCode::Left.into())])] #[case::right1("", vec![KeyMatcher::Key(KeyCode::Right.into())])] #[case::right2("", vec![KeyMatcher::Key(KeyCode::Right.into())])] #[case::up1("", vec![KeyMatcher::Key(KeyCode::Up.into())])] #[case::up2("", vec![KeyMatcher::Key(KeyCode::Up.into())])] #[case::down1("", vec![KeyMatcher::Key(KeyCode::Down.into())])] #[case::down2("", vec![KeyMatcher::Key(KeyCode::Down.into())])] #[case::esc1("", vec![KeyMatcher::Key(KeyCode::Esc.into())])] #[case::esc2("", vec![KeyMatcher::Key(KeyCode::Esc.into())])] #[case::f1("", vec![KeyMatcher::Key(KeyCode::F(1).into())])] #[case::f12("", vec![KeyMatcher::Key(KeyCode::F(12).into())])] #[case::backspace1("", vec![KeyMatcher::Key(KeyCode::Backspace.into())])] #[case::backspace2("", vec![KeyMatcher::Key(KeyCode::Backspace.into())])] #[case::tab1("", vec![KeyMatcher::Key(KeyCode::Tab.into())])] #[case::tab2("", vec![KeyMatcher::Key(KeyCode::Tab.into())])] fn parse_key_binding(#[case] pattern: &str, #[case] matchers: Vec) { let binding = KeyBinding::from_str(pattern).expect("failed to parse"); let expected = KeyBinding(matchers); assert_eq!(binding, expected); } #[rstest] #[case::invalid_tag("")] #[case::invalid_char("🚀")] #[case::too_many_numbers("")] #[case::control_sequence("")] #[case::unfinished_f("", &['w'.into_event().with_control()])] #[case::page_up("", &[KeyCode::PageUp.into_event()])] #[case::page_down("", &[KeyCode::PageDown.into_event()])] #[case::enter("", &[KeyCode::Enter.into_event()])] #[case::home("", &[KeyCode::Home.into_event()])] #[case::end("", &[KeyCode::End.into_event()])] fn matching(#[case] pattern: &str, #[case] events: &[KeyEvent]) { let binding = KeyBinding::from_str(pattern).expect("failed to parse"); let result = binding.match_events(events); assert!(matches!(result, BindingMatch::Full(_)), "not full match: {result:?}"); } #[rstest] #[case::fewer("gg", &['g'.into_event()])] #[case::number_something1("G", &['4'.into_event()])] #[case::number_something2("G", &['4'.into_event(), '2'.into_event()])] #[case::number_something3(":", &[':'.into_event(), '4'.into_event()])] fn partial_matching(#[case] pattern: &str, #[case] events: &[KeyEvent]) { let binding = KeyBinding::from_str(pattern).expect("failed to parse"); let result = binding.match_events(events); assert!(matches!(result, BindingMatch::Partial), "not partial match: {result:?}"); } #[rstest] #[case::number_something("G", &['4'.into_event(), 'K'.into_event()])] fn no_matching(#[case] pattern: &str, #[case] events: &[KeyEvent]) { let binding = KeyBinding::from_str(pattern).expect("failed to parse"); let result = binding.match_events(events); assert!(matches!(result, BindingMatch::None), "some match: {result:?}"); } #[rstest] #[case::number_something("G", &['4'.into_event(), '2'.into_event(), 'G'.into_event()])] #[case::number_something( ":", &[':'.into_event(), '4'.into_event(), '2'.into_event(), KeyCode::Enter.into_event()] )] fn match_number(#[case] pattern: &str, #[case] events: &[KeyEvent]) { let binding = KeyBinding::from_str(pattern).expect("failed to parse"); let result = binding.match_events(events); let BindingMatch::Full(MatchContext::Number(number)) = result else { panic!("unexpected match: {result:?}"); }; assert_eq!(number, 42); } #[rstest] #[case(&["G", "other", "Go"])] #[case(&["", "something", ""])] #[case(&["", ""])] #[case(&["", "a"])] #[case(&["", ""])] #[case(&["", ""])] fn conflicts(#[case] patterns: &[&str]) { let bindings: Vec<_> = patterns.iter().map(|p| KeyBinding::from_str(p).unwrap()).collect(); let result = CommandKeyBindings::validate_conflicts(bindings.iter()); assert!(result.is_err(), "not an error: {result:?}"); } #[rstest] #[case(&["Ga", "Go"])] #[case(&["", "hi"])] fn no_conflicts(#[case] patterns: &[&str]) { let bindings: Vec<_> = patterns.iter().map(|p| KeyBinding::from_str(p).unwrap()).collect(); let result = CommandKeyBindings::validate_conflicts(bindings.iter()); assert!(result.is_ok(), "got error: {result:?}"); } #[rstest] #[case("G")] #[case("potato")] #[case("")] fn display(#[case] pattern: &str) { let binding = KeyBinding::from_str(pattern).expect("invalid pattern"); let rendered = binding.to_string(); assert_eq!(rendered, pattern); } } presenterm-0.15.1/src/commands/listener.rs000064400000000000000000000057411046102023000166660ustar 00000000000000use super::{ keyboard::{CommandKeyBindings, KeyBindingsValidationError, KeyboardListener}, speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventListener}, }; use crate::{config::KeyBindingsConfig, presenter::PresentationError}; use serde::Deserialize; use std::time::Duration; use strum::EnumDiscriminants; /// A command listener that allows polling all command sources in a single place. pub struct CommandListener { keyboard: KeyboardListener, speaker_notes_event_listener: Option, } impl CommandListener { /// Create a new command source over the given presentation path. pub fn new( config: KeyBindingsConfig, speaker_notes_event_listener: Option, ) -> Result { let bindings = CommandKeyBindings::try_from(config)?; Ok(Self { keyboard: KeyboardListener::new(bindings), speaker_notes_event_listener }) } /// Try to get the next command. /// /// This attempts to get a command and returns `Ok(None)` on timeout. pub(crate) fn try_next_command(&mut self) -> Result, PresentationError> { if let Some(receiver) = &self.speaker_notes_event_listener { if let Some(msg) = receiver.try_recv()? { let command = match msg { SpeakerNotesEvent::GoToSlide { slide } => Command::GoToSlide(slide), SpeakerNotesEvent::Exit => Command::Exit, }; return Ok(Some(command)); } } match self.keyboard.poll_next_command(Duration::from_millis(100))? { Some(command) => Ok(Some(command)), None => Ok(None), } } } /// A command. #[derive(Clone, Debug, PartialEq, Eq, EnumDiscriminants)] #[strum_discriminants(derive(Deserialize))] pub(crate) enum Command { /// Redraw the presentation. /// /// This can happen on terminal resize. Redraw, /// Move forward in the presentation. Next, /// Move to the next slide fast. NextFast, /// Move backwards in the presentation. Previous, /// Move to the previous slide fast. PreviousFast, /// Go to the first slide. FirstSlide, /// Go to the last slide. LastSlide, /// Go to one particular slide. GoToSlide(u32), /// Render any async render operations in the current slide. RenderAsyncOperations, /// Exit the presentation. Exit, /// Suspend the presentation. Suspend, /// The presentation has changed and needs to be reloaded. Reload, /// Hard reload the presentation. /// /// Like [Command::Reload] but also reloads any external resources like images and themes. HardReload, /// Toggle the slide index view. ToggleSlideIndex, /// Toggle the key bindings config view. ToggleKeyBindingsConfig, /// Hide the currently open modal, if any. CloseModal, /// Skip pauses in the current slide. SkipPauses, } presenterm-0.15.1/src/commands/mod.rs000064400000000000000000000001201046102023000156020ustar 00000000000000pub(crate) mod keyboard; pub(crate) mod listener; pub(crate) mod speaker_notes; presenterm-0.15.1/src/commands/speaker_notes.rs000064400000000000000000000100751046102023000176770ustar 00000000000000use serde::{Deserialize, Serialize}; use socket2::{Domain, Protocol, Socket, Type}; use std::{ io, net::{SocketAddr, UdpSocket}, path::PathBuf, }; pub struct SpeakerNotesEventPublisher { socket: UdpSocket, presentation_path: PathBuf, } impl SpeakerNotesEventPublisher { pub fn new(address: SocketAddr, presentation_path: PathBuf) -> io::Result { let socket = UdpSocket::bind("127.0.0.1:0")?; socket.set_broadcast(true)?; socket.connect(address)?; Ok(Self { socket, presentation_path }) } pub(crate) fn send(&self, event: SpeakerNotesEvent) -> io::Result<()> { // Wrap this event in an envelope that contains the presentation path so listeners can // ignore unrelated events. let envelope = SpeakerNotesEventEnvelope { event, presentation_path: self.presentation_path.clone() }; let data = serde_json::to_string(&envelope).expect("serialization failed"); match self.socket.send(data.as_bytes()) { Ok(_) => Ok(()), Err(e) if e.kind() == io::ErrorKind::ConnectionRefused => Ok(()), Err(e) => Err(e), } } } pub struct SpeakerNotesEventListener { socket: UdpSocket, presentation_path: PathBuf, } impl SpeakerNotesEventListener { pub fn new(address: SocketAddr, presentation_path: PathBuf) -> io::Result { let s = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; // Use SO_REUSEADDR so we can have multiple listeners on the same port. #[cfg(not(target_os = "macos"))] s.set_reuse_address(true)?; // Don't block so we can listen to the keyboard and this socket at the same time. s.set_nonblocking(true)?; s.bind(&address.into())?; Ok(Self { socket: s.into(), presentation_path }) } pub(crate) fn try_recv(&self) -> io::Result> { let mut buffer = [0; 1024]; let bytes_read = match self.socket.recv(&mut buffer) { Ok(bytes_read) => bytes_read, Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(None), Err(e) => return Err(e), }; // Ignore garbage. Odds are this is someone else sending garbage rather than presenterm // itself. let Ok(envelope) = serde_json::from_slice::(&buffer[0..bytes_read]) else { return Ok(None); }; if envelope.presentation_path == self.presentation_path { Ok(Some(envelope.event)) } else { Ok(None) } } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "command")] pub(crate) enum SpeakerNotesEvent { GoToSlide { slide: u32 }, Exit, } #[derive(Clone, Debug, Serialize, Deserialize)] struct SpeakerNotesEventEnvelope { presentation_path: PathBuf, event: SpeakerNotesEvent, } #[cfg(not(target_os = "macos"))] #[cfg(test)] mod tests { use super::*; use crate::config::{default_speaker_notes_listen_address, default_speaker_notes_publish_address}; use std::{thread::sleep, time::Duration}; fn make_listener(path: PathBuf) -> SpeakerNotesEventListener { SpeakerNotesEventListener::new(default_speaker_notes_listen_address(), path).expect("building listener") } fn make_publisher(path: PathBuf) -> SpeakerNotesEventPublisher { SpeakerNotesEventPublisher::new(default_speaker_notes_publish_address(), path).expect("building publisher") } #[test] fn bind_multiple() { let _l1 = make_listener("".into()); let _l2 = make_listener("".into()); } #[test] fn multicast() { let path = PathBuf::from("/tmp/test.md"); let l1 = make_listener(path.clone()); let l2 = make_listener(path.clone()); let publisher = make_publisher(path); let event = SpeakerNotesEvent::Exit; publisher.send(event.clone()).expect("send failed"); sleep(Duration::from_millis(100)); assert_eq!(l1.try_recv().expect("recv first failed"), Some(event.clone())); assert_eq!(l2.try_recv().expect("recv second failed"), Some(event)); } } presenterm-0.15.1/src/config.rs000064400000000000000000000571231046102023000145060ustar 00000000000000use crate::{ code::snippet::SnippetLanguage, commands::keyboard::KeyBinding, terminal::{GraphicsMode, emulator::TerminalEmulator, image::protocols::kitty::KittyMode}, }; use clap::ValueEnum; use serde::Deserialize; use std::{ collections::{BTreeMap, HashMap}, fs, io, net::{IpAddr, Ipv4Addr, SocketAddr}, num::NonZeroU8, path::{Path, PathBuf}, }; #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct Config { /// The default configuration for the presentation. #[serde(default)] pub defaults: DefaultsConfig, #[serde(default)] pub typst: TypstConfig, #[serde(default)] pub mermaid: MermaidConfig, #[serde(default)] pub d2: D2Config, #[serde(default)] pub options: OptionsConfig, #[serde(default)] pub bindings: KeyBindingsConfig, #[serde(default)] pub snippet: SnippetConfig, #[serde(default)] pub speaker_notes: SpeakerNotesConfig, #[serde(default)] pub export: ExportConfig, #[serde(default)] pub transition: Option, } impl Config { /// Load the config from a path. pub fn load(path: &Path) -> Result { let contents = match fs::read_to_string(path) { Ok(contents) => contents, Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(ConfigLoadError::NotFound), Err(e) => return Err(e.into()), }; let config = serde_yaml::from_str(&contents)?; Ok(config) } } #[derive(Debug, thiserror::Error)] pub enum ConfigLoadError { #[error("io: {0}")] Io(#[from] io::Error), #[error("config file not found")] NotFound, #[error("invalid configuration: {0}")] Invalid(#[from] serde_yaml::Error), } #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct DefaultsConfig { /// The theme to use by default in every presentation unless overridden. pub theme: Option, /// Override the terminal font size when in windows or when using sixel. #[serde(default = "default_terminal_font_size")] #[cfg_attr(feature = "json-schema", validate(range(min = 1)))] pub terminal_font_size: u8, /// The image protocol to use. #[serde(default)] pub image_protocol: ImageProtocol, /// Validate that the presentation does not overflow the terminal screen. #[serde(default)] pub validate_overflows: ValidateOverflows, /// A max width in columns that the presentation must always be capped to. #[serde(default = "default_u16_max")] pub max_columns: u16, /// The alignment the presentation should have if `max_columns` is set and the terminal is /// larger than that. #[serde(default)] pub max_columns_alignment: MaxColumnsAlignment, /// A max height in rows that the presentation must always be capped to. #[serde(default = "default_u16_max")] pub max_rows: u16, /// The alignment the presentation should have if `max_rows` is set and the terminal is /// larger than that. #[serde(default)] pub max_rows_alignment: MaxRowsAlignment, /// The configuration for lists when incremental lists are enabled. #[serde(default)] pub incremental_lists: IncrementalListsConfig, } impl Default for DefaultsConfig { fn default() -> Self { Self { theme: Default::default(), terminal_font_size: default_terminal_font_size(), image_protocol: Default::default(), validate_overflows: Default::default(), max_columns: default_u16_max(), max_columns_alignment: Default::default(), max_rows: default_u16_max(), max_rows_alignment: Default::default(), incremental_lists: Default::default(), } } } /// The configuration for lists when incremental lists are enabled. #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct IncrementalListsConfig { /// Whether to pause before a list begins. #[serde(default)] pub pause_before: Option, /// Whether to pause after a list ends. #[serde(default)] pub pause_after: Option, } fn default_terminal_font_size() -> u8 { 16 } /// The alignment to use when `defaults.max_columns` is set. #[derive(Clone, Copy, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum MaxColumnsAlignment { /// Align the presentation to the left. Left, /// Align the presentation on the center. #[default] Center, /// Align the presentation to the right. Right, } /// The alignment to use when `defaults.max_rows` is set. #[derive(Clone, Copy, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum MaxRowsAlignment { /// Align the presentation to the top. Top, /// Align the presentation on the center. #[default] Center, /// Align the presentation to the bottom. Bottom, } #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum ValidateOverflows { #[default] Never, Always, WhenPresenting, WhenDeveloping, } #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct OptionsConfig { /// Whether slides are automatically terminated when a slide title is found. pub implicit_slide_ends: Option, /// The prefix to use for commands. pub command_prefix: Option, /// The prefix to use for image attributes. pub image_attributes_prefix: Option, /// Show all lists incrementally, by implicitly adding pauses in between elements. pub incremental_lists: Option, /// The number of newlines in between list items. pub list_item_newlines: Option, /// Whether to treat a thematic break as a slide end. pub end_slide_shorthand: Option, /// Whether to be strict about parsing the presentation's front matter. pub strict_front_matter_parsing: Option, /// Assume snippets for these languages contain `+render` and render them automatically. #[serde(default)] pub auto_render_languages: Vec, } #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct SnippetConfig { /// The properties for snippet execution. #[serde(default)] pub exec: SnippetExecConfig, /// The properties for snippet execution. #[serde(default)] pub exec_replace: SnippetExecReplaceConfig, /// The properties for snippet auto rendering. #[serde(default)] pub render: SnippetRenderConfig, /// Whether to validate snippets. #[serde(default)] pub validate: bool, } #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct SnippetExecConfig { /// Whether to enable snippet execution. #[serde(default)] pub enable: bool, /// Custom snippet executors. #[serde(default)] pub custom: BTreeMap, } #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct SnippetExecReplaceConfig { /// Whether to enable snippet replace-executions, which automatically run code snippets without /// the user's intervention. pub enable: bool, } #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct SnippetRenderConfig { /// The number of threads to use when rendering. #[serde(default = "default_snippet_render_threads")] pub threads: usize, } impl Default for SnippetRenderConfig { fn default() -> Self { Self { threads: default_snippet_render_threads() } } } pub(crate) fn default_snippet_render_threads() -> usize { 2 } #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct TypstConfig { /// The pixels per inch when rendering latex/typst formulas. #[serde(default = "default_typst_ppi")] pub ppi: u32, } impl Default for TypstConfig { fn default() -> Self { Self { ppi: default_typst_ppi() } } } pub(crate) fn default_typst_ppi() -> u32 { 300 } #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct MermaidConfig { /// The scaling parameter to be used in the mermaid CLI. #[serde(default = "default_mermaid_scale")] pub scale: u32, } impl Default for MermaidConfig { fn default() -> Self { Self { scale: default_mermaid_scale() } } } pub(crate) fn default_mermaid_scale() -> u32 { 2 } #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct D2Config { /// The scaling parameter to be used in the d2 CLI. #[serde(default)] pub scale: Option, } pub(crate) fn default_u16_max() -> u16 { u16::MAX } /// The snippet execution configuration for a specific programming language. #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct LanguageSnippetExecutionConfig { #[serde(flatten)] pub executor: SnippetExecutorConfig, /// The prefix to use to hide lines visually but still execute them. pub hidden_line_prefix: Option, /// Alternative executors for this language. #[serde(default)] pub alternative: HashMap, } /// A snippet executor configuration. #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SnippetExecutorConfig { /// The filename to use for the snippet input file. pub filename: String, /// The environment variables to set before invoking every command. #[serde(default)] pub environment: HashMap, /// The commands to be ran when executing snippets for this programming language. pub commands: Vec>, } #[derive(Clone, Debug, Default, Deserialize, ValueEnum)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "kebab-case")] pub enum ImageProtocol { /// Automatically detect the best image protocol to use. #[default] Auto, /// Use the iTerm2 image protocol. Iterm2, /// Use the iTerm2 image protocol in multipart mode. Iterm2Multipart, /// Use the kitty protocol in "local" mode, meaning both presenterm and the terminal run in the /// same host and can share the filesystem to communicate. KittyLocal, /// Use the kitty protocol in "remote" mode, meaning presenterm and the terminal run in /// different hosts and therefore can only communicate via terminal escape codes. KittyRemote, /// Use the sixel protocol. Note that this requires compiling presenterm using the --features /// sixel flag. Sixel, /// The default image protocol to use when no other is specified. AsciiBlocks, } pub struct SixelUnsupported; impl TryFrom<&ImageProtocol> for GraphicsMode { type Error = SixelUnsupported; fn try_from(protocol: &ImageProtocol) -> Result { let mode = match protocol { ImageProtocol::Auto => { let emulator = TerminalEmulator::detect(); emulator.preferred_protocol() } ImageProtocol::Iterm2 => GraphicsMode::Iterm2, ImageProtocol::Iterm2Multipart => GraphicsMode::Iterm2Multipart, ImageProtocol::KittyLocal => GraphicsMode::Kitty { mode: KittyMode::Local }, ImageProtocol::KittyRemote => GraphicsMode::Kitty { mode: KittyMode::Remote }, ImageProtocol::AsciiBlocks => GraphicsMode::AsciiBlocks, #[cfg(feature = "sixel")] ImageProtocol::Sixel => GraphicsMode::Sixel, #[cfg(not(feature = "sixel"))] ImageProtocol::Sixel => return Err(SixelUnsupported), }; Ok(mode) } } #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct KeyBindingsConfig { /// The keys that cause the presentation to move forwards. #[serde(default = "default_next_bindings")] pub(crate) next: Vec, /// The keys that cause the presentation to jump to the next slide "fast". /// /// "fast" means for slides that contain pauses, we will skip all pauses and jump straight to /// the next slide. #[serde(default = "default_next_fast_bindings")] pub(crate) next_fast: Vec, /// The keys that cause the presentation to move backwards. #[serde(default = "default_previous_bindings")] pub(crate) previous: Vec, /// The keys that cause the presentation to move backwards "fast". /// /// "fast" means for slides that contain pauses, we will skip all pauses and jump straight to /// the previous slide. #[serde(default = "default_previous_fast_bindings")] pub(crate) previous_fast: Vec, /// The key binding to jump to the first slide. #[serde(default = "default_first_slide_bindings")] pub(crate) first_slide: Vec, /// The key binding to jump to the last slide. #[serde(default = "default_last_slide_bindings")] pub(crate) last_slide: Vec, /// The key binding to jump to a specific slide. #[serde(default = "default_go_to_slide_bindings")] pub(crate) go_to_slide: Vec, /// The key binding to execute a piece of shell code. #[serde(default = "default_execute_code_bindings")] pub(crate) execute_code: Vec, /// The key binding to reload the presentation. #[serde(default = "default_reload_bindings")] pub(crate) reload: Vec, /// The key binding to toggle the slide index modal. #[serde(default = "default_toggle_index_bindings")] pub(crate) toggle_slide_index: Vec, /// The key binding to toggle the key bindings modal. #[serde(default = "default_toggle_bindings_modal_bindings")] pub(crate) toggle_bindings: Vec, /// The key binding to close the currently open modal. #[serde(default = "default_close_modal_bindings")] pub(crate) close_modal: Vec, /// The key binding to close the application. #[serde(default = "default_exit_bindings")] pub(crate) exit: Vec, /// The key binding to suspend the application. #[serde(default = "default_suspend_bindings")] pub(crate) suspend: Vec, /// The key binding to show the entire slide, after skipping any pauses in it. #[serde(default = "default_skip_pauses")] pub(crate) skip_pauses: Vec, } impl Default for KeyBindingsConfig { fn default() -> Self { Self { next: default_next_bindings(), next_fast: default_next_fast_bindings(), previous: default_previous_bindings(), previous_fast: default_previous_fast_bindings(), first_slide: default_first_slide_bindings(), last_slide: default_last_slide_bindings(), go_to_slide: default_go_to_slide_bindings(), execute_code: default_execute_code_bindings(), reload: default_reload_bindings(), toggle_slide_index: default_toggle_index_bindings(), toggle_bindings: default_toggle_bindings_modal_bindings(), close_modal: default_close_modal_bindings(), exit: default_exit_bindings(), suspend: default_suspend_bindings(), skip_pauses: default_skip_pauses(), } } } #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct SpeakerNotesConfig { /// The address in which to listen for speaker note events. #[serde(default = "default_speaker_notes_listen_address")] pub listen_address: SocketAddr, /// The address in which to publish speaker notes events. #[serde(default = "default_speaker_notes_publish_address")] pub publish_address: SocketAddr, /// Whether to always publish speaker notes. #[serde(default)] pub always_publish: bool, } impl Default for SpeakerNotesConfig { fn default() -> Self { Self { listen_address: default_speaker_notes_listen_address(), publish_address: default_speaker_notes_publish_address(), always_publish: false, } } } /// The export configuration. #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct ExportConfig { /// The dimensions to use for presentation exports. pub dimensions: Option, /// Whether pauses should create new slides. #[serde(default)] pub pauses: PauseExportPolicy, /// The policy for executable snippets when exporting. #[serde(default)] pub snippets: SnippetsExportPolicy, /// The PDF specific export configs. #[serde(default)] pub pdf: PdfExportConfig, } /// The policy for pauses when exporting. #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields, rename_all = "snake_case")] pub enum PauseExportPolicy { /// Whether to ignore pauses. #[default] Ignore, /// Create a new slide when a pause is found. NewSlide, } /// The policy for executable snippets when exporting. #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields, rename_all = "snake_case")] pub enum SnippetsExportPolicy { /// Render all executable snippets in parallel. #[default] Parallel, /// Render all executable snippets sequentially. Sequential, } /// The dimensions to use for presentation exports. #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct ExportDimensionsConfig { /// The number of rows. pub rows: u16, /// The number of columns. pub columns: u16, } /// The PDF export specific configs. #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields, rename_all = "snake_case")] pub struct PdfExportConfig { /// The path to the font file to be used. pub fonts: Option, } /// The fonts used for exports. #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields, rename_all = "snake_case")] pub struct ExportFontsConfig { /// The path to the font file to be used for the "normal" variable of this font. pub normal: PathBuf, /// The path to the font file to be used for the "bold" variable of this font. pub bold: Option, /// The path to the font file to be used for the "italic" variable of this font. pub italic: Option, /// The path to the font file to be used for the "bold+italic" variable of this font. pub bold_italic: Option, } // The slide transition configuration. #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "style", deny_unknown_fields)] pub struct SlideTransitionConfig { /// The amount of time to take to perform the transition. #[serde(default = "default_transition_duration_millis")] pub duration_millis: u16, /// The number of frames in a transition. #[serde(default = "default_transition_frames")] pub frames: usize, /// The slide transition style. pub animation: SlideTransitionStyleConfig, } // The slide transition style configuration. #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "style", rename_all = "snake_case", deny_unknown_fields)] pub enum SlideTransitionStyleConfig { /// Slide horizontally. SlideHorizontal, /// Fade the new slide into the previous one. Fade, /// Collapse the current slide into the center of the screen. CollapseHorizontal, } fn make_keybindings(raw_bindings: [&str; N]) -> Vec { let mut bindings = Vec::new(); for binding in raw_bindings { bindings.push(binding.parse().expect("invalid binding")); } bindings } fn default_next_bindings() -> Vec { make_keybindings(["l", "j", "", "", "", " "]) } fn default_next_fast_bindings() -> Vec { make_keybindings(["n"]) } fn default_previous_bindings() -> Vec { make_keybindings(["h", "k", "", "", ""]) } fn default_previous_fast_bindings() -> Vec { make_keybindings(["p"]) } fn default_first_slide_bindings() -> Vec { make_keybindings(["gg"]) } fn default_last_slide_bindings() -> Vec { make_keybindings(["G"]) } fn default_go_to_slide_bindings() -> Vec { make_keybindings(["G"]) } fn default_execute_code_bindings() -> Vec { make_keybindings([""]) } fn default_reload_bindings() -> Vec { make_keybindings([""]) } fn default_toggle_index_bindings() -> Vec { make_keybindings([""]) } fn default_toggle_bindings_modal_bindings() -> Vec { make_keybindings(["?"]) } fn default_close_modal_bindings() -> Vec { make_keybindings([""]) } fn default_exit_bindings() -> Vec { make_keybindings(["", "q"]) } fn default_suspend_bindings() -> Vec { make_keybindings([""]) } fn default_skip_pauses() -> Vec { make_keybindings(["s"]) } fn default_transition_duration_millis() -> u16 { 1000 } fn default_transition_frames() -> usize { 30 } #[cfg(target_os = "linux")] pub(crate) fn default_speaker_notes_listen_address() -> SocketAddr { SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255)), 59418) } #[cfg(not(target_os = "linux"))] pub(crate) fn default_speaker_notes_listen_address() -> SocketAddr { SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 59418) } #[cfg(not(target_os = "macos"))] pub(crate) fn default_speaker_notes_publish_address() -> SocketAddr { SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255)), 59418) } #[cfg(target_os = "macos")] pub(crate) fn default_speaker_notes_publish_address() -> SocketAddr { SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 59418) } #[cfg(test)] mod test { use super::*; use crate::commands::keyboard::CommandKeyBindings; #[test] fn default_bindings() { let config = KeyBindingsConfig::default(); CommandKeyBindings::try_from(config).expect("construction failed"); } #[test] fn default_options_serde() { serde_yaml::from_str::<'_, OptionsConfig>("implicit_slide_ends: true").expect("failed to parse"); } } presenterm-0.15.1/src/demo.rs000064400000000000000000000112371046102023000141610ustar 00000000000000use crate::{ ImageRegistry, MarkdownParser, PresentationBuilderOptions, Resources, ThemeOptions, Themes, ThirdPartyRender, code::execute::SnippetExecutor, commands::{ keyboard::{CommandKeyBindings, KeyboardListener}, listener::Command, }, markdown::elements::MarkdownElement, presentation::{ Presentation, builder::{PresentationBuilder, error::BuildError}, }, render::TerminalDrawer, terminal::emulator::TerminalEmulator, theme::raw::PresentationTheme, }; use std::{io, sync::Arc}; const PRESENTATION: &str = r#" # Header 1 ## Header 2 ### Header 3 #### Header 4 ##### Header 5 ###### Header 6 ```rust fn greet(name: &str) -> String { format!("hi {name}") } ```` * **bold text** * _italics_ * `some inline code` * ~strikethrough~ > a block quote "#; pub struct ThemesDemo { themes: Themes, input: KeyboardListener, drawer: TerminalDrawer, } impl ThemesDemo { pub fn new(themes: Themes, bindings: CommandKeyBindings) -> io::Result { let input = KeyboardListener::new(bindings); let drawer = TerminalDrawer::new(Default::default(), Default::default())?; Ok(Self { themes, input, drawer }) } pub fn run(mut self) -> Result<(), Box> { let arena = Default::default(); let parser = MarkdownParser::new(&arena); let elements = parser.parse(PRESENTATION).expect("broken demo presentation"); let mut presentations = Vec::new(); for theme_name in self.themes.presentation.theme_names() { let theme = self.themes.presentation.load_by_name(&theme_name).expect("theme not found"); let presentation = self.build(&elements, &theme_name, &theme)?; presentations.push(presentation); } let mut current = 0; loop { self.drawer.render_operations(presentations[current].current_slide().iter_visible_operations())?; let command = self.next_command()?; match command { DemoCommand::Next => current = (current + 1).min(presentations.len() - 1), DemoCommand::Previous => current = current.saturating_sub(1), DemoCommand::First => current = 0, DemoCommand::Last => current = presentations.len() - 1, DemoCommand::Exit => return Ok(()), }; } } fn next_command(&mut self) -> io::Result { loop { let mut command = self.input.next_command()?; while command.is_none() { command = self.input.next_command()?; } match command.unwrap() { Command::Next => return Ok(DemoCommand::Next), Command::Previous => return Ok(DemoCommand::Previous), Command::FirstSlide => return Ok(DemoCommand::First), Command::LastSlide => return Ok(DemoCommand::Last), Command::Exit => return Ok(DemoCommand::Exit), _ => continue, } } } fn build( &self, base_elements: &[MarkdownElement], theme_name: &str, theme: &PresentationTheme, ) -> Result { let image_registry = ImageRegistry::default(); let resources = Resources::new("non_existent", "non_existent", image_registry.clone()); let mut third_party = ThirdPartyRender::default(); let options = PresentationBuilderOptions { theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size }, ..Default::default() }; let executer = Arc::new(SnippetExecutor::default()); let bindings_config = Default::default(); let arena = Default::default(); let parser = MarkdownParser::new(&arena); let builder = PresentationBuilder::new( theme, resources, &mut third_party, executer, &self.themes, image_registry, bindings_config, &parser, options, )?; let mut elements = vec![MarkdownElement::SetexHeading { text: vec![format!("theme: {theme_name}").into()] }]; elements.extend(base_elements.iter().cloned()); builder.build_from_parsed(elements) } } enum DemoCommand { Next, Previous, First, Last, Exit, } #[cfg(test)] mod test { use super::*; #[test] fn demo_presentation() { let arena = Default::default(); let parser = MarkdownParser::new(&arena); parser.parse(PRESENTATION).expect("broken demo presentation"); } } presenterm-0.15.1/src/export/exporter.rs000064400000000000000000000266551046102023000164400ustar 00000000000000use crate::{ MarkdownParser, Resources, code::execute::SnippetExecutor, config::{KeyBindingsConfig, PauseExportPolicy, PdfExportConfig, SnippetsExportPolicy}, export::output::{ExportRenderer, OutputFormat}, markdown::text_style::Color, presentation::{ Presentation, builder::{PresentationBuilder, PresentationBuilderOptions, Themes, error::BuildError}, poller::{Poller, PollerCommand}, }, render::{ RenderError, operation::{AsRenderOperations, PollableState, RenderOperation}, properties::WindowSize, }, theme::{ProcessingThemeError, raw::PresentationTheme}, third_party::ThirdPartyRender, tools::{ExecutionError, ThirdPartyTools}, }; use crossterm::{ cursor::{MoveToColumn, MoveToNextLine, MoveUp}, execute, style::{Print, PrintStyledContent, Stylize}, terminal::{Clear, ClearType}, }; use image::ImageError; use std::{ fs, io, path::{Path, PathBuf}, rc::Rc, sync::Arc, }; use tempfile::TempDir; pub enum OutputDirectory { Temporary(TempDir), External(PathBuf), } impl OutputDirectory { pub fn temporary() -> io::Result { let dir = TempDir::with_suffix("presenterm")?; Ok(Self::Temporary(dir)) } pub fn external(path: PathBuf) -> io::Result { fs::create_dir_all(&path)?; Ok(Self::External(path)) } pub(crate) fn path(&self) -> &Path { match self { Self::Temporary(temp) => temp.path(), Self::External(path) => path, } } } /// Allows exporting presentations into PDF. pub struct Exporter<'a> { parser: MarkdownParser<'a>, default_theme: &'a PresentationTheme, resources: Resources, third_party: ThirdPartyRender, code_executor: Arc, themes: Themes, dimensions: WindowSize, options: PresentationBuilderOptions, snippet_policy: SnippetsExportPolicy, } impl<'a> Exporter<'a> { /// Construct a new exporter. #[allow(clippy::too_many_arguments)] pub fn new( parser: MarkdownParser<'a>, default_theme: &'a PresentationTheme, resources: Resources, third_party: ThirdPartyRender, code_executor: Arc, themes: Themes, mut options: PresentationBuilderOptions, mut dimensions: WindowSize, pause_policy: PauseExportPolicy, snippet_policy: SnippetsExportPolicy, ) -> Self { // We don't want dynamically highlighted code blocks. options.allow_mutations = false; options.theme_options.font_size_supported = true; options.pause_create_new_slide = match pause_policy { PauseExportPolicy::Ignore => false, PauseExportPolicy::NewSlide => true, }; // Make sure we have a 1:2 aspect ratio. let width = (0.5 * dimensions.columns as f64) / (dimensions.rows as f64 / dimensions.height as f64); dimensions.width = width as u16; Self { parser, default_theme, resources, third_party, code_executor, themes, options, dimensions, snippet_policy, } } fn build_renderer( &mut self, presentation_path: &Path, output_directory: OutputDirectory, renderer: OutputFormat, ) -> Result { let mut presentation = PresentationBuilder::new( self.default_theme, self.resources.clone(), &mut self.third_party, self.code_executor.clone(), &self.themes, Default::default(), KeyBindingsConfig::default(), &self.parser, self.options.clone(), )? .build(presentation_path)?; Self::validate_theme_colors(&presentation)?; let mut render = ExportRenderer::new(self.dimensions, output_directory, renderer); Self::log("waiting for images to be generated and code to be executed, if any...")?; match self.snippet_policy { SnippetsExportPolicy::Parallel => Self::wait_async_renders_parallel(&mut presentation), SnippetsExportPolicy::Sequential => Self::wait_async_renders_sequential(&mut presentation), }; for (index, slide) in presentation.into_slides().into_iter().enumerate() { let index = index + 1; Self::log(&format!("processing slide {index}..."))?; render.process_slide(slide)?; } Self::log("invoking weasyprint...")?; Ok(render) } /// Export the given presentation into PDF. pub fn export_pdf( mut self, presentation_path: &Path, output_directory: OutputDirectory, output_path: Option<&Path>, config: PdfExportConfig, ) -> Result<(), ExportError> { println!( "exporting using rows={}, columns={}, width={}, height={}", self.dimensions.rows, self.dimensions.columns, self.dimensions.width, self.dimensions.height ); println!("checking for weasyprint..."); Self::validate_weasyprint_exists()?; Self::log("weasyprint installation found")?; let render = self.build_renderer(presentation_path, output_directory, OutputFormat::Pdf)?; let pdf_path = match output_path { Some(path) => path.to_path_buf(), None => presentation_path.with_extension("pdf"), }; render.generate(&pdf_path, &config.fonts)?; execute!( io::stdout(), PrintStyledContent( format!("output file is at {}\n", pdf_path.display()).stylize().with(Color::Green.into()) ) )?; Ok(()) } /// Export the given presentation into HTML. pub fn export_html( mut self, presentation_path: &Path, output_directory: OutputDirectory, output_path: Option<&Path>, ) -> Result<(), ExportError> { println!( "exporting using rows={}, columns={}, width={}, height={}", self.dimensions.rows, self.dimensions.columns, self.dimensions.width, self.dimensions.height ); let render = self.build_renderer(presentation_path, output_directory, OutputFormat::Html)?; let output_path = match output_path { Some(path) => path.to_path_buf(), None => presentation_path.with_extension("html"), }; render.generate(&output_path, &None)?; execute!( io::stdout(), PrintStyledContent( format!("output file is at {}\n", output_path.display()).stylize().with(Color::Green.into()) ) )?; Ok(()) } fn wait_async_renders_parallel(presentation: &mut Presentation) { let poller = Poller::launch(); let mut pollables = Vec::new(); for (index, slide) in presentation.iter_slides().enumerate() { for op in slide.iter_operations() { if let RenderOperation::RenderAsync(inner) = op { // Send a pollable to the poller and keep one for ourselves. poller.send(PollerCommand::Poll { pollable: inner.pollable(), slide: index }); pollables.push(inner.pollable()) } } } // Poll until they're all done for mut pollable in pollables { while let PollableState::Unmodified | PollableState::Modified = pollable.poll() {} } // Replace render asyncs with new operations that contains the replaced image // and any other unmodified operations. for slide in presentation.iter_slides_mut() { for op in slide.iter_operations_mut() { if let RenderOperation::RenderAsync(inner) = op { let window_size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 }; let new_operations = inner.as_render_operations(&window_size); *op = RenderOperation::RenderDynamic(Rc::new(RenderMany(new_operations))); } } } } fn wait_async_renders_sequential(presentation: &mut Presentation) { let poller = Poller::launch(); for (index, slide) in presentation.iter_slides_mut().enumerate() { for op in slide.iter_operations_mut() { if let RenderOperation::RenderAsync(inner) = op { // Send a pollable to the poller poller.send(PollerCommand::Poll { pollable: inner.pollable(), slide: index }); // Poll until it's done let mut pollable = inner.pollable(); while let PollableState::Unmodified | PollableState::Modified = pollable.poll() {} // Replace it with its contents let window_size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 }; let new_operations = inner.as_render_operations(&window_size); *op = RenderOperation::RenderDynamic(Rc::new(RenderMany(new_operations))); } } } } fn validate_weasyprint_exists() -> Result<(), ExportError> { let result = ThirdPartyTools::weasyprint(&["--version"]).run_and_capture_stdout(); match result { Ok(_) => Ok(()), Err(ExecutionError::Execution { .. }) => Err(ExportError::WeasyprintMissing), Err(e) => Err(e.into()), } } fn validate_theme_colors(presentation: &Presentation) -> Result<(), ExportError> { for slide in presentation.iter_slides() { for operation in slide.iter_visible_operations() { let RenderOperation::SetColors(colors) = operation else { continue; }; // The PDF requires a specific theme to be set, as "no background" means "what the // browser uses" which is likely white and it will probably look terrible. It's // better to err early and let you choose a theme that contains _some_ color. if colors.background.is_none() { return Err(ExportError::UnsupportedColor("background")); } if colors.foreground.is_none() { return Err(ExportError::UnsupportedColor("foreground")); } } } Ok(()) } fn log(text: &str) -> io::Result<()> { execute!( io::stdout(), MoveUp(1), Clear(ClearType::CurrentLine), MoveToColumn(0), Print(text), MoveToNextLine(1) ) } } #[derive(thiserror::Error, Debug)] pub enum ExportError { #[error("failed to build presentation: {0}")] BuildPresentation(#[from] BuildError), #[error("unsupported {0} color in theme")] UnsupportedColor(&'static str), #[error("generating images: {0}")] GeneratingImages(#[from] ImageError), #[error(transparent)] Execution(#[from] ExecutionError), #[error("weasyprint not found")] WeasyprintMissing, #[error("processing theme: {0}")] ProcessingTheme(#[from] ProcessingThemeError), #[error("io: {0}")] Io(#[from] io::Error), #[error("render: {0}")] Render(#[from] RenderError), } #[derive(Debug)] struct RenderMany(Vec); impl AsRenderOperations for RenderMany { fn as_render_operations(&self, _: &WindowSize) -> Vec { self.0.clone() } } presenterm-0.15.1/src/export/html.rs000064400000000000000000000114511046102023000155200ustar 00000000000000use crate::markdown::text_style::{Color, TextAttribute, TextStyle}; use std::{borrow::Cow, fmt}; pub(crate) enum HtmlText { Plain(String), Styled { text: String, style: String }, } impl HtmlText { pub(crate) fn new(text: &str, style: &TextStyle, font_size: FontSize) -> Self { let mut text = text.to_string(); if style == &TextStyle::default() { return Self::Plain(text); } let mut css_styles = Vec::new(); let mut text_decorations = Vec::new(); for attr in style.iter_attributes() { match attr { TextAttribute::Bold => css_styles.push(Cow::Borrowed("font-weight: bold")), TextAttribute::Italics => css_styles.push(Cow::Borrowed("font-style: italic")), TextAttribute::Strikethrough => text_decorations.push(Cow::Borrowed("line-through")), TextAttribute::Underlined => text_decorations.push(Cow::Borrowed("underline")), TextAttribute::Superscript => text = format!("{text}"), TextAttribute::ForegroundColor(color) => { let color = color_to_html(&color); css_styles.push(format!("color: {color}").into()); } TextAttribute::BackgroundColor(color) => { let color = color_to_html(&color); css_styles.push(format!("background-color: {color}").into()); } }; } if !text_decorations.is_empty() { let text_decoration = text_decorations.join(" "); css_styles.push(format!("text-decoration: {text_decoration}").into()); } if style.size > 1 { let font_size = font_size.scale(style.size); css_styles.push(format!("font-size: {font_size}").into()); } let css_style = css_styles.join("; "); Self::Styled { text, style: css_style } } } impl fmt::Display for HtmlText { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Plain(text) => write!(f, "{text}"), Self::Styled { text, style } => write!(f, "{text}"), } } } pub(crate) enum FontSize { Pixels(u16), } impl FontSize { fn scale(&self, size: u8) -> String { match self { Self::Pixels(scale) => format!("{}px", scale * size as u16), } } } pub(crate) fn color_to_html(color: &Color) -> String { match color { Color::Black => "#000000".into(), Color::DarkGrey => "#5a5a5a".into(), Color::Red => "#ff0000".into(), Color::DarkRed => "#8b0000".into(), Color::Green => "#00ff00".into(), Color::DarkGreen => "#006400".into(), Color::Yellow => "#ffff00".into(), Color::DarkYellow => "#8b8000".into(), Color::Blue => "#0000ff".into(), Color::DarkBlue => "#00008b".into(), Color::Magenta => "#ff00ff".into(), Color::DarkMagenta => "#8b008b".into(), Color::Cyan => "#00ffff".into(), Color::DarkCyan => "#008b8b".into(), Color::White => "#ffffff".into(), Color::Grey => "#808080".into(), Color::Rgb { r, g, b } => format!("#{r:02x}{g:02x}{b:02x}"), } } #[cfg(test)] mod test { use super::*; use rstest::rstest; #[rstest] #[case::none(TextStyle::default(), "")] #[case::bold(TextStyle::default().bold(), "font-weight: bold")] #[case::italics(TextStyle::default().italics(), "font-style: italic")] #[case::bold_italics(TextStyle::default().bold().italics(), "font-weight: bold; font-style: italic")] #[case::strikethrough(TextStyle::default().strikethrough(), "text-decoration: line-through")] #[case::underlined(TextStyle::default().underlined(), "text-decoration: underline")] #[case::strikethrough_underlined( TextStyle::default().strikethrough().underlined(), "text-decoration: line-through underline" )] #[case::foreground_color(TextStyle::default().fg_color(Color::new(1,2,3)), "color: #010203")] #[case::background_color(TextStyle::default().bg_color(Color::new(1,2,3)), "background-color: #010203")] #[case::font_size(TextStyle::default().size(3), "font-size: 6px")] fn html_text(#[case] style: TextStyle, #[case] expected_style: &str) { let html_text = HtmlText::new("", &style, FontSize::Pixels(2)); let style = match &html_text { HtmlText::Plain(_) => "", HtmlText::Styled { style, .. } => style, }; assert_eq!(style, expected_style); } #[test] fn render_span() { let html_text = HtmlText::new("hi", &TextStyle::default().bold(), FontSize::Pixels(1)); let rendered = html_text.to_string(); assert_eq!(rendered, "hi"); } } presenterm-0.15.1/src/export/mod.rs000064400000000000000000000000761046102023000153340ustar 00000000000000pub mod exporter; pub(crate) mod html; pub(crate) mod output; presenterm-0.15.1/src/export/output.rs000064400000000000000000000223501046102023000161140ustar 00000000000000use super::{ exporter::{ExportError, OutputDirectory}, html::{FontSize, color_to_html}, }; use crate::{ config::ExportFontsConfig, export::html::HtmlText, markdown::text_style::TextStyle, presentation::Slide, render::{engine::RenderEngine, properties::WindowSize}, terminal::{ image::printer::TerminalImage, virt::{TerminalGrid, VirtualTerminal}, }, tools::ThirdPartyTools, }; use std::{ fs, io, path::{Path, PathBuf}, }; const FONT_NAME: &str = "presenterm-font"; // A magical multiplier that converts a font size in pixels to a font width. // // There's probably something somewhere that specifies what the relationship // really is but I found this by trial and error an I'm okay with that. const FONT_SIZE_WIDTH: f64 = 0.605; const FONT_SIZE: u16 = 10; const LINE_HEIGHT: u16 = 12; struct HtmlSlide { rows: Vec, background_color: Option, } impl HtmlSlide { fn new(grid: TerminalGrid) -> Result { let mut rows = Vec::new(); rows.push(String::from("

")); Ok(HtmlSlide { rows, background_color: grid.background_color.as_ref().map(color_to_html) }) } fn finalize_string(s: &str, style: &TextStyle) -> String { HtmlText::new(s, style, FontSize::Pixels(FONT_SIZE)).to_string() } } pub(crate) struct ContentManager { output_directory: OutputDirectory, } impl ContentManager { pub(crate) fn new(output_directory: OutputDirectory) -> Self { Self { output_directory } } fn persist_file(&self, name: &str, data: &[u8]) -> io::Result { let path = self.output_directory.path().join(name); fs::write(&path, data)?; Ok(path) } } pub(crate) enum OutputFormat { Pdf, Html, } pub(crate) struct ExportRenderer { content_manager: ContentManager, output_format: OutputFormat, dimensions: WindowSize, html_body: String, background_color: Option, } impl ExportRenderer { pub(crate) fn new(dimensions: WindowSize, output_directory: OutputDirectory, output_type: OutputFormat) -> Self { let image_manager = ContentManager::new(output_directory); Self { content_manager: image_manager, dimensions, html_body: "".to_string(), background_color: None, output_format: output_type, } } pub(crate) fn process_slide(&mut self, slide: Slide) -> Result<(), ExportError> { let mut terminal = VirtualTerminal::new(self.dimensions, Default::default()); let engine = RenderEngine::new(&mut terminal, self.dimensions, Default::default()); engine.render(slide.iter_operations())?; let grid = terminal.into_contents(); let slide = HtmlSlide::new(grid)?; if self.background_color.is_none() { self.background_color.clone_from(&slide.background_color); } for row in slide.rows { self.html_body.push_str(&row); self.html_body.push('\n'); } Ok(()) } pub(crate) fn generate(self, output_path: &Path, fonts: &Option) -> Result<(), ExportError> { let html_body = &self.html_body; let script = include_str!("script.js"); let width = (self.dimensions.columns as f64 * FONT_SIZE as f64 * FONT_SIZE_WIDTH).ceil(); let height = self.dimensions.rows * LINE_HEIGHT; let background_color = self.background_color.unwrap_or_else(|| "black".into()); let container = match self.output_format { OutputFormat::Pdf => String::from("display: contents;"), OutputFormat::Html => String::from( " width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; ", ), }; let FontConfig { font_face, font_family } = fonts.as_ref().map(Self::font_configs).unwrap_or_default(); let css = format!( r" pre {{ margin: 0; padding: 0; {font_family} }} span {{ display: inline-block; }} {font_face} body {{ margin: 0; font-size: {FONT_SIZE}px; line-height: {LINE_HEIGHT}px; width: {width}px; height: {height}px; transform-origin: top left; background-color: {background_color}; }} .container {{ {container} }} .content-line {{ line-height: {LINE_HEIGHT}px; height: {LINE_HEIGHT}px; margin: 0px; width: {width}px; }} .hidden {{ display: none; }} @page {{ margin: 0; height: {height}px; width: {width}px; }}" ); let html_script = match self.output_format { OutputFormat::Pdf => String::new(), OutputFormat::Html => { format!( " " ) } }; let style = match self.output_format { OutputFormat::Pdf => String::new(), OutputFormat::Html => format!( " " ), }; let html = format!( r" {style} {html_body} {html_script} " ); let html_path = self.content_manager.persist_file("index.html", html.as_bytes())?; let css_path = self.content_manager.persist_file("styles.css", css.as_bytes())?; match self.output_format { OutputFormat::Pdf => { ThirdPartyTools::weasyprint(&[ "-s", css_path.to_string_lossy().as_ref(), "--presentational-hints", "-e", "utf8", html_path.to_string_lossy().as_ref(), output_path.to_string_lossy().as_ref(), ]) .run()?; } OutputFormat::Html => { fs::write(output_path, html.as_bytes())?; } } Ok(()) } fn font_configs(config: &ExportFontsConfig) -> FontConfig { let mut font_face = Self::make_font_face(&config.normal, "normal", "normal"); if let Some(path) = &config.bold { font_face.push_str(&Self::make_font_face(path, "bold", "normal")); } if let Some(path) = &config.italic { font_face.push_str(&Self::make_font_face(path, "normal", "italic")); } if let Some(path) = &config.bold_italic { font_face.push_str(&Self::make_font_face(path, "bold", "italic")); } let font_family = format!("font-family: {FONT_NAME}"); FontConfig { font_face, font_family } } fn make_font_face(path: &Path, weight: &str, style: &str) -> String { let path = path.display(); format!( r" @font-face {{ font-family: {FONT_NAME}; src: url(file://{path}); font-weight: {weight}; font-style: {style}; }}" ) } } #[derive(Default)] struct FontConfig { font_face: String, font_family: String, } presenterm-0.15.1/src/export/script.js000064400000000000000000000023401046102023000160450ustar 00000000000000document.addEventListener('DOMContentLoaded', function() { const allLines = document.querySelectorAll('body > div'); const pageBreakMarkers = document.querySelectorAll('.container'); let currentPageIndex = 0; function showCurrentPage() { allLines.forEach((line) => { line.classList.add('hidden'); }); allLines[currentPageIndex].classList.remove('hidden'); } function scaler() { var w = document.documentElement.clientWidth; var h = document.documentElement.clientHeight; let widthScaledAmount= w/originalWidth; let heightScaledAmount= h/originalHeight; let scaledAmount = Math.min(widthScaledAmount, heightScaledAmount); document.querySelector("body").style.transform = `scale(${scaledAmount})`; } function handleKeyPress(event) { if (event.key === 'ArrowLeft') { if (currentPageIndex > 0) { currentPageIndex--; showCurrentPage(); } } else if (event.key === 'ArrowRight') { if (currentPageIndex < pageBreakMarkers.length - 1) { currentPageIndex++; showCurrentPage(); } } } document.addEventListener('keydown', handleKeyPress); window.addEventListener("resize", scaler); scaler(); showCurrentPage(); }); presenterm-0.15.1/src/main.rs000064400000000000000000000445211046102023000141630ustar 00000000000000use crate::{ code::{execute::SnippetExecutor, highlighting::HighlightThemeSet}, commands::listener::CommandListener, config::{Config, ImageProtocol, ValidateOverflows}, demo::ThemesDemo, export::exporter::Exporter, markdown::parse::MarkdownParser, presentation::builder::{PresentationBuilderOptions, Themes}, presenter::{PresentMode, Presenter, PresenterOptions}, resource::Resources, terminal::{ GraphicsMode, image::printer::{ImagePrinter, ImageRegistry}, }, theme::{raw::PresentationTheme, registry::PresentationThemeRegistry}, third_party::{ThirdPartyConfigs, ThirdPartyRender}, }; use anyhow::anyhow; use clap::{CommandFactory, Parser, error::ErrorKind}; use commands::speaker_notes::{SpeakerNotesEventListener, SpeakerNotesEventPublisher}; use comrak::Arena; use config::ConfigLoadError; use crossterm::{ execute, style::{PrintStyledContent, Stylize}, }; use directories::ProjectDirs; use export::exporter::OutputDirectory; use render::{engine::MaxSize, properties::WindowSize}; use std::{ env::{self, current_dir}, io, path::{Path, PathBuf}, sync::Arc, }; use terminal::emulator::TerminalEmulator; use theme::ThemeOptions; mod code; mod commands; mod config; mod demo; mod export; mod markdown; mod presentation; mod presenter; mod render; mod resource; mod terminal; mod theme; mod third_party; mod tools; mod transitions; mod ui; mod utils; const DEFAULT_THEME: &str = "dark"; const DEFAULT_EXPORT_PIXELS_PER_COLUMN: u16 = 20; const DEFAULT_EXPORT_PIXELS_PER_ROW: u16 = DEFAULT_EXPORT_PIXELS_PER_COLUMN * 2; /// Run slideshows from your terminal. #[derive(Parser)] #[command()] #[command(author, version, about = create_splash(), arg_required_else_help = true)] struct Cli { /// The path to the markdown file that contains the presentation. #[clap(group = "target")] path: Option, /// Export the presentation as a PDF rather than displaying it. #[clap(short, long, group = "export")] export_pdf: bool, /// Export the presentation as a HTML rather than displaying it. #[clap(short = 'E', long, group = "export")] export_html: bool, /// The path in which to store temporary files used when exporting. #[clap(long, requires = "export")] export_temporary_path: Option, /// The output path for the exported PDF. #[clap(short = 'o', long = "output", requires = "export")] export_output: Option, /// Generate a JSON schema for the configuration file. #[clap(long)] #[cfg(feature = "json-schema")] generate_config_file_schema: bool, /// Use presentation mode. #[clap(short, long, default_value_t = false)] present: bool, /// The theme to use. #[clap(short, long)] theme: Option, /// List all supported themes. #[clap(long, group = "target")] list_themes: bool, /// Print the theme in use. #[clap(long, group = "target")] current_theme: bool, /// Display acknowledgements. #[clap(long, group = "target")] acknowledgements: bool, /// The image protocol to use. #[clap(long)] image_protocol: Option, /// Validate that the presentation does not overflow the terminal screen. #[clap(long)] validate_overflows: bool, /// Enable code snippet execution. #[clap(short = 'x', long)] enable_snippet_execution: bool, /// Enable code snippet auto execution via `+exec_replace` blocks. #[clap(short = 'X', long)] enable_snippet_execution_replace: bool, /// The path to the configuration file. #[clap(short, long, env = "PRESENTERM_CONFIG_FILE")] config_file: Option, /// Whether to publish speaker notes to local listeners. #[clap(short = 'P', long, group = "speaker-notes")] publish_speaker_notes: bool, /// Whether to listen for speaker notes. #[clap(short, long, group = "speaker-notes")] listen_speaker_notes: bool, /// Whether to validate snippets. #[clap(long)] validate_snippets: bool, } fn create_splash() -> String { let crate_version = env!("CARGO_PKG_VERSION"); format!( r#" ┌─┐┬─┐┌─┐┌─┐┌─┐┌┐┌┌┬┐┌─┐┬─┐┌┬┐ ├─┘├┬┘├┤ └─┐├┤ │││ │ ├┤ ├┬┘│││ ┴ ┴└─└─┘└─┘└─┘┘└┘ ┴ └─┘┴└─┴ ┴ v{crate_version} A terminal slideshow tool @mfontanini/presenterm "#, ) } #[derive(Default)] struct Customizations { config: Config, themes: Themes, themes_path: Option, code_executor: SnippetExecutor, } impl Customizations { fn load(config_file_path: Option, cwd: &Path) -> Result> { let configs_path: PathBuf = match env::var("XDG_CONFIG_HOME") { Ok(path) => Path::new(&path).join("presenterm"), Err(_) => { let Some(project_dirs) = ProjectDirs::from("", "", "presenterm") else { return Ok(Default::default()); }; project_dirs.config_dir().into() } }; let themes_path = configs_path.join("themes"); let themes = Self::load_themes(&themes_path)?; let require_config_file = config_file_path.is_some(); let config_file_path = config_file_path.unwrap_or_else(|| configs_path.join("config.yaml")); let config = match Config::load(&config_file_path) { Ok(config) => config, Err(ConfigLoadError::NotFound) if !require_config_file => Default::default(), Err(e) => return Err(e.into()), }; let code_executor = SnippetExecutor::new(config.snippet.exec.custom.clone(), cwd.to_path_buf())?; Ok(Customizations { config, themes, themes_path: Some(themes_path), code_executor }) } fn load_themes(themes_path: &Path) -> Result> { let mut highlight_themes = HighlightThemeSet::default(); highlight_themes.register_from_directory(themes_path.join("highlighting"))?; let mut presentation_themes = PresentationThemeRegistry::default(); presentation_themes.register_from_directory(themes_path)?; let themes = Themes { presentation: presentation_themes, highlight: highlight_themes }; Ok(themes) } } struct CoreComponents { third_party: ThirdPartyRender, code_executor: Arc, resources: Resources, printer: Arc, builder_options: PresentationBuilderOptions, themes: Themes, default_theme: PresentationTheme, config: Config, present_mode: PresentMode, graphics_mode: GraphicsMode, } impl CoreComponents { fn new(cli: &Cli, path: &Path) -> Result> { let mut resources_path = path.parent().unwrap_or(Path::new("./")).to_path_buf(); if resources_path == Path::new("") { resources_path = "./".into(); } let resources_path = resources_path.canonicalize().unwrap_or(resources_path); let Customizations { config, themes, code_executor, themes_path } = Customizations::load(cli.config_file.clone().map(PathBuf::from), &resources_path)?; let default_theme = Self::load_default_theme(&config, &themes, cli); let force_default_theme = cli.theme.is_some(); let present_mode = match (cli.present, cli.export_pdf) { (true, _) | (_, true) => PresentMode::Presentation, (false, false) => PresentMode::Development, }; let mut builder_options = Self::make_builder_options(&config, force_default_theme, cli.listen_speaker_notes); if cli.enable_snippet_execution { builder_options.enable_snippet_execution = true; } if cli.enable_snippet_execution_replace { builder_options.enable_snippet_execution_replace = true; } let graphics_mode = Self::select_graphics_mode(cli, &config); let printer = Arc::new(ImagePrinter::new(graphics_mode.clone())?); let registry = ImageRegistry::new(printer.clone()); let resources = Resources::new( resources_path.clone(), themes_path.unwrap_or_else(|| resources_path.clone()), registry.clone(), ); let third_party_config = ThirdPartyConfigs { typst_ppi: config.typst.ppi.to_string(), mermaid_scale: config.mermaid.scale.to_string(), d2_scale: config.d2.scale.map(|s| s.to_string()).unwrap_or_else(|| "-1".to_string()), threads: config.snippet.render.threads, }; let third_party = ThirdPartyRender::new(third_party_config, registry, &resources_path); let code_executor = Arc::new(code_executor); Ok(Self { third_party, code_executor, resources, printer, builder_options, themes, default_theme, config, present_mode, graphics_mode, }) } fn make_builder_options( config: &Config, force_default_theme: bool, render_speaker_notes_only: bool, ) -> PresentationBuilderOptions { PresentationBuilderOptions { allow_mutations: true, implicit_slide_ends: config.options.implicit_slide_ends.unwrap_or_default(), command_prefix: config.options.command_prefix.clone().unwrap_or_default(), image_attribute_prefix: config .options .image_attributes_prefix .clone() .unwrap_or_else(|| "image:".to_string()), incremental_lists: config.options.incremental_lists.unwrap_or_default(), force_default_theme, end_slide_shorthand: config.options.end_slide_shorthand.unwrap_or_default(), print_modal_background: false, strict_front_matter_parsing: config.options.strict_front_matter_parsing.unwrap_or(true), enable_snippet_execution: config.snippet.exec.enable, enable_snippet_execution_replace: config.snippet.exec_replace.enable, render_speaker_notes_only, auto_render_languages: config.options.auto_render_languages.clone(), theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size }, pause_before_incremental_lists: config.defaults.incremental_lists.pause_before.unwrap_or(true), pause_after_incremental_lists: config.defaults.incremental_lists.pause_after.unwrap_or(true), pause_create_new_slide: false, list_item_newlines: config.options.list_item_newlines.map(Into::into).unwrap_or(1), validate_snippets: config.snippet.validate, } } fn select_graphics_mode(cli: &Cli, config: &Config) -> GraphicsMode { if cli.export_pdf | cli.export_html { GraphicsMode::Raw } else { let protocol = cli.image_protocol.as_ref().unwrap_or(&config.defaults.image_protocol); match GraphicsMode::try_from(protocol) { Ok(mode) => mode, Err(_) => Cli::command() .error(ErrorKind::InvalidValue, "sixel support was not enabled during compilation") .exit(), } } } fn load_default_theme(config: &Config, themes: &Themes, cli: &Cli) -> PresentationTheme { let default_theme_name = cli.theme.as_ref().or(config.defaults.theme.as_ref()).map(|s| s.as_str()).unwrap_or(DEFAULT_THEME); let Some(default_theme) = themes.presentation.load_by_name(default_theme_name) else { let valid_themes = themes.presentation.theme_names().join(", "); let error_message = format!("invalid theme name, valid themes are: {valid_themes}"); Cli::command().error(ErrorKind::InvalidValue, error_message).exit(); }; default_theme } } struct SpeakerNotesComponents { events_listener: Option, events_publisher: Option, } impl SpeakerNotesComponents { fn new(cli: &Cli, config: &Config, path: &Path) -> anyhow::Result { let full_presentation_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); let publish_speaker_notes = cli.publish_speaker_notes || (config.speaker_notes.always_publish && !cli.listen_speaker_notes); let events_publisher = publish_speaker_notes .then(|| { SpeakerNotesEventPublisher::new(config.speaker_notes.publish_address, full_presentation_path.clone()) }) .transpose() .map_err(|e| anyhow!("failed to create speaker notes publisher: {e}"))?; let events_listener = cli .listen_speaker_notes .then(|| SpeakerNotesEventListener::new(config.speaker_notes.listen_address, full_presentation_path)) .transpose() .map_err(|e| anyhow!("failed to create speaker notes listener: {e}"))?; Ok(Self { events_listener, events_publisher }) } } fn overflow_validation_enabled(mode: &PresentMode, config: &ValidateOverflows) -> bool { match (config, mode) { (ValidateOverflows::Always, _) => true, (ValidateOverflows::Never, _) => false, (ValidateOverflows::WhenPresenting, PresentMode::Presentation) => true, (ValidateOverflows::WhenDeveloping, PresentMode::Development) => true, _ => false, } } fn run(cli: Cli) -> Result<(), Box> { #[cfg(feature = "json-schema")] if cli.generate_config_file_schema { let schema = schemars::schema_for!(Config); serde_json::to_writer_pretty(io::stdout(), &schema).map_err(|e| format!("failed to write schema: {e}"))?; return Ok(()); } if cli.acknowledgements { let acknowledgements = include_bytes!("../bat/acknowledgements.txt"); println!("{}", String::from_utf8_lossy(acknowledgements)); return Ok(()); } else if cli.list_themes { // Load this ahead of time so we don't do it when we're already in raw mode. TerminalEmulator::capabilities(); let Customizations { config, themes, .. } = Customizations::load(cli.config_file.clone().map(PathBuf::from), ¤t_dir()?)?; let bindings = config.bindings.try_into()?; let demo = ThemesDemo::new(themes, bindings)?; demo.run()?; return Ok(()); } else if cli.current_theme { let Customizations { config, .. } = Customizations::load(cli.config_file.clone().map(PathBuf::from), ¤t_dir()?)?; let theme_name = cli.theme.as_ref().or(config.defaults.theme.as_ref()).map(|s| s.as_str()).unwrap_or(DEFAULT_THEME); println!("{theme_name}"); return Ok(()); } // Disable this so we don't mess things up when generating PDFs if cli.export_pdf { TerminalEmulator::disable_capability_detection(); } let Some(path) = cli.path.clone() else { Cli::command().error(ErrorKind::MissingRequiredArgument, "no path specified").exit(); }; let CoreComponents { third_party, code_executor, resources, printer, mut builder_options, themes, default_theme, config, present_mode, graphics_mode, } = CoreComponents::new(&cli, &path)?; let arena = Arena::new(); let parser = MarkdownParser::new(&arena); let validate_overflows = overflow_validation_enabled(&present_mode, &config.defaults.validate_overflows) || cli.validate_overflows; if cli.validate_snippets { builder_options.validate_snippets = cli.validate_snippets; } if cli.export_pdf || cli.export_html { let dimensions = match config.export.dimensions { Some(dimensions) => WindowSize { rows: dimensions.rows, columns: dimensions.columns, height: dimensions.rows * DEFAULT_EXPORT_PIXELS_PER_ROW, width: dimensions.columns * DEFAULT_EXPORT_PIXELS_PER_COLUMN, }, None => WindowSize::current(config.defaults.terminal_font_size)?, }; let exporter = Exporter::new( parser, &default_theme, resources, third_party, code_executor, themes, builder_options, dimensions, config.export.pauses, config.export.snippets, ); let output_directory = match cli.export_temporary_path { Some(path) => OutputDirectory::external(path), None => OutputDirectory::temporary(), }?; if cli.export_pdf { exporter.export_pdf(&path, output_directory, cli.export_output.as_deref(), config.export.pdf)?; } else { exporter.export_html(&path, output_directory, cli.export_output.as_deref())?; } } else { let SpeakerNotesComponents { events_listener, events_publisher } = SpeakerNotesComponents::new(&cli, &config, &path)?; let command_listener = CommandListener::new(config.bindings.clone(), events_listener)?; builder_options.print_modal_background = matches!(graphics_mode, GraphicsMode::Kitty { .. }); let options = PresenterOptions { builder_options, mode: present_mode, font_size_fallback: config.defaults.terminal_font_size, bindings: config.bindings, validate_overflows, max_size: MaxSize { max_columns: config.defaults.max_columns, max_columns_alignment: config.defaults.max_columns_alignment, max_rows: config.defaults.max_rows, max_rows_alignment: config.defaults.max_rows_alignment, }, transition: config.transition, }; let presenter = Presenter::new( &default_theme, command_listener, parser, resources, third_party, code_executor, themes, printer, options, events_publisher, ); presenter.present(&path)?; } Ok(()) } fn main() { let cli = Cli::parse(); if let Err(e) = run(cli) { let _ = execute!(io::stdout(), PrintStyledContent(format!("{e}\n").stylize().with(crossterm::style::Color::Red))); std::process::exit(1); } } presenterm-0.15.1/src/markdown/elements.rs000064400000000000000000000165611046102023000167000ustar 00000000000000use super::text_style::{Color, TextStyle, UndefinedPaletteColorError}; use crate::theme::{ColorPalette, raw::RawColor}; use comrak::nodes::AlertType; use std::{fmt, iter, path::PathBuf, str::FromStr}; use unicode_width::UnicodeWidthStr; /// A markdown element. /// /// This represents each of the supported markdown elements. The structure here differs a bit from /// the spec, mostly in how inlines are handled, to simplify its processing. #[derive(Clone, Debug)] pub(crate) enum MarkdownElement { /// The front matter that optionally shows up at the beginning of the file. FrontMatter(String), /// A setex heading. SetexHeading { text: Vec> }, /// A normal heading. Heading { level: u8, text: Line }, /// A paragraph composed by a list of lines. Paragraph(Vec>), /// An image. Image { path: PathBuf, title: String, source_position: SourcePosition }, /// A list. /// /// All contiguous list items are merged into a single one, regardless of levels of nesting. List(Vec), /// A code snippet. Snippet { /// The information line that specifies this code's language, attributes, etc. info: String, /// The code in this snippet. code: String, /// The position in the source file this snippet came from. source_position: SourcePosition, }, /// A table. Table(Table), /// A thematic break. ThematicBreak, /// An HTML comment. Comment { comment: String, source_position: SourcePosition }, /// A block quote containing a list of lines. BlockQuote(Vec>), /// An alert. Alert { /// The alert's type. alert_type: AlertType, /// The optional title. title: Option, /// The content lines in this alert. lines: Vec>, }, /// A footnote definition. Footnote(Line), } #[derive(Clone, Copy, Debug, Default)] pub struct SourcePosition { pub(crate) start: LineColumn, } impl fmt::Display for SourcePosition { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}:{}", self.start.line, self.start.column) } } impl From for SourcePosition { fn from(position: comrak::nodes::Sourcepos) -> Self { Self { start: position.start.into() } } } #[derive(Clone, Copy, Debug, Default)] pub(crate) struct LineColumn { pub(crate) line: usize, pub(crate) column: usize, } impl From for LineColumn { fn from(position: comrak::nodes::LineColumn) -> Self { Self { line: position.line, column: position.column } } } /// A text line. /// /// Text is represented as a series of chunks, each with their own formatting. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Line(pub(crate) Vec>); impl Default for Line { fn default() -> Self { Self(vec![]) } } impl Line { /// Get the total width for this text. pub(crate) fn width(&self) -> usize { self.0.iter().map(|text| text.content.width()).sum() } } impl Line { /// Applies the given style to this text. pub(crate) fn apply_style(&mut self, style: &TextStyle) { for text in &mut self.0 { text.style.merge(style); } } } impl Line { /// Resolve the colors in this line. pub(crate) fn resolve(self, palette: &ColorPalette) -> Result, UndefinedPaletteColorError> { let mut output = Vec::with_capacity(self.0.len()); for text in self.0 { let style = text.style.resolve(palette)?; output.push(Text::new(text.content, style)); } Ok(Line(output)) } } impl>> From for Line { fn from(text: T) -> Self { Self(vec![text.into()]) } } /// A styled piece of text. /// /// This is the most granular text representation: a `String` and a style. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Text { pub(crate) content: String, pub(crate) style: TextStyle, } impl Default for Text { fn default() -> Self { Self { content: Default::default(), style: TextStyle::default() } } } impl Text { /// Construct a new styled text. pub(crate) fn new>(content: S, style: TextStyle) -> Self { Self { content: content.into(), style } } /// Get the width of this text. pub(crate) fn width(&self) -> usize { self.content.width() } } impl From for Text { fn from(text: String) -> Self { Self { content: text, style: TextStyle::default() } } } impl From<&str> for Text { fn from(text: &str) -> Self { Self { content: text.into(), style: TextStyle::default() } } } /// A list item. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ListItem { /// The depth of this item. /// /// This increases by one for every nested list level. pub(crate) depth: u8, /// The contents of this list item. pub(crate) contents: Line, /// The type of list item. pub(crate) item_type: ListItemType, } /// The type of a list item. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ListItemType { /// A list item for an unordered list. Unordered, /// A list item for an ordered list that uses parenthesis after the list item number. OrderedParens(usize), /// A list item for an ordered list that uses a period after the list item number. OrderedPeriod(usize), } /// A table. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Table { /// The table's header. pub(crate) header: TableRow, /// All of the rows in this table, excluding the header. pub(crate) rows: Vec, } impl Table { /// gets the number of columns in this table. pub(crate) fn columns(&self) -> usize { self.header.0.len() } /// Iterates all the text entries in a column. /// /// This includes the header. pub(crate) fn iter_column(&self, column: usize) -> impl Iterator> { let header_element = &self.header.0[column]; let row_elements = self.rows.iter().map(move |row| &row.0[column]); iter::once(header_element).chain(row_elements) } } /// A table row. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct TableRow(pub(crate) Vec>); /// A percentage. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Percent(pub(crate) u8); impl Percent { pub(crate) fn as_ratio(&self) -> f64 { self.0 as f64 / 100.0 } } impl FromStr for Percent { type Err = PercentParseError; fn from_str(input: &str) -> Result { let (prefix, suffix) = input.split_once('%').ok_or(PercentParseError::Unit)?; let value: u8 = prefix.parse().map_err(|_| PercentParseError::Value)?; if !(1..=100).contains(&value) { return Err(PercentParseError::Value); } if !suffix.is_empty() { return Err(PercentParseError::Trailer(suffix.into())); } Ok(Percent(value)) } } #[derive(thiserror::Error, Debug)] pub enum PercentParseError { #[error("value must be a number between 1-100")] Value, #[error("no unit provided")] Unit, #[error("unexpected: '{0}'")] Trailer(String), } presenterm-0.15.1/src/markdown/html.rs000064400000000000000000000164321046102023000160250ustar 00000000000000use super::text_style::{Color, TextStyle}; use crate::theme::raw::{ParseColorError, RawColor}; use std::{borrow::Cow, str, str::Utf8Error}; use tl::Attributes; pub(crate) struct HtmlParseOptions { pub(crate) strict: bool, } impl Default for HtmlParseOptions { fn default() -> Self { Self { strict: true } } } #[derive(Default)] pub(crate) struct HtmlParser { options: HtmlParseOptions, } impl HtmlParser { pub(crate) fn parse(self, input: &str) -> Result { if input.starts_with(" (HtmlTag::Span, TextStyle::default()), b"sup" => (HtmlTag::Sup, TextStyle::default().superscript()), _ => return Err(ParseHtmlError::UnsupportedHtml), }; let style = self.parse_attributes(tag.attributes())?; Ok(HtmlInline::OpenTag { style: style.merged(&base_style), tag: output_tag }) } fn parse_attributes(&self, attributes: &Attributes) -> Result, ParseHtmlError> { let mut style = TextStyle::default(); for (name, value) in attributes.iter() { let value = value.unwrap_or(Cow::Borrowed("")); match name.as_ref() { "style" => self.parse_css_attribute(&value, &mut style)?, "class" => { style = style.fg_color(RawColor::ForegroundClass(value.to_string())); style = style.bg_color(RawColor::BackgroundClass(value.to_string())); } _ => { if self.options.strict { return Err(ParseHtmlError::UnsupportedTagAttribute(name.to_string())); } } } } Ok(style) } fn parse_css_attribute(&self, attribute: &str, style: &mut TextStyle) -> Result<(), ParseHtmlError> { for attribute in attribute.split(';') { let attribute = attribute.trim(); if attribute.is_empty() { continue; } let (key, value) = attribute.split_once(':').ok_or(ParseHtmlError::NoColonInAttribute)?; let key = key.trim(); let value = value.trim(); match key { "color" => style.colors.foreground = Some(Self::parse_color(value)?), "background-color" => style.colors.background = Some(Self::parse_color(value)?), _ => { if self.options.strict { return Err(ParseHtmlError::UnsupportedCssAttribute(key.into())); } } } } Ok(()) } fn parse_color(input: &str) -> Result { if input.starts_with('#') { let color = input.strip_prefix('#').unwrap().parse()?; if matches!(color, RawColor::Color(Color::Rgb { .. })) { Ok(color) } else { Ok(input.parse()?) } } else { let color = input.parse::()?; if matches!(color, RawColor::Color(Color::Rgb { .. })) { Err(ParseHtmlError::InvalidColor("missing '#' in rgb color".into())) } else { Ok(color) } } } } #[derive(Debug, PartialEq)] pub(crate) enum HtmlInline { OpenTag { style: TextStyle, tag: HtmlTag }, CloseTag { tag: HtmlTag }, } #[derive(Clone, Debug, PartialEq)] pub(crate) enum HtmlTag { Span, Sup, } #[derive(Debug, thiserror::Error)] pub(crate) enum ParseHtmlError { #[error("parsing html failed: {0}")] ParsingHtml(#[from] tl::ParseError), #[error("no html tags found")] NoTags, #[error("non utf8 content: {0}")] NotUtf8(#[from] Utf8Error), #[error("attribute has no ':'")] NoColonInAttribute, #[error("invalid color: {0}")] InvalidColor(String), #[error("invalid css attribute: {0}")] UnsupportedCssAttribute(String), #[error("HTML can only contain span and sup tags")] UnsupportedHtml, #[error("unsupported tag attribute: {0}")] UnsupportedTagAttribute(String), #[error("unsupported closing tag: {0}")] UnsupportedClosingTag(String), } impl From for ParseHtmlError { fn from(e: ParseColorError) -> Self { Self::InvalidColor(e.to_string()) } } #[cfg(test)] mod tests { use super::*; use rstest::rstest; #[test] fn parse_style() { let tag = HtmlParser::default().parse(r#""#).expect("parse failed"); let HtmlInline::OpenTag { style, tag: HtmlTag::Span } = tag else { panic!("not an open tag") }; assert_eq!(style, TextStyle::default().bg_color(Color::Black).fg_color(Color::Red)); } #[test] fn parse_sup() { let tag = HtmlParser::default().parse(r#""#).expect("parse failed"); let HtmlInline::OpenTag { style, tag: HtmlTag::Sup } = tag else { panic!("not an open tag") }; assert_eq!(style, TextStyle::default().superscript()); } #[test] fn parse_class() { let tag = HtmlParser::default().parse(r#""#).expect("parse failed"); let HtmlInline::OpenTag { style, tag: HtmlTag::Span } = tag else { panic!("not an open tag") }; assert_eq!( style, TextStyle::default() .bg_color(RawColor::BackgroundClass("foo".into())) .fg_color(RawColor::ForegroundClass("foo".into())) ); } #[rstest] #[case::span("", HtmlTag::Span)] #[case::sup("", HtmlTag::Sup)] fn parse_end_tag(#[case] input: &str, #[case] tag: HtmlTag) { let inline = HtmlParser::default().parse(input).expect("parse failed"); assert_eq!(inline, HtmlInline::CloseTag { tag }); } #[rstest] #[case::invalid_start_tag("
")] #[case::invalid_end_tag("
")] #[case::invalid_attribute("")] #[case::invalid_attribute(" = Result; struct ParserOptions(comrak::Options<'static>); impl Default for ParserOptions { fn default() -> Self { let mut options = ComrakOptions::default(); options.extension.front_matter_delimiter = Some("---".into()); options.extension.table = true; options.extension.strikethrough = true; options.extension.multiline_block_quotes = true; options.extension.alerts = true; options.extension.wikilinks_title_before_pipe = true; options.extension.superscript = true; options.extension.footnotes = true; Self(options) } } /// A markdown parser. /// /// This takes the contents of a markdown file and parses it into a list of [MarkdownElement]. pub struct MarkdownParser<'a> { arena: &'a Arena>, options: comrak::Options<'static>, } impl<'a> MarkdownParser<'a> { /// Construct a new markdown parser. pub fn new(arena: &'a Arena>) -> Self { Self { arena, options: ParserOptions::default().0 } } /// Parse the contents of a markdown file. pub(crate) fn parse(&self, contents: &str) -> ParseResult> { let node = parse_document(self.arena, contents, &self.options); let mut elements = Vec::new(); for node in node.children() { let parsed_elements = self.parse_node(node).map_err(|e| ParseError::new(e.kind, e.sourcepos))?; elements.extend(parsed_elements); } Ok(elements) } /// Parse inlines in a markdown input. pub(crate) fn parse_inlines(&self, line: &str) -> Result, ParseInlinesError> { let node = parse_document(self.arena, line, &self.options); if node.children().count() == 0 { return Ok(Default::default()); } if node.children().count() > 1 { return Err(ParseInlinesError("inline must be simple text".into())); } let node = node.first_child().expect("must have one child"); let data = node.data.borrow(); let NodeValue::Paragraph = &data.value else { return Err(ParseInlinesError("inline must be simple text".into())); }; let parser = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No); let inlines = parser.parse(node).map_err(|e| ParseInlinesError(e.to_string()))?; let mut output = Line::default(); for inline in inlines { match inline { Inline::Text(line) => { output.0.extend(line.0); } Inline::Image { .. } => return Err(ParseInlinesError("images not supported".into())), Inline::LineBreak => return Err(ParseInlinesError("line breaks not supported".into())), }; } Ok(output) } fn parse_node(&self, node: &'a AstNode<'a>) -> ParseResult> { let data = node.data.borrow(); let element = match &data.value { // Paragraphs are the only ones that can actually yield more than one. NodeValue::Paragraph => return self.parse_paragraph(node), NodeValue::FrontMatter(contents) => Self::parse_front_matter(contents)?, NodeValue::Heading(heading) => self.parse_heading(heading, node)?, NodeValue::List(list) => { let items = self.parse_list(node, list.marker_offset as u8 / 2)?; MarkdownElement::List(items) } NodeValue::Table(_) => self.parse_table(node)?, NodeValue::CodeBlock(block) => Self::parse_code_block(block, data.sourcepos)?, NodeValue::ThematicBreak => MarkdownElement::ThematicBreak, NodeValue::HtmlBlock(block) => self.parse_html_block(block, data.sourcepos)?, NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_) => self.parse_block_quote(node)?, NodeValue::Alert(alert) => self.parse_alert(alert, node)?, NodeValue::FootnoteDefinition(definition) => self.parse_footnote_definition(definition, node)?, other => return Err(ParseErrorKind::UnsupportedElement(other.identifier()).with_sourcepos(data.sourcepos)), }; Ok(vec![element]) } fn parse_front_matter(contents: &str) -> ParseResult { // Remove leading and trailing delimiters before parsing. This is quite poopy but hey, it // works. let contents = contents.strip_prefix("---\n").unwrap_or(contents); let contents = contents.strip_prefix("---\r\n").unwrap_or(contents); let contents = contents.strip_suffix("---\n").unwrap_or(contents); let contents = contents.strip_suffix("---\r\n").unwrap_or(contents); let contents = contents.strip_suffix("---\n\n").unwrap_or(contents); let contents = contents.strip_suffix("---\r\n\r\n").unwrap_or(contents); Ok(MarkdownElement::FrontMatter(contents.into())) } fn parse_html_block(&self, block: &NodeHtmlBlock, sourcepos: Sourcepos) -> ParseResult { let block = block.literal.trim(); let start_tag = ""; if !block.starts_with(start_tag) || !block.ends_with(end_tag) { return Err(ParseErrorKind::UnsupportedElement("html block").with_sourcepos(sourcepos)); } let block = &block[start_tag.len()..]; let block = &block[0..block.len() - end_tag.len()]; Ok(MarkdownElement::Comment { comment: block.into(), source_position: sourcepos.into() }) } fn parse_block_quote(&self, node: &'a AstNode<'a>) -> ParseResult { let mut lines = Vec::new(); let inlines = InlinesParser::new(self.arena, SoftBreak::Newline, StringifyImages::Yes).parse(node)?; for inline in inlines { match inline { Inline::Text(text) => lines.push(text), Inline::LineBreak => lines.push(Line::from("")), Inline::Image { .. } => {} } } if lines.last() == Some(&Line::::from("")) { lines.pop(); } Ok(MarkdownElement::BlockQuote(lines)) } fn parse_code_block(block: &NodeCodeBlock, sourcepos: Sourcepos) -> ParseResult { if !block.fenced { return Err(ParseErrorKind::UnfencedCodeBlock.with_sourcepos(sourcepos)); } Ok(MarkdownElement::Snippet { info: block.info.clone(), code: block.literal.clone(), source_position: sourcepos.into(), }) } fn parse_alert(&self, alert: &NodeAlert, node: &'a AstNode<'a>) -> ParseResult { let MarkdownElement::BlockQuote(lines) = self.parse_block_quote(node)? else { panic!("not a block quote") }; Ok(MarkdownElement::Alert { alert_type: alert.alert_type, title: alert.title.clone(), lines }) } fn parse_footnote_definition( &self, definition: &NodeFootnoteDefinition, node: &'a AstNode<'a>, ) -> ParseResult { let mut line = vec![Text::new(definition.name.clone(), TextStyle::default().superscript())]; let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::Yes).parse(node)?; for inline in inlines { match inline { Inline::Text(text) => line.extend(text.0), Inline::LineBreak | Inline::Image { .. } => {} } } Ok(MarkdownElement::Footnote(Line(line))) } fn parse_heading(&self, heading: &NodeHeading, node: &'a AstNode<'a>) -> ParseResult { if heading.setext { let text = self.parse_exheading(node)?; Ok(MarkdownElement::SetexHeading { text }) } else { let text = self.parse_text(node)?; Ok(MarkdownElement::Heading { text, level: heading.level }) } } fn parse_paragraph(&self, node: &'a AstNode<'a>) -> ParseResult> { let mut elements = Vec::new(); let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No).parse(node)?; let mut paragraph_elements = Vec::new(); for inline in inlines { match inline { Inline::Text(text) => paragraph_elements.push(text), Inline::LineBreak => (), Inline::Image { path, title } => { if !paragraph_elements.is_empty() { elements.push(MarkdownElement::Paragraph(mem::take(&mut paragraph_elements))); } elements.push(MarkdownElement::Image { path: path.into(), title, source_position: node.data.borrow().sourcepos.into(), }); } } } if !paragraph_elements.is_empty() { elements.push(MarkdownElement::Paragraph(mem::take(&mut paragraph_elements))); } Ok(elements) } fn parse_exheading(&self, node: &'a AstNode<'a>) -> ParseResult>> { let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No).parse(node)?; let mut lines = Vec::new(); let mut chunks = Vec::new(); for inline in inlines { match inline { Inline::Text(text) => chunks.extend(text.0), Inline::LineBreak => { lines.push(Line(chunks)); chunks = Vec::new(); } other => { return Err(ParseErrorKind::UnsupportedStructure { container: "text", element: other.kind() } .with_sourcepos(node.data.borrow().sourcepos)); } }; } lines.push(Line(chunks)); Ok(lines) } fn parse_text(&self, node: &'a AstNode<'a>) -> ParseResult> { let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No).parse(node)?; let mut chunks = Vec::new(); for inline in inlines { match inline { Inline::Text(text) => chunks.extend(text.0), other => { return Err(ParseErrorKind::UnsupportedStructure { container: "text", element: other.kind() } .with_sourcepos(node.data.borrow().sourcepos)); } }; } Ok(Line(chunks)) } fn parse_list(&self, root: &'a AstNode<'a>, depth: u8) -> ParseResult> { let mut elements = Vec::new(); for node in root.children() { let data = node.data.borrow(); match &data.value { NodeValue::Item(item) => { elements.extend(self.parse_list_item(item, node, depth)?); } other => { return Err(ParseErrorKind::UnsupportedStructure { container: "list", element: other.identifier(), } .with_sourcepos(data.sourcepos)); } }; } Ok(elements) } fn parse_list_item(&self, item: &NodeList, root: &'a AstNode<'a>, depth: u8) -> ParseResult> { let item_type = match (item.list_type, item.delimiter) { (ListType::Bullet, _) => ListItemType::Unordered, (ListType::Ordered, ListDelimType::Paren) => ListItemType::OrderedParens(item.start), (ListType::Ordered, ListDelimType::Period) => ListItemType::OrderedPeriod(item.start), }; let mut elements = Vec::new(); for node in root.children() { let data = node.data.borrow(); match &data.value { NodeValue::Paragraph => { let contents = self.parse_text(node)?; elements.push(ListItem { contents, depth, item_type: item_type.clone() }); } NodeValue::List(_) => { elements.extend(self.parse_list(node, depth + 1)?); } other => { return Err(ParseErrorKind::UnsupportedStructure { container: "list", element: other.identifier(), } .with_sourcepos(data.sourcepos)); } } } Ok(elements) } fn parse_table(&self, node: &'a AstNode<'a>) -> ParseResult { let mut header = TableRow(Vec::new()); let mut rows = Vec::new(); for node in node.children() { let data = node.data.borrow(); let NodeValue::TableRow(_) = &data.value else { return Err(ParseErrorKind::UnsupportedStructure { container: "table", element: data.value.identifier(), } .with_sourcepos(data.sourcepos)); }; let row = self.parse_table_row(node)?; if header.0.is_empty() { header = row; } else { rows.push(row) } } Ok(MarkdownElement::Table(Table { header, rows })) } fn parse_table_row(&self, node: &'a AstNode<'a>) -> ParseResult { let mut cells = Vec::new(); for node in node.children() { let data = node.data.borrow(); let NodeValue::TableCell = &data.value else { return Err(ParseErrorKind::UnsupportedStructure { container: "table", element: data.value.identifier(), } .with_sourcepos(data.sourcepos)); }; let text = self.parse_text(node)?; cells.push(text); } Ok(TableRow(cells)) } } enum SoftBreak { Newline, Space, } enum StringifyImages { Yes, No, } struct InlinesParser<'a> { inlines: Vec, pending_text: Vec>, arena: &'a Arena>, soft_break: SoftBreak, stringify_images: StringifyImages, } impl<'a> InlinesParser<'a> { fn new(arena: &'a Arena>, soft_break: SoftBreak, stringify_images: StringifyImages) -> Self { Self { inlines: Vec::new(), pending_text: Vec::new(), arena, soft_break, stringify_images } } fn parse(mut self, node: &'a AstNode<'a>) -> ParseResult> { self.process_children(node, TextStyle::default())?; self.store_pending_text(); Ok(self.inlines) } fn store_pending_text(&mut self) { let chunks = mem::take(&mut self.pending_text); if !chunks.is_empty() { self.inlines.push(Inline::Text(Line(chunks))); } } fn process_node( &mut self, node: &'a AstNode<'a>, parent: &'a AstNode<'a>, style: TextStyle, ) -> ParseResult> { let data = node.data.borrow(); match &data.value { NodeValue::Text(text) => { self.pending_text.push(Text::new(text.clone(), style)); } NodeValue::Code(code) => { self.pending_text.push(Text::new(code.literal.clone(), TextStyle::default().code())); } NodeValue::Strong => self.process_children(node, style.bold())?, NodeValue::Emph => self.process_children(node, style.italics())?, NodeValue::Strikethrough => self.process_children(node, style.strikethrough())?, NodeValue::Superscript => self.process_children(node, style.superscript())?, NodeValue::SoftBreak => { match self.soft_break { SoftBreak::Newline => { self.store_pending_text(); } SoftBreak::Space => self.pending_text.push(Text::new(" ", style)), }; } NodeValue::Link(link) => { let has_label = node.first_child().is_some(); if has_label { self.process_children(node, TextStyle::default().link_label())?; self.pending_text.push(Text::from(" (")); } self.pending_text.push(Text::new(link.url.clone(), TextStyle::default().link_url())); if !link.title.is_empty() { self.pending_text.push(Text::from(" \"")); self.pending_text.push(Text::new(link.title.clone(), TextStyle::default().link_title())); self.pending_text.push(Text::from("\"")); } if has_label { self.pending_text.push(Text::from(")")); } } NodeValue::WikiLink(link) => { self.pending_text.push(Text::new(link.url.clone(), TextStyle::default().link_url())); } NodeValue::LineBreak => { self.store_pending_text(); self.inlines.push(Inline::LineBreak); } NodeValue::Image(link) => { if link.url.starts_with("http://") || link.url.starts_with("https://") { return Err(ParseErrorKind::ExternalImageUrl.with_sourcepos(data.sourcepos)); } if matches!(self.stringify_images, StringifyImages::Yes) { self.pending_text.push(Text::from(format!("![{}]({})", link.title, link.url))); return Ok(None); } self.store_pending_text(); // The image "title" contains inlines so we create a dummy paragraph node that // contains it so we can flatten it back into text. We could walk the tree but this // is good enough. let mut buffer = Vec::new(); let paragraph = self.arena.alloc(Node::new(RefCell::new(Ast::new(NodeValue::Paragraph, data.sourcepos.start)))); for child in node.children() { paragraph.append(child); } format_commonmark(paragraph, &ParserOptions::default().0, &mut buffer) .map_err(|e| ParseErrorKind::Internal(e.to_string()).with_sourcepos(data.sourcepos))?; let title = String::from_utf8_lossy(&buffer).trim_end().to_string(); self.inlines.push(Inline::Image { path: link.url.clone(), title }); } NodeValue::Paragraph => { self.process_children(node, style)?; self.store_pending_text(); if matches!(parent.data.borrow().value, NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_)) { self.inlines.push(Inline::LineBreak); } } NodeValue::List(_) => { self.process_children(node, style)?; self.store_pending_text(); self.inlines.push(Inline::LineBreak); } NodeValue::Item(item) => { match (item.list_type, item.delimiter) { (ListType::Bullet, _) => self.pending_text.push(Text::from("* ")), (ListType::Ordered, ListDelimType::Period) => { self.pending_text.push(Text::from(format!("{}. ", item.start))) } (ListType::Ordered, ListDelimType::Paren) => { self.pending_text.push(Text::from(format!("{}) ", item.start))) } }; self.process_children(node, style)?; } NodeValue::HtmlInline(html) => { let html_inline = HtmlParser::default() .parse(html) .map_err(|e| ParseErrorKind::InvalidHtml(e).with_sourcepos(data.sourcepos))?; match html_inline { HtmlInline::OpenTag { style, tag } => return Ok(Some(HtmlStyle::Add(style, tag))), HtmlInline::CloseTag { tag } => return Ok(Some(HtmlStyle::Remove(tag))), }; } NodeValue::FootnoteReference(reference) => { // Keep only colors here, we don't care about e.g. italics footnotes. let style = TextStyle::colored(style.colors).superscript(); self.pending_text.push(Text::new(reference.name.clone(), style)); } other => { return Err(ParseErrorKind::UnsupportedStructure { container: "text", element: other.identifier() } .with_sourcepos(data.sourcepos)); } }; Ok(None) } fn process_children(&mut self, root: &'a AstNode<'a>, base_style: TextStyle) -> ParseResult<()> { let mut html_styles = Vec::new(); let mut style = base_style.clone(); for node in root.children() { if let Some(html_style) = self.process_node(node, root, style.clone())? { match html_style { HtmlStyle::Add(style, tag) => html_styles.push((style, tag)), HtmlStyle::Remove(tag) => { let popped_tag = html_styles .pop() .ok_or_else(|| ParseErrorKind::NoOpenTag.with_sourcepos(node.data.borrow().sourcepos))? .1; if popped_tag != tag { return Err(ParseErrorKind::CloseTagMismatch.with_sourcepos(node.data.borrow().sourcepos)); } } }; style = base_style.clone(); for html_style in html_styles.iter().rev() { style.merge(&html_style.0); } } } Ok(()) } } enum HtmlStyle { Add(TextStyle, HtmlTag), Remove(HtmlTag), } enum Inline { Text(Line), Image { path: String, title: String }, LineBreak, } impl Inline { fn kind(&self) -> &'static str { match self { Self::Text(_) => "text", Self::Image { .. } => "image", Self::LineBreak => "line break", } } } /// A parsing error. #[derive(thiserror::Error, Debug)] pub struct ParseError { /// The kind of error. pub(crate) kind: ParseErrorKind, /// The position in the source file this error originated from. pub(crate) sourcepos: SourcePosition, } impl Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "parse error at {}: {}", self.sourcepos, self.kind) } } impl ParseError { fn new>(kind: ParseErrorKind, sourcepos: S) -> Self { Self { kind, sourcepos: sourcepos.into() } } } /// The kind of error. #[derive(Debug)] pub(crate) enum ParseErrorKind { /// We don't support parsing this element. UnsupportedElement(&'static str), /// We don't support parsing an element in a specific container. UnsupportedStructure { container: &'static str, element: &'static str }, /// We don't support unfenced code blocks. UnfencedCodeBlock, /// We don't support external URLs in images. ExternalImageUrl, /// Invalid HTML was found. InvalidHtml(ParseHtmlError), /// HTML tag closed without having an open one. NoOpenTag, /// HTML tag closed for a different opened one. CloseTagMismatch, /// An internal parsing error. Internal(String), } impl Display for ParseErrorKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::UnsupportedElement(element) => write!(f, "unsupported element: {element}"), Self::UnsupportedStructure { container, element } => { write!(f, "unsupported structure in {container}: {element}") } Self::ExternalImageUrl => write!(f, "external URLs are not supported in image tags"), Self::UnfencedCodeBlock => write!(f, "only fenced code blocks are supported"), Self::InvalidHtml(inner) => write!(f, "invalid HTML: {inner}"), Self::NoOpenTag => write!(f, "closing tag without an open one"), Self::CloseTagMismatch => write!(f, "closing tag does not match last open one"), Self::Internal(message) => write!(f, "internal error: {message}"), } } } impl ParseErrorKind { fn with_sourcepos>(self, sourcepos: S) -> ParseError { ParseError::new(self, sourcepos) } } trait Identifier { fn identifier(&self) -> &'static str; } impl Identifier for NodeValue { fn identifier(&self) -> &'static str { match self { NodeValue::Document => "document", NodeValue::FrontMatter(_) => "front matter", NodeValue::BlockQuote => "block quote", NodeValue::List(_) => "list", NodeValue::Item(_) => "item", NodeValue::DescriptionList => "description list", NodeValue::DescriptionItem(_) => "description item", NodeValue::DescriptionTerm => "description term", NodeValue::DescriptionDetails => "description details", NodeValue::CodeBlock(_) => "code block", NodeValue::HtmlBlock(_) => "html block", NodeValue::Paragraph => "paragraph", NodeValue::Heading(_) => "heading", NodeValue::ThematicBreak => "thematic break", NodeValue::FootnoteDefinition(_) => "footnote definition", NodeValue::Table(_) => "table", NodeValue::TableRow(_) => "table row", NodeValue::TableCell => "table cell", NodeValue::Text(_) => "text", NodeValue::TaskItem(_) => "task item", NodeValue::SoftBreak => "soft break", NodeValue::LineBreak => "line break", NodeValue::Code(_) => "code", NodeValue::HtmlInline(_) => "inline html", NodeValue::Emph => "emph", NodeValue::Strong => "strong", NodeValue::Strikethrough => "strikethrough", NodeValue::Superscript => "superscript", NodeValue::Link(_) => "link", NodeValue::Image(_) => "image", NodeValue::FootnoteReference(_) => "footnote reference", NodeValue::MultilineBlockQuote(_) => "multiline block quote", NodeValue::Math(_) => "math", NodeValue::Escaped => "escaped", NodeValue::WikiLink(_) => "wiki link", NodeValue::Underline => "underline", NodeValue::SpoileredText => "spoilered text", NodeValue::EscapedTag(_) => "escaped tag", NodeValue::Subscript => "subscript", NodeValue::Raw(_) => "raw", NodeValue::Alert(_) => "alert", } } } #[derive(Debug, thiserror::Error)] #[error("invalid markdown line: {0}")] pub(crate) struct ParseInlinesError(String); #[cfg(test)] mod test { use super::*; use crate::markdown::text_style::Color; use rstest::rstest; use std::path::Path; fn try_parse(input: &str) -> Result, ParseError> { let arena = Arena::new(); MarkdownParser::new(&arena).parse(input) } fn parse_single(input: &str) -> MarkdownElement { let elements = try_parse(input).expect("failed to parse"); assert_eq!(elements.len(), 1, "more than one element: {elements:?}"); elements.into_iter().next().unwrap() } fn parse_all(input: &str) -> Vec { try_parse(input).expect("parsing failed") } #[test] fn slide_metadata() { let parsed = parse_single( r"--- beep boop --- ", ); let MarkdownElement::FrontMatter(contents) = parsed else { panic!("not a front matter: {parsed:?}") }; assert_eq!(contents, "beep\nboop\n"); } #[test] fn paragraph() { let parsed = parse_single("some **bold text**, _italics_, *italics*, **nested _italics_**, ~strikethrough~, ^super^"); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = vec![ Text::from("some "), Text::new("bold text", TextStyle::default().bold()), Text::from(", "), Text::new("italics", TextStyle::default().italics()), Text::from(", "), Text::new("italics", TextStyle::default().italics()), Text::from(", "), Text::new("nested ", TextStyle::default().bold()), Text::new("italics", TextStyle::default().italics().bold()), Text::from(", "), Text::new("strikethrough", TextStyle::default().strikethrough()), Text::from(", "), Text::new("super", TextStyle::default().superscript()), ]; let expected_elements = &[Line(expected_chunks)]; assert_eq!(elements, expected_elements); } #[test] fn html_inlines() { let parsed = parse_single( "hiredblueyellow", ); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = vec![ Text::from("hi"), Text::new("red", TextStyle::default().fg_color(Color::Red)), Text::new("blue", TextStyle::default().fg_color(Color::Red).bg_color(Color::Blue)), Text::new("yell", TextStyle::default().fg_color(Color::Yellow).bg_color(Color::Blue)), Text::new("ow", TextStyle::default().fg_color(Color::Yellow).bg_color(Color::Blue).superscript()), ]; let expected_elements = &[Line(expected_chunks)]; assert_eq!(elements, expected_elements); } #[rstest] #[case::closed_no_open("", ParseErrorKind::NoOpenTag)] #[case::mismatch_open1("", ParseErrorKind::CloseTagMismatch)] #[case::mismatch_open2("", ParseErrorKind::CloseTagMismatch)] #[case::mismatch_open3("", ParseErrorKind::CloseTagMismatch)] fn invalid_html_inlines(#[case] input: &str, #[case] expected_error: ParseErrorKind) { let ParseError { kind, .. } = try_parse(input).expect_err("no failure"); assert_eq!(kind.to_string(), expected_error.to_string()); } #[test] fn link_wo_label_wo_title() { let parsed = parse_single("my [](https://example.com)"); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = vec![Text::from("my "), Text::new("https://example.com", TextStyle::default().link_url())]; let expected_elements = &[Line(expected_chunks)]; assert_eq!(elements, expected_elements); } #[test] fn link_w_label_wo_title() { let parsed = parse_single("my [website](https://example.com)"); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = vec![ Text::from("my "), Text::new("website", TextStyle::default().link_label()), Text::from(" ("), Text::new("https://example.com", TextStyle::default().link_url()), Text::from(")"), ]; let expected_elements = &[Line(expected_chunks)]; assert_eq!(elements, expected_elements); } #[test] fn link_wo_label_w_title() { let parsed = parse_single("my [](https://example.com \"Example\")"); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = vec![ Text::from("my "), Text::new("https://example.com", TextStyle::default().link_url()), Text::from(" \""), Text::new("Example", TextStyle::default().link_title()), Text::from("\""), ]; let expected_elements = &[Line(expected_chunks)]; assert_eq!(elements, expected_elements); } #[test] fn link_w_label_w_title() { let parsed = parse_single("my [website](https://example.com \"Example\")"); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = vec![ Text::from("my "), Text::new("website", TextStyle::default().link_label()), Text::from(" ("), Text::new("https://example.com", TextStyle::default().link_url()), Text::from(" \""), Text::new("Example", TextStyle::default().link_title()), Text::from("\""), Text::from(")"), ]; let expected_elements = &[Line(expected_chunks)]; assert_eq!(elements, expected_elements); } #[test] fn wikilink_wo_title() { let parsed = parse_single("[[https://example.com]]"); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = vec![Text::new("https://example.com", TextStyle::default().link_url())]; let expected_elements = &[Line(expected_chunks)]; assert_eq!(elements, expected_elements); } #[test] fn image() { let parsed = parse_single("![](potato.png)"); let MarkdownElement::Image { path, .. } = parsed else { panic!("not an image: {parsed:?}") }; assert_eq!(path, Path::new("potato.png")); } #[test] fn image_within_text() { let parsed = parse_all( r" picture of potato: ![](potato.png) ", ); assert_eq!(parsed.len(), 2); } #[test] fn external_image() { let result = try_parse("![](https://example.com/potato.png)"); let Err(ParseError { kind: ParseErrorKind::ExternalImageUrl, .. }) = result else { panic!("not the expected error: {result:?}") }; } #[test] fn setex_heading() { let parsed = parse_single( r" Title === ", ); let MarkdownElement::SetexHeading { text } = parsed else { panic!("not a slide title: {parsed:?}") }; let expected_chunks = [Text::from("Title")]; assert_eq!(text[0].0, expected_chunks); } #[test] fn heading() { let parsed = parse_single("# Title **with bold**"); let MarkdownElement::Heading { text, level } = parsed else { panic!("not a heading: {parsed:?}") }; let expected_chunks = vec![Text::from("Title "), Text::new("with bold", TextStyle::default().bold())]; assert_eq!(level, 1); assert_eq!(text.0, expected_chunks); } #[test] fn unordered_list() { let parsed = parse_single( r" * One * Sub1 * Sub2 * Two * Three", ); let MarkdownElement::List(items) = parsed else { panic!("not a list: {parsed:?}") }; let mut items = items.into_iter(); let mut next = || items.next().expect("list ended prematurely"); assert_eq!(next().depth, 0); assert_eq!(next().depth, 1); assert_eq!(next().depth, 1); assert_eq!(next().depth, 0); assert_eq!(next().depth, 0); } #[test] fn ordered_list_starting_non_one() { let parsed = parse_single( r" 4. One 1. Sub1 2. Sub2 5. Two 6. Three", ); let MarkdownElement::List(items) = parsed else { panic!("not a list: {parsed:?}") }; let mut items = items.into_iter(); let mut next = || items.next().expect("list ended prematurely"); assert_eq!(next().item_type, ListItemType::OrderedPeriod(4)); assert_eq!(next().item_type, ListItemType::OrderedPeriod(1)); assert_eq!(next().item_type, ListItemType::OrderedPeriod(2)); assert_eq!(next().item_type, ListItemType::OrderedPeriod(5)); assert_eq!(next().item_type, ListItemType::OrderedPeriod(6)); } #[test] fn line_breaks() { let parsed = parse_all( r" some text with line breaks a hard break another", ); // note that "with line breaks" also has a hard break (" ") at the end, hence the 3. assert_eq!(parsed.len(), 2); let MarkdownElement::Paragraph(elements) = &parsed[0] else { panic!("not a line break: {parsed:?}") }; assert_eq!(elements.len(), 2); let expected_chunks = &[Text::from("some text"), Text::from(" "), Text::from("with line breaks")]; let text = &elements[0]; assert_eq!(text.0, expected_chunks); } #[test] fn code_block() { let parsed = parse_single( r" ```rust +exec let q = 42; ```` ", ); let MarkdownElement::Snippet { info, code, .. } = parsed else { panic!("not a code block: {parsed:?}") }; assert_eq!(info, "rust +exec"); assert_eq!(code, "let q = 42;\n"); } #[test] fn inline_code() { let parsed = parse_single("some `inline code`"); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = &[Text::from("some "), Text::new("inline code", TextStyle::default().code())]; assert_eq!(elements.len(), 1); let text = &elements[0]; assert_eq!(text.0, expected_chunks); } #[test] fn table() { let parsed = parse_single( r" | Name | Taste | | ------ | ------ | | Potato | Great | | Carrot | Yuck | ", ); let MarkdownElement::Table(Table { header, rows }) = parsed else { panic!("not a table: {parsed:?}") }; assert_eq!(header.0.len(), 2); assert_eq!(rows.len(), 2); assert_eq!(rows[0].0.len(), 2); assert_eq!(rows[1].0.len(), 2); } #[test] fn comment() { let parsed = parse_single( r" ", ); let MarkdownElement::Comment { comment, .. } = parsed else { panic!("not a comment: {parsed:?}") }; assert_eq!(comment, " foo "); } #[test] fn list_comment_in_between() { let parsed = parse_all( r" * A * B ", ); assert_eq!(parsed.len(), 3); let MarkdownElement::List(items) = &parsed[2] else { panic!("not a list item: {parsed:?}") }; assert_eq!(items[0].depth, 1); } #[test] fn block_quote() { let parsed = parse_single( r#" > foo **is not** bar > ![](hehe.png) test ![](potato.png) > > * a > * b > > 1. a > 2. b > > 1) a > 2) b "#, ); let MarkdownElement::BlockQuote(lines) = parsed else { panic!("not a block quote: {parsed:?}") }; assert_eq!(lines.len(), 11); assert_eq!( lines[0], Line(vec![Text::from("foo "), Text::new("is not", TextStyle::default().bold()), Text::from(" bar")]) ); assert_eq!( lines[1], Line(vec![Text::from("![](hehe.png)"), Text::from(" test "), Text::from("![](potato.png)")]) ); assert_eq!(lines[2], Line::from("")); assert_eq!(lines[3], Line(vec![Text::from("* "), Text::from("a")])); assert_eq!(lines[4], Line(vec![Text::from("* "), Text::from("b")])); assert_eq!(lines[5], Line::from("")); assert_eq!(lines[6], Line(vec![Text::from("1. "), Text::from("a")])); assert_eq!(lines[7], Line(vec![Text::from("2. "), Text::from("b")])); assert_eq!(lines[8], Line::from("")); assert_eq!(lines[9], Line(vec![Text::from("1) "), Text::from("a")])); assert_eq!(lines[10], Line(vec![Text::from("2) "), Text::from("b")])); } #[test] fn multiline_block_quote() { let parsed = parse_single( r" >>> bar foo * a * b >>>", ); let MarkdownElement::BlockQuote(lines) = parsed else { panic!("not a block quote: {parsed:?}") }; assert_eq!(lines.len(), 5); assert_eq!(lines[0], Line::from("bar")); assert_eq!(lines[1], Line::from("foo")); assert_eq!(lines[2], Line::from("")); assert_eq!(lines[3], Line(vec![Text::from("* "), Text::from("a")])); assert_eq!(lines[4], Line(vec![Text::from("* "), Text::from("b")])); } #[test] fn thematic_break() { let parsed = parse_all( r" hello --- bye ", ); assert_eq!(parsed.len(), 3); assert!(matches!(parsed[1], MarkdownElement::ThematicBreak)); } #[test] fn error_lines_offset_by_front_matter() { let input = r"--- hi mom --- * ![](potato.png) "; let arena = Arena::new(); let result = MarkdownParser::new(&arena).parse(input); let Err(e) = result else { panic!("parsing didn't fail"); }; assert_eq!(e.sourcepos.start.line, 6); assert_eq!(e.sourcepos.start.column, 3); } #[test] fn comment_lines_offset_by_front_matter() { let parsed = parse_all( r"--- hi mom --- ", ); let MarkdownElement::Comment { source_position, .. } = &parsed[1] else { panic!("not a comment") }; assert_eq!(source_position.start.line, 6); assert_eq!(source_position.start.column, 1); } #[rstest] #[case::lf("\n")] #[case::crlf("\r\n")] fn front_matter_newlines(#[case] nl: &str) { let input = format!("---{nl}hi{nl}mom{nl}---{nl}"); let parsed = parse_single(&input); let MarkdownElement::FrontMatter(contents) = &parsed else { panic!("not a front matter") }; let expected = format!("hi{nl}mom{nl}"); assert_eq!(contents, &expected); } #[test] fn parse_alert() { let input = r" > [!note] > hi mom > bye **mom** "; let MarkdownElement::Alert { lines, .. } = parse_single(input) else { panic!("not an alert"); }; assert_eq!(lines.len(), 2); } #[test] fn parse_inlines() { let arena = Arena::new(); let input = "hello **mom** how _are you_?"; let parsed = MarkdownParser::new(&arena).parse_inlines(input).expect("parse failed"); let expected = &[ "hello ".into(), Text::new("mom", TextStyle::default().bold()), " how ".into(), Text::new("are you", TextStyle::default().italics()), "?".into(), ]; assert_eq!(parsed.0, expected); } #[test] fn footnote() { let input = r" this[^1] [^1]: ref "; let elements = parse_all(input); assert_eq!(elements.len(), 2); let MarkdownElement::Paragraph(line) = &elements[0] else { panic!("not a paragraph") }; assert_eq!(line, &[Line(vec![Text::from("this"), Text::new("1", TextStyle::default().superscript())])]); let MarkdownElement::Footnote(line) = &elements[1] else { panic!("not a footnote") }; assert_eq!(line, &Line(vec![Text::new("1", TextStyle::default().superscript()), Text::from("ref")])); } } presenterm-0.15.1/src/markdown/text.rs000064400000000000000000000353401046102023000160440ustar 00000000000000use super::{ elements::{Line, Text}, text_style::TextStyle, }; use std::{fmt, mem}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; /// A weighted line of text. /// /// The weight of a character is its given by its width in unicode. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct WeightedLine { text: Vec, width: usize, font_size: u8, } impl WeightedLine { /// Split this line into chunks of at most `max_length` width. pub(crate) fn split(&self, max_length: usize) -> SplitTextIter { SplitTextIter::new(&self.text, max_length) } /// The total width of this line. pub(crate) fn width(&self) -> usize { self.width } /// The height of this line. pub(crate) fn font_size(&self) -> u8 { self.font_size } } impl From for WeightedLine { fn from(block: Line) -> Self { block.0.into() } } impl From> for WeightedLine { fn from(mut texts: Vec) -> Self { let mut output = Vec::new(); let mut index = 0; let mut width = 0; let mut font_size = 1; // Compact chunks so any consecutive chunk with the same style is merged into the same block. while index < texts.len() { let mut target = mem::replace(&mut texts[index], Text::from("")); let mut current = index + 1; while current < texts.len() && texts[current].style == target.style { let current_content = mem::take(&mut texts[current].content); target.content.push_str(¤t_content); current += 1; } let size = target.style.size.max(1); width += target.content.width() * size as usize; output.push(target.into()); index = current; font_size = font_size.max(size); } Self { text: output, width, font_size } } } impl From for WeightedLine { fn from(text: String) -> Self { let width = text.width(); let text = vec![WeightedText::from(text)]; Self { text, width, font_size: 1 } } } impl From<&str> for WeightedLine { fn from(text: &str) -> Self { Self::from(text.to_string()) } } #[derive(Clone, Debug, PartialEq, Eq)] struct CharAccumulator { width: usize, bytes: usize, } /// A piece of weighted text. #[derive(Clone, PartialEq, Eq)] pub(crate) struct WeightedText { text: Text, accumulators: Vec, } impl WeightedText { fn to_ref(&self) -> WeightedTextRef { WeightedTextRef { text: &self.text.content, accumulators: &self.accumulators, style: self.text.style } } pub(crate) fn width(&self) -> usize { self.to_ref().width() } pub(crate) fn text(&self) -> &Text { &self.text } } impl> From for WeightedText { fn from(text: S) -> Self { Self::from(Text::from(text.into())) } } impl From for WeightedText { fn from(text: Text) -> Self { let mut accumulators = Vec::new(); let mut width = 0; let mut bytes = 0; for c in text.content.chars() { accumulators.push(CharAccumulator { width, bytes }); width += c.width().unwrap_or(0); bytes += c.len_utf8(); } accumulators.push(CharAccumulator { width, bytes }); Self { text, accumulators } } } impl fmt::Debug for WeightedText { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("WeightedText").field("text", &self.text).finish() } } /// An iterator over the chunks in a [WeightedLine]. pub(crate) struct SplitTextIter<'a> { texts: &'a [WeightedText], max_length: usize, current: Option>, } impl<'a> SplitTextIter<'a> { fn new(texts: &'a [WeightedText], max_length: usize) -> Self { Self { texts, max_length, current: texts.first().map(WeightedText::to_ref) } } } impl<'a> Iterator for SplitTextIter<'a> { type Item = Vec>; fn next(&mut self) -> Option { self.current.as_ref()?; let mut elements = Vec::new(); let mut remaining = self.max_length as i64; while let Some(current) = self.current.take() { let (head, rest) = current.word_split_at_length(remaining as usize); // Prevent splitting a word partially. We do allow this on the first chunk as otherwise // a word longer than `max_length` would never be split. if !rest.text.is_empty() && !rest.text.starts_with(' ') && !elements.is_empty() { self.current = Some(current); break; } let head_width = head.width(); remaining -= head_width as i64; elements.push(head); // The moment we hit a chunk we couldn't fully split, we're done. if !rest.text.is_empty() { self.current = Some(rest.trim_start()); break; } // Consume the first one and point to the next one, if any. self.texts = &self.texts[1..]; self.current = self.texts.first().map(WeightedText::to_ref); } Some(elements) } } /// A reference of a piece of a [WeightedText]. #[derive(Clone, Debug)] pub(crate) struct WeightedTextRef<'a> { text: &'a str, accumulators: &'a [CharAccumulator], style: TextStyle, } impl<'a> WeightedTextRef<'a> { /// Decompose this into its parts. pub(crate) fn into_parts(self) -> (&'a str, TextStyle) { (self.text, self.style) } // Attempts to split this at a word boundary. // // This will try to consume as many words as possible up to the given maximum length, and // return the text before and after that split point. fn word_split_at_length(&self, max_length: usize) -> (Self, Self) { if self.width() <= max_length { return (self.make_ref(0, self.text.len()), self.make_ref(0, 0)); } let max_length = (max_length / self.style.size as usize).max(1); let target_chunk = self.substr(max_length + 1); let output_chunk = match target_chunk.rsplit_once(' ') { Some((before, _)) => before, None => self.substr(max_length), }; (self.make_ref(0, output_chunk.len()), self.make_ref(output_chunk.len(), self.text.len())) } fn substr(&self, max_length: usize) -> &'a str { let last_index = self.bytes_until(max_length); &self.text[0..last_index] } fn make_ref(&self, from: usize, to: usize) -> Self { let text = &self.text[from..to]; let leading_char_count = self.text[0..from].chars().count(); let output_char_count = text.chars().count(); let character_lengths = &self.accumulators[leading_char_count..leading_char_count + output_char_count + 1]; WeightedTextRef { text, accumulators: character_lengths, style: self.style } } fn trim_start(self) -> Self { let text = self.text.trim_start(); let trimmed = self.text.chars().count() - text.chars().count(); let accumulators = &self.accumulators[trimmed..]; Self { text, accumulators, style: self.style } } pub(crate) fn width(&self) -> usize { let last_width = self.accumulators.last().map(|a| a.width).unwrap_or(0); let first_width = self.accumulators.first().map(|a| a.width).unwrap_or(0); (last_width - first_width) * self.style.size as usize } fn bytes_until(&self, index: usize) -> usize { let last_bytes = self.accumulators.get(index).or_else(|| self.accumulators.last()).map(|a| a.bytes).unwrap_or(0); let first_bytes = self.accumulators.first().map(|a| a.bytes).unwrap_or(0); last_bytes - first_bytes } } #[cfg(test)] mod test { use super::*; use rstest::rstest; fn join_lines<'a>(lines: impl Iterator>>) -> Vec { lines.map(|l| l.iter().map(|weighted| weighted.text).collect::>().join(" ")).collect() } #[test] fn text_creation() { let text = WeightedText::from("hello world"); let text_ref = text.to_ref(); assert_eq!(text_ref.width(), 11); } #[test] fn text_creation_utf8() { let text = WeightedText::from("█████"); let text_ref = text.to_ref(); assert_eq!(text_ref.width(), 5); assert_eq!(text_ref.bytes_until(0), 0); assert_eq!(text_ref.bytes_until(1), 3); assert_eq!(text_ref.bytes_until(2), 6); assert_eq!(text_ref.bytes_until(3), 9); assert_eq!(text_ref.bytes_until(4), 12); let text_ref = text_ref.make_ref(3, 12); assert_eq!(text_ref.width(), 3); assert_eq!(text_ref.bytes_until(0), 0); assert_eq!(text_ref.bytes_until(1), 3); assert_eq!(text_ref.bytes_until(2), 6); let text_ref = text_ref.make_ref(0, 9); assert_eq!(text_ref.width(), 3); assert_eq!(text_ref.bytes_until(0), 0); assert_eq!(text_ref.bytes_until(1), 3); assert_eq!(text_ref.bytes_until(2), 6); } #[test] fn minimal_split() { let text = WeightedText::from("█████"); let text_ref = text.to_ref(); let (head, rest) = text_ref.word_split_at_length(1); assert_eq!(head.width(), 1); assert_eq!(rest.width(), 4); } #[test] fn no_spaces_split() { let text = WeightedText::from("█████"); let text_ref = text.to_ref(); let (head, rest) = text_ref.word_split_at_length(2); assert_eq!(head.width(), 2); assert_eq!(rest.width(), 3); } #[test] fn font_size_split() { let text = WeightedText::from(Text::new("█████", TextStyle::default().size(2))); let text_ref = text.to_ref(); let (head, rest) = text_ref.word_split_at_length(3); assert_eq!(head.width(), 2); assert_eq!(rest.width(), 8); } #[test] fn make_ref() { let text = WeightedText::from("hello world"); let text_ref = text.to_ref(); let head = text_ref.make_ref(0, 1); assert_eq!(head.text, "h"); assert_eq!(head.width(), 1); let rest = text_ref.make_ref(1, 11); assert_eq!(rest.text, "ello world"); assert_eq!(rest.width(), 10); } #[test] fn word_split() { let text = WeightedText::from("short string"); let (head, rest) = text.to_ref().word_split_at_length(7); assert_eq!(head.text, "short"); assert_eq!(rest.text, " string"); } #[test] fn split_at_full_length() { let text = WeightedLine::from("hello world"); let lines = join_lines(text.split(11)); let expected = vec!["hello world"]; assert_eq!(lines, expected); } #[test] fn no_split_necessary() { let text = WeightedLine { text: vec![WeightedText::from("short"), WeightedText::from("text")], width: 0, font_size: 1, }; let lines = join_lines(text.split(50)); let expected = vec!["short text"]; assert_eq!(lines, expected); } #[test] fn split_lines_single() { let text = WeightedLine { text: vec![WeightedText::from("this is a slightly long line")], width: 0, font_size: 1 }; let lines = join_lines(text.split(6)); let expected = vec!["this", "is a", "slight", "ly", "long", "line"]; assert_eq!(lines, expected); } #[test] fn split_lines_multi() { let text = WeightedLine { text: vec![ WeightedText::from("this is a slightly long line"), WeightedText::from("another chunk"), WeightedText::from("yet some other piece"), ], width: 0, font_size: 1, }; let lines = join_lines(text.split(10)); let expected = vec!["this is a", "slightly", "long line", "another", "chunk yet", "some other", "piece"]; assert_eq!(lines, expected); } #[test] fn long_splits() { let text = WeightedLine { text: vec![ WeightedText::from("this is a slightly long line"), WeightedText::from("another chunk"), WeightedText::from("yet some other piece"), ], width: 0, font_size: 1, }; let lines = join_lines(text.split(50)); let expected = vec!["this is a slightly long line another chunk yet some", "other piece"]; assert_eq!(lines, expected); } #[test] fn prefixed_by_whitespace() { let text = WeightedLine::from(" * bullet"); let lines = join_lines(text.split(50)); let expected = vec![" * bullet"]; assert_eq!(lines, expected); } #[test] fn utf8_character() { let text = WeightedLine::from("• A"); let lines = join_lines(text.split(50)); let expected = vec!["• A"]; assert_eq!(lines, expected); } #[test] fn many_utf8_characters() { let content = "█████ ██"; let text = WeightedLine::from(content); let lines = join_lines(text.split(3)); let expected = vec!["███", "██", "██"]; assert_eq!(lines, expected); } #[test] fn no_whitespaces_ascii() { let content = "X".repeat(10); let text = WeightedLine::from(content); let lines = join_lines(text.split(3)); let expected = vec!["XXX", "XXX", "XXX", "X"]; assert_eq!(lines, expected); } #[test] fn no_whitespaces_utf8() { let content = "─".repeat(10); let text = WeightedLine::from(content); let lines = join_lines(text.split(3)); let expected = vec!["───", "───", "───", "─"]; assert_eq!(lines, expected); } #[test] fn wide_characters() { let content = "Hello world"; let text = WeightedLine::from(content); let lines = join_lines(text.split(10)); // Each word is 10 characters long let expected = vec!["Hello", "world"]; assert_eq!(lines, expected); } #[rstest] #[case::single(&["hello".into()], 1)] #[case::two(&["hello".into(), " world".into()], 1)] #[case::three(&["hello".into(), " ".into(), "world".into()], 1)] #[case::split(&["hello".into(), Text::new(" ", TextStyle::default().bold()), "world".into()], 3)] #[case::split_merged(&["hello".into(), Text::new(" ", TextStyle::default().bold()), Text::new("w", TextStyle::default().bold()), "orld".into()], 3)] fn compaction(#[case] texts: &[Text], #[case] expected: usize) { let block = WeightedLine::from(texts.to_vec()); assert_eq!(block.text.len(), expected); } } presenterm-0.15.1/src/markdown/text_style.rs000064400000000000000000000347301046102023000172660ustar 00000000000000use crate::{ terminal::capabilities::TerminalCapabilities, theme::{ColorPalette, raw::RawColor}, }; use crossterm::style::{ContentStyle, StyledContent, Stylize}; use hex::FromHexError; use serde::{Deserialize, Serialize}; use std::{ borrow::Cow, fmt::{self, Display}, }; /// The style of a piece of text. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct TextStyle { flags: u8, pub(crate) colors: Colors, pub(crate) size: u8, } impl Default for TextStyle { fn default() -> Self { Self { flags: Default::default(), colors: Default::default(), size: 1 } } } impl TextStyle where C: Clone, { pub(crate) fn colored(colors: Colors) -> Self { Self { colors, ..Default::default() } } pub(crate) fn size(mut self, size: u8) -> Self { self.size = size.min(16); self } /// Add bold to this style. pub(crate) fn bold(self) -> Self { self.add_flag(TextFormatFlags::Bold) } /// Add italics to this style. pub(crate) fn italics(self) -> Self { self.add_flag(TextFormatFlags::Italics) } /// Indicate this text is a piece of inline code. pub(crate) fn code(self) -> Self { self.add_flag(TextFormatFlags::Code) } /// Add strikethrough to this style. pub(crate) fn strikethrough(self) -> Self { self.add_flag(TextFormatFlags::Strikethrough) } /// Add underline to this style. pub(crate) fn underlined(self) -> Self { self.add_flag(TextFormatFlags::Underlined) } /// Indicate this is a link label. pub(crate) fn link_label(self) -> Self { self.bold() } /// Indicate this is a link title. pub(crate) fn link_title(self) -> Self { self.italics() } /// Indicate this is a link url. pub(crate) fn link_url(self) -> Self { self.italics().underlined() } /// Indicate this is a superscript. pub(crate) fn superscript(self) -> Self { self.add_flag(TextFormatFlags::Superscript) } /// Set the background color for this text style. pub(crate) fn bg_color>(mut self, color: U) -> Self { self.colors.background = Some(color.into()); self } /// Set the foreground color for this text style. pub(crate) fn fg_color>(mut self, color: U) -> Self { self.colors.foreground = Some(color.into()); self } /// Set the colors on this style. pub(crate) fn colors(mut self, colors: Colors) -> Self { self.colors = colors; self } /// Check whether this text is code. pub(crate) fn is_code(&self) -> bool { self.has_flag(TextFormatFlags::Code) } /// Merge this style with another one. pub(crate) fn merge(&mut self, other: &TextStyle) { self.flags |= other.flags; self.size = self.size.max(other.size); self.colors.background = self.colors.background.clone().or(other.colors.background.clone()); self.colors.foreground = self.colors.foreground.clone().or(other.colors.foreground.clone()); } /// Return a new style merged with the one passed in. pub(crate) fn merged(mut self, other: &TextStyle) -> Self { self.merge(other); self } fn add_flag(mut self, flag: TextFormatFlags) -> Self { self.flags |= flag as u8; self } fn has_flag(&self, flag: TextFormatFlags) -> bool { self.flags & flag as u8 != 0 } } impl TextStyle { /// Apply this style to a piece of text. pub(crate) fn apply<'a>( &self, text: &'a str, capabilities: &TerminalCapabilities, ) -> StyledContent { let mut contents = Cow::Borrowed(text); let mut font_size = FontSize::Scaled(self.size); let mut style = ContentStyle::default(); for attr in self.iter_attributes() { style = match attr { TextAttribute::Bold => style.bold(), TextAttribute::Italics => style.italic(), TextAttribute::Strikethrough => style.crossed_out(), TextAttribute::Underlined => style.underlined(), TextAttribute::Superscript => { if capabilities.fractional_font_size { font_size = FontSize::Fractional { numerator: self.size, denominator: 2 } } else if let Some(t) = text.try_into_superscript() { contents = Cow::Owned(t); } style } TextAttribute::ForegroundColor(color) => style.with(color.into()), TextAttribute::BackgroundColor(color) => style.on(color.into()), } } let text = FontSizedStr { contents, font_size }; StyledContent::new(style, text) } pub(crate) fn into_raw(self) -> TextStyle { let colors = Colors { background: self.colors.background.map(Into::into), foreground: self.colors.foreground.map(Into::into), }; TextStyle { flags: self.flags, colors, size: self.size } } /// Iterate all attributes in this style. pub(crate) fn iter_attributes(&self) -> AttributeIterator { AttributeIterator { flags: self.flags, next_mask: Some(TextFormatFlags::Bold), background_color: self.colors.background, foreground_color: self.colors.foreground, } } } impl TextStyle { pub(crate) fn resolve(&self, palette: &ColorPalette) -> Result { let colors = self.colors.resolve(palette)?; Ok(TextStyle { flags: self.flags, colors, size: self.size }) } } pub(crate) struct AttributeIterator { flags: u8, next_mask: Option, background_color: Option, foreground_color: Option, } impl Iterator for AttributeIterator { type Item = TextAttribute; fn next(&mut self) -> Option { if let Some(c) = self.background_color.take() { return Some(TextAttribute::BackgroundColor(c)); } if let Some(c) = self.foreground_color.take() { return Some(TextAttribute::ForegroundColor(c)); } use TextFormatFlags::*; loop { let next_mask = self.next_mask?; self.next_mask = match next_mask { Bold => Some(Italics), Italics => Some(Strikethrough), Code => Some(Strikethrough), Strikethrough => Some(Superscript), Superscript => Some(Underlined), Underlined => None, }; if self.flags & next_mask as u8 != 0 { let attr = match next_mask { Bold => TextAttribute::Bold, Italics => TextAttribute::Italics, Code => panic!("code shouldn't reach here"), Strikethrough => TextAttribute::Strikethrough, Superscript => TextAttribute::Superscript, Underlined => TextAttribute::Underlined, }; return Some(attr); } } } } #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) enum TextAttribute { Bold, Italics, Strikethrough, Underlined, Superscript, ForegroundColor(Color), BackgroundColor(Color), } #[derive(Clone, Debug)] struct FontSizedStr<'a> { contents: Cow<'a, str>, font_size: FontSize, } impl fmt::Display for FontSizedStr<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let contents = &self.contents; match self.font_size { FontSize::Scaled(0 | 1) => write!(f, "{contents}"), FontSize::Scaled(size) => write!(f, "\x1b]66;s={size};{contents}\x1b\\"), FontSize::Fractional { numerator, denominator } => { write!(f, "\x1b]66;n={numerator}:d={denominator};{contents}\x1b\\") } } } } #[derive(Clone, Debug)] enum FontSize { Scaled(u8), Fractional { numerator: u8, denominator: u8 }, } #[derive(Clone, Copy, Debug)] enum TextFormatFlags { Bold = 1, Italics = 2, Code = 4, Strikethrough = 8, Underlined = 16, Superscript = 32, } #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub(crate) enum Color { Black, DarkGrey, Red, DarkRed, Green, DarkGreen, Yellow, DarkYellow, Blue, DarkBlue, Magenta, DarkMagenta, Cyan, DarkCyan, White, Grey, Rgb { r: u8, g: u8, b: u8 }, } impl Color { pub(crate) fn new(r: u8, g: u8, b: u8) -> Self { Self::Rgb { r, g, b } } pub(crate) fn as_rgb(&self) -> Option<(u8, u8, u8)> { match self { Self::Rgb { r, g, b } => Some((*r, *g, *b)), _ => None, } } pub(crate) fn from_ansi(color: u8) -> Option { let color = match color { 30 | 40 => Color::Black, 31 | 41 => Color::Red, 32 | 42 => Color::Green, 33 | 43 => Color::Yellow, 34 | 44 => Color::Blue, 35 | 45 => Color::Magenta, 36 | 46 => Color::Cyan, 37 | 47 => Color::White, _ => return None, }; Some(color) } } impl From for crossterm::style::Color { fn from(value: Color) -> Self { use crossterm::style::Color as C; match value { Color::Black => C::Black, Color::DarkGrey => C::DarkGrey, Color::Red => C::Red, Color::DarkRed => C::DarkRed, Color::Green => C::Green, Color::DarkGreen => C::DarkGreen, Color::Yellow => C::Yellow, Color::DarkYellow => C::DarkYellow, Color::Blue => C::Blue, Color::DarkBlue => C::DarkBlue, Color::Magenta => C::Magenta, Color::DarkMagenta => C::DarkMagenta, Color::Cyan => C::Cyan, Color::DarkCyan => C::DarkCyan, Color::White => C::White, Color::Grey => C::Grey, Color::Rgb { r, g, b } => C::Rgb { r, g, b }, } } } #[derive(Debug, thiserror::Error)] #[error("unresolved palette color: {0}")] pub(crate) struct PaletteColorError(String); #[derive(Debug, thiserror::Error)] #[error("undefined palette color: {0}")] pub(crate) struct UndefinedPaletteColorError(pub(crate) String); /// Text colors. #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] pub(crate) struct Colors { /// The background color. pub(crate) background: Option, /// The foreground color. pub(crate) foreground: Option, } impl Default for Colors { fn default() -> Self { Self { background: None, foreground: None } } } impl Colors { pub(crate) fn resolve(&self, palette: &ColorPalette) -> Result, UndefinedPaletteColorError> { let background = self.background.clone().map(|c| c.resolve(palette)).transpose()?.flatten(); let foreground = self.foreground.clone().map(|c| c.resolve(palette)).transpose()?.flatten(); Ok(Colors { foreground, background }) } } impl From for crossterm::style::Colors { fn from(value: Colors) -> Self { let foreground = value.foreground.map(Color::into); let background = value.background.map(Color::into); Self { foreground, background } } } #[derive(thiserror::Error, Debug)] pub(crate) enum ParseColorError { #[error("invalid hex color: {0}")] Hex(#[from] FromHexError), } trait TryIntoSuperscript { fn try_into_superscript(&self) -> Option; } impl TryIntoSuperscript for &'_ str { fn try_into_superscript(&self) -> Option { let mut output = String::new(); for from in self.chars() { let to = match from { '0' => '⁰', '1' => '¹', '2' => '²', '3' => '³', '4' => '⁴', '5' => '⁵', '6' => '⁶', '7' => '⁷', '8' => '⁸', '9' => '⁹', '+' => '⁺', '-' => '⁻', '=' => '⁼', '(' => '⁽', ')' => '⁾', 'a' => 'ᵃ', 'b' => 'ᵇ', 'c' => 'ᶜ', 'd' => 'ᵈ', 'e' => 'ᵉ', 'f' => 'ᶠ', 'g' => 'ᵍ', 'h' => 'ʰ', 'i' => 'ⁱ', 'j' => 'ʲ', 'k' => 'ᵏ', 'l' => 'ˡ', 'm' => 'ᵐ', 'n' => 'ⁿ', 'o' => 'ᵒ', 'p' => 'ᵖ', 'q' => '𐞥', 'r' => 'ʳ', 's' => 'ˢ', 't' => 'ᵗ', 'u' => 'ᵘ', 'v' => 'ᵛ', 'w' => 'ʷ', 'x' => 'ˣ', 'y' => 'ʸ', 'z' => 'ᶻ', _ => return None, }; output.push(to); } Some(output) } } #[cfg(test)] mod tests { use super::*; use rstest::rstest; #[rstest] #[case::default(TextStyle::default(), &[])] #[case::code(TextStyle::default().code(), &[])] #[case::bold(TextStyle::default().bold(), &[TextAttribute::Bold])] #[case::italics(TextStyle::default().italics(), &[TextAttribute::Italics])] #[case::strikethrough(TextStyle::default().strikethrough(), &[TextAttribute::Strikethrough])] #[case::underlined(TextStyle::default().underlined(), &[TextAttribute::Underlined])] #[case::bg_color(TextStyle::default().bg_color(Color::Red), &[TextAttribute::BackgroundColor(Color::Red)])] #[case::bg_color(TextStyle::default().fg_color(Color::Red), &[TextAttribute::ForegroundColor(Color::Red)])] #[case::all( TextStyle::default().bold().code().italics().strikethrough().underlined().bg_color(Color::Black).fg_color(Color::Red), &[ TextAttribute::BackgroundColor(Color::Black), TextAttribute::ForegroundColor(Color::Red), TextAttribute::Bold, TextAttribute::Italics, TextAttribute::Strikethrough, TextAttribute::Underlined, ] )] fn iterate_attributes(#[case] style: TextStyle, #[case] expected: &[TextAttribute]) { let attrs: Vec<_> = style.iter_attributes().collect(); assert_eq!(attrs, expected); } } presenterm-0.15.1/src/presentation/builder/comment.rs000064400000000000000000000511731046102023000210430ustar 00000000000000use crate::{ markdown::elements::{MarkdownElement, SourcePosition}, presentation::builder::{BuildResult, LayoutState, PresentationBuilder, error::InvalidPresentation}, render::operation::RenderOperation, theme::{Alignment, ElementType}, }; use serde::Deserialize; use std::{fmt, num::NonZeroU8, path::PathBuf, str::FromStr}; impl PresentationBuilder<'_, '_> { pub(crate) fn process_comment(&mut self, comment: String, source_position: SourcePosition) -> BuildResult { let comment = comment.trim(); let trimmed_comment = comment.trim_start_matches(&self.options.command_prefix); let command = match trimmed_comment.parse::() { Ok(comment) => comment, Err(error) => { // If we failed to parse this, make sure we shouldn't have ignored it if self.should_ignore_comment(comment) { return Ok(()); } return Err(self.invalid_presentation(source_position, error)); } }; if self.options.render_speaker_notes_only { self.process_comment_command_speaker_notes_mode(command); } else { self.process_comment_command_presentation_mode(command, source_position)?; } Ok(()) } fn process_comment_command_presentation_mode( &mut self, command: CommentCommand, source_position: SourcePosition, ) -> BuildResult { match command { CommentCommand::Pause => self.push_pause(), CommentCommand::EndSlide => self.terminate_slide(), CommentCommand::NewLine => self.push_line_breaks(self.slide_font_size() as usize), CommentCommand::NewLines(count) => { self.push_line_breaks(count as usize * self.slide_font_size() as usize); } CommentCommand::JumpToMiddle => self.chunk_operations.push(RenderOperation::JumpToVerticalCenter), CommentCommand::InitColumnLayout(columns) => { self.validate_column_layout(&columns, source_position)?; let resolved_position = self.sources.resolve_source_position(source_position); self.slide_state.last_layout_comment = Some(resolved_position); self.slide_state.layout = LayoutState::InLayout { columns_count: columns.len() }; self.chunk_operations.push(RenderOperation::InitColumnLayout { columns }); self.slide_state.needs_enter_column = true; } CommentCommand::ResetLayout => { self.slide_state.layout = LayoutState::Default; self.chunk_operations.extend([RenderOperation::ExitLayout, RenderOperation::RenderLineBreak]); } CommentCommand::Column(column) => { let (current_column, columns_count) = match self.slide_state.layout { LayoutState::InColumn { column, columns_count } => (Some(column), columns_count), LayoutState::InLayout { columns_count } => (None, columns_count), LayoutState::Default => { return Err(self.invalid_presentation(source_position, InvalidPresentation::NoLayout)); } }; if current_column == Some(column) { return Err(self.invalid_presentation(source_position, InvalidPresentation::AlreadyInColumn)); } else if column >= columns_count { return Err(self.invalid_presentation(source_position, InvalidPresentation::ColumnIndexTooLarge)); } self.slide_state.layout = LayoutState::InColumn { column, columns_count }; self.chunk_operations.push(RenderOperation::EnterColumn { column }); } CommentCommand::IncrementalLists(value) => { self.slide_state.incremental_lists = Some(value); } CommentCommand::NoFooter => { self.slide_state.ignore_footer = true; } CommentCommand::SpeakerNote(_) => {} CommentCommand::FontSize(size) => { if size == 0 || size > 7 { return Err(self.invalid_presentation(source_position, InvalidPresentation::InvalidFontSize)); } self.slide_state.font_size = Some(size) } CommentCommand::Alignment(alignment) => { let alignment = match alignment { CommentCommandAlignment::Left => Alignment::Left { margin: Default::default() }, CommentCommandAlignment::Center => { Alignment::Center { minimum_margin: Default::default(), minimum_size: Default::default() } } CommentCommandAlignment::Right => Alignment::Right { margin: Default::default() }, }; self.slide_state.alignment = Some(alignment); } CommentCommand::SkipSlide => { self.slide_state.skip_slide = true; } CommentCommand::ListItemNewlines(count) => { self.slide_state.list_item_newlines = Some(count.into()); } CommentCommand::Include(path) => { self.process_include(path, source_position)?; return Ok(()); } CommentCommand::SnippetOutput(id) => { let handle = self.executable_snippets.get(&id).cloned().ok_or_else(|| { self.invalid_presentation(source_position, InvalidPresentation::UndefinedSnippetId(id)) })?; self.push_detached_code_execution(handle)?; return Ok(()); } }; // Don't push line breaks for any comments. self.slide_state.ignore_element_line_break = true; Ok(()) } fn process_comment_command_speaker_notes_mode(&mut self, comment_command: CommentCommand) { match comment_command { CommentCommand::SpeakerNote(note) => { for line in note.lines() { self.push_text(line.into(), ElementType::Paragraph); self.push_line_break(); } } CommentCommand::EndSlide => self.terminate_slide(), CommentCommand::SkipSlide => self.slide_state.skip_slide = true, _ => {} } } fn should_ignore_comment(&self, comment: &str) -> bool { if comment.contains('\n') || !comment.starts_with(&self.options.command_prefix) { // Ignore any multi line comment; those are assumed to be user comments // Ignore any line that doesn't start with the selected prefix. true } else if comment.trim().starts_with("vim:") { // ignore vim: commands true } else { // Ignore vim-like code folding tags let comment = comment.trim(); comment == "{{{" || comment == "}}}" } } fn process_include(&mut self, path: PathBuf, source_position: SourcePosition) -> BuildResult { let base = self.resource_base_path(); let resolved_path = self.resources.resolve_path(&path, &base); let contents = self.resources.external_text_file(&path, &base).map_err(|e| { self.invalid_presentation( source_position, InvalidPresentation::IncludeMarkdown { path: path.clone(), error: e }, ) })?; let elements = self.markdown_parser.parse(&contents).map_err(|e| { self.invalid_presentation( source_position, InvalidPresentation::ParseInclude { path: path.clone(), error: e }, ) })?; let _guard = self .sources .enter(resolved_path) .map_err(|e| self.invalid_presentation(source_position, InvalidPresentation::Import { path, error: e }))?; for element in elements { if let MarkdownElement::FrontMatter(_) = element { return Err(self.invalid_presentation(source_position, InvalidPresentation::IncludeFrontMatter)); } self.process_element_for_presentation_mode(element)?; } Ok(()) } } #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(rename_all = "snake_case")] enum CommentCommand { Alignment(CommentCommandAlignment), Column(usize), EndSlide, FontSize(u8), Include(PathBuf), IncrementalLists(bool), #[serde(rename = "column_layout")] InitColumnLayout(Vec), JumpToMiddle, ListItemNewlines(NonZeroU8), #[serde(alias = "newline")] NewLine, #[serde(alias = "newlines")] NewLines(u32), NoFooter, Pause, ResetLayout, SkipSlide, SpeakerNote(String), SnippetOutput(String), } impl FromStr for CommentCommand { type Err = CommandParseError; fn from_str(s: &str) -> Result { #[derive(Deserialize)] struct CommandWrapper(#[serde(with = "serde_yaml::with::singleton_map")] CommentCommand); let wrapper = serde_yaml::from_str::(s)?; Ok(wrapper.0) } } #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(rename_all = "snake_case")] enum CommentCommandAlignment { Left, Center, Right, } #[derive(thiserror::Error, Debug)] pub struct CommandParseError(#[from] serde_yaml::Error); impl fmt::Display for CommandParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let inner = self.0.to_string(); // Remove the trailing "at line X, ..." that comes from serde_yaml. This otherwise claims // we're always in line 1 because the yaml is parsed in isolation out of the HTML comment. let inner = inner.split(" at line").next().unwrap(); write!(f, "{inner}") } } #[cfg(test)] mod tests { use std::{fs, io::BufWriter}; use super::*; use crate::presentation::builder::{PresentationBuilderOptions, utils::Test}; use image::{DynamicImage, ImageEncoder, codecs::png::PngEncoder}; use rstest::rstest; use tempfile::tempdir; #[rstest] #[case::pause("pause", CommentCommand::Pause)] #[case::pause(" pause ", CommentCommand::Pause)] #[case::end_slide("end_slide", CommentCommand::EndSlide)] #[case::column_layout("column_layout: [1, 2]", CommentCommand::InitColumnLayout(vec![1, 2]))] #[case::column("column: 1", CommentCommand::Column(1))] #[case::reset_layout("reset_layout", CommentCommand::ResetLayout)] #[case::incremental_lists("incremental_lists: true", CommentCommand::IncrementalLists(true))] #[case::incremental_lists("new_lines: 2", CommentCommand::NewLines(2))] #[case::incremental_lists("newlines: 2", CommentCommand::NewLines(2))] #[case::incremental_lists("new_line", CommentCommand::NewLine)] #[case::incremental_lists("newline", CommentCommand::NewLine)] fn command_formatting(#[case] input: &str, #[case] expected: CommentCommand) { let parsed: CommentCommand = input.parse().expect("deserialization failed"); assert_eq!(parsed, expected); } #[rstest] #[case::multiline("hello\nworld")] #[case::many_open_braces("{{{")] #[case::many_close_braces("}}}")] #[case::vim_command("vim: hi")] #[case::padded_vim_command("vim: hi")] fn ignore_comments(#[case] comment: &str) { let input = format!(""); Test::new(input).build(); } #[rstest] #[case::command_with_prefix("cmd:end_slide", true)] #[case::non_command_with_prefix("cmd:bogus", false)] #[case::non_prefixed("random", true)] fn comment_prefix(#[case] comment: &str, #[case] should_work: bool) { let options = PresentationBuilderOptions { command_prefix: "cmd:".into(), ..Default::default() }; let element = MarkdownElement::Comment { comment: comment.into(), source_position: Default::default() }; let result = Test::new(vec![element]).options(options).try_build(); assert_eq!(result.is_ok(), should_work, "{result:?}"); } #[test] fn layout_without_init() { let input = ""; Test::new(input).expect_invalid(); } #[test] fn already_in_column() { let input = " "; Test::new(input).expect_invalid(); } #[test] fn column_index_overflow() { let input = " "; Test::new(input).expect_invalid(); } #[rstest] #[case::empty("column_layout: []")] #[case::zero("column_layout: [0]")] #[case::one_is_zero("column_layout: [1, 0]")] fn invalid_layouts(#[case] definition: &str) { let input = format!(""); Test::new(input).expect_invalid(); } #[test] fn operation_without_enter_column() { let input = " # hi "; Test::new(input).expect_invalid(); } #[test] fn end_slide_inside_layout() { let input = " "; let presentation = Test::new(input).build(); assert_eq!(presentation.iter_slides().count(), 2); } #[test] fn end_slide_inside_column() { let input = " "; let presentation = Test::new(input).build(); assert_eq!(presentation.iter_slides().count(), 2); } #[test] fn columns() { let input = " foo1 foo2 --- bar1 bar2 --- "; let lines = Test::new(input).render().rows(7).columns(24).into_lines(); let expected = &[ " ", "foo1 bar1 ", " ", "foo2 bar2 ", " ", "———————— ————————", " ", ]; assert_eq!(lines, expected); } #[test] fn columns_back_and_forth() { // this is the same as the above but we run back and forth between the columns let input = " foo1 bar1 foo2 --- bar2 --- "; let lines = Test::new(input).render().rows(7).columns(24).into_lines(); let expected = &[ " ", "foo1 bar1 ", " ", "foo2 bar2 ", " ", "———————— ————————", " ", ]; assert_eq!(lines, expected); } #[test] fn unevevn_columns() { let input = " foo1 foo2 --- bar1 bar2 --- "; let lines = Test::new(input).render().rows(7).columns(24).into_lines(); let expected = &[ " ", "foo1 bar1", " ", "foo2 bar2", " ", "———————————— ————", " ", ]; assert_eq!(lines, expected); } #[test] fn pause_layout() { let input = r" hi bye "; let lines = Test::new(input).render().rows(5).columns(12).advances(1).into_lines(); let expected = &[" ", "hi ", " ", " ", " "]; assert_eq!(lines, expected); } #[test] fn pause_new_slide() { let input = " hi bye "; let options = PresentationBuilderOptions { pause_create_new_slide: true, ..Default::default() }; let slides = Test::new(input).options(options).build().into_slides(); assert_eq!(slides.len(), 2); } #[test] fn skip_slide() { let input = " hi bye "; let lines = Test::new(input).render().rows(5).columns(3).into_lines(); let expected = &[" ", "bye", " ", " ", " "]; assert_eq!(lines, expected); } #[test] fn skip_all_slides() { let input = " hi "; let lines = Test::new(input).render().rows(5).columns(3).into_lines(); let expected = &[" ", " ", " ", " ", " "]; assert_eq!(lines, expected); } #[test] fn skip_slide_pauses() { let input = " hi bye "; let lines = Test::new(input).render().rows(2).columns(3).into_lines(); let expected = &[" ", "bye"]; assert_eq!(lines, expected); } #[test] fn skip_slide_speaker_note() { let input = " hi "; let options = PresentationBuilderOptions { render_speaker_notes_only: true, ..Default::default() }; let lines = Test::new(input).options(options).render().rows(2).columns(3).into_lines(); let expected = &[" ", "bye"]; assert_eq!(lines, expected); } #[test] fn speaker_notes() { let input = " "; let options = PresentationBuilderOptions { render_speaker_notes_only: true, ..Default::default() }; let lines = Test::new(input).options(options).render().rows(3).columns(3).into_lines(); let expected = &[" ", "hi ", "bye"]; assert_eq!(lines, expected); } #[test] fn alignment() { let input = " hi hello hola "; let lines = Test::new(input).render().rows(6).columns(16).into_lines(); let expected = &[ " ", "hi ", " ", " hello ", " ", " hola", ]; assert_eq!(lines, expected); } #[test] fn include() { let dir = tempdir().expect("failed to created tempdir"); let path = dir.path(); let inner_path = path.join("inner"); fs::create_dir_all(path.join(&inner_path)).expect("failed to create dir"); let image = DynamicImage::new_rgba8(1, 1); let mut buffer = BufWriter::new(fs::File::create(inner_path.join("img.png")).expect("failed to write image")); PngEncoder::new(&mut buffer) .write_image(image.as_bytes(), 1, 1, image.color().into()) .expect("failed to create imager"); drop(buffer); fs::write( path.join("first.md"), r" first === ![](inner/img.png) ```file path: inner/foo.txt language: text ``` ", ) .unwrap(); fs::write( inner_path.join("second.md"), r" second === ![](img.png) ", ) .unwrap(); fs::write(inner_path.join("foo.txt"), "a").unwrap(); let input = " hi "; let lines = Test::new(input).resources_path(path).render().rows(10).columns(12).into_lines(); let expected = &[ " ", "hi ", " ", "first ", " ", " ", "second ", " ", " ", "a ", ]; assert_eq!(lines, expected); } #[test] fn self_include() { let dir = tempdir().expect("failed to created tempdir"); let path = dir.path(); fs::write(path.join("main.md"), "").unwrap(); let input = ""; let err = Test::new(input).resources_path(path).expect_invalid(); assert!(err.to_string().contains("was already imported"), "{err:?}"); } #[test] fn include_cycle() { let dir = tempdir().expect("failed to created tempdir"); let path = dir.path(); fs::write(path.join("main.md"), "").unwrap(); fs::write(path.join("inner.md"), "").unwrap(); let input = ""; let err = Test::new(input).resources_path(path).expect_invalid(); assert!(err.to_string().contains("was already imported"), "{err:?}"); } } presenterm-0.15.1/src/presentation/builder/error.rs000064400000000000000000000207371046102023000205340ustar 00000000000000use crate::{ code::execute::UnsupportedExecution, markdown::{ elements::SourcePosition, parse::ParseError, text_style::{Color, TextStyle, UndefinedPaletteColorError}, }, presentation::builder::{comment::CommandParseError, images::ImageAttributeError, sources::MarkdownSourceError}, terminal::{capabilities::TerminalCapabilities, image::printer::RegisterImageError}, theme::{ProcessingThemeError, registry::LoadThemeError}, third_party::ThirdPartyRenderError, ui::footer::InvalidFooterTemplateError, }; use std::{ fmt, io::{self}, path::PathBuf, }; /// An error when building a presentation. #[derive(thiserror::Error, Debug)] pub(crate) enum BuildError { #[error("failed to read presentation file {0:?}: {1:?}")] ReadPresentation(PathBuf, io::Error), #[error("failed to register image: {0}")] RegisterImage(#[from] RegisterImageError), #[error("invalid theme: {0}")] InvalidTheme(#[from] LoadThemeError), #[error("invalid code highlighter theme: '{0}'")] InvalidCodeTheme(String), #[error("third party render failed: {0}")] ThirdPartyRender(#[from] ThirdPartyRenderError), #[error(transparent)] UnsupportedExecution(#[from] UnsupportedExecution), #[error(transparent)] UndefinedPaletteColor(#[from] UndefinedPaletteColorError), #[error("processing theme: {0}")] ThemeProcessing(#[from] ProcessingThemeError), #[error("invalid footer: {0}")] InvalidFooter(#[from] InvalidFooterTemplateError), #[error( "invalid markdown at {display_path}:{line}:{column}:\n\n{context}", display_path = .path.display(), line = .error.sourcepos.start.line, column = .error.sourcepos.start.column, )] Parse { path: PathBuf, error: ParseError, context: String }, #[error("cannot process presentation file: {0}")] EnterRoot(MarkdownSourceError), #[error( "error at {display_path}:{line}:{column}:\n\n{context}", display_path = .path.display(), line = .source_position.start.line, column = .source_position.start.column, )] InvalidPresentation { path: PathBuf, source_position: SourcePosition, context: String }, #[error("error in frontmatter:\n\n{0}")] InvalidFrontmatter(String), #[error("need to enter layout column explicitly using `column` command\n\n{0}")] NotInsideColumn(String), } #[derive(Debug, thiserror::Error)] pub(crate) enum InvalidPresentation { #[error("could not load image '{path}': {error}")] LoadImage { path: PathBuf, error: String }, #[error("invalid image attribute: {0}")] ParseImageAttribute(#[from] ImageAttributeError), #[error("invalid snippet: {0}")] Snippet(String), #[error("invalid command: {0}")] CommandParse(#[from] CommandParseError), #[error("invalid markdown in imported file {path:?}: {error}")] ParseInclude { path: PathBuf, error: ParseError }, #[error("could not read included markdown file {path:?}: {error}")] IncludeMarkdown { path: PathBuf, error: io::Error }, #[error("included markdown files cannot contain a front matter")] IncludeFrontMatter, #[error("cannot include markdown file at {path}: {error}")] Import { path: PathBuf, error: MarkdownSourceError }, #[error("can't enter layout: no layout defined")] NoLayout, #[error("can't enter layout column: already in it")] AlreadyInColumn, #[error("can't enter layout column: column index too large")] ColumnIndexTooLarge, #[error("invalid layout: {0}")] InvalidLayout(&'static str), #[error("font sizes must be >= 1 and <= 7")] InvalidFontSize, #[error("snippet id '{0}' not defined")] UndefinedSnippetId(String), #[error("snippet identifiers can only be used in +exec blocks")] SnippetIdNonExec, #[error("snippet id '{0}' already exists")] SnippetAlreadyExists(String), } #[derive(Debug)] pub(crate) struct FileSourcePosition { pub(crate) source_position: SourcePosition, pub(crate) file: PathBuf, } impl fmt::Display for FileSourcePosition { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let file = self.file.display(); let pos = &self.source_position; write!(f, "{file}:{pos}") } } pub(super) trait FormatError { fn format_error(self) -> String; } impl FormatError for String { fn format_error(self) -> String { TextStyle::default().fg_color(Color::Red).apply(&self, &Default::default()).to_string() } } #[derive(Default)] pub(super) struct ErrorContextBuilder<'a> { line: Option, column: Option, source_line: &'a str, error: &'a str, prefix_style: TextStyle, error_style: TextStyle, } impl<'a> ErrorContextBuilder<'a> { pub(super) fn new(source_line: &'a str, error: &'a str) -> Self { Self { line: None, column: None, source_line, error, prefix_style: TextStyle::default().fg_color(Color::Blue), error_style: TextStyle::default().fg_color(Color::Red), } } pub(super) fn position(mut self, position: SourcePosition) -> Self { self.line = Some(position.start.line); self.column = Some(position.start.column); self } pub(super) fn column(mut self, column: usize) -> Self { self.column = Some(column); self } pub(super) fn build(self) -> String { let Self { line, column, source_line, error, prefix_style, error_style } = self; let (error_line_prefix, empty_line, source_line) = match line { Some(line) => { let line_number = line.to_string(); let empty_prefix = " ".repeat(line_number.len()); let error_line_prefix = format!("{line_number} | "); let empty_line = format!("{empty_prefix} | "); let source_line = source_line.lines().nth(line.saturating_sub(1)).unwrap_or_default(); (error_line_prefix, empty_line, source_line) } None => { let prefix = " | ".to_string(); (prefix.clone(), prefix, source_line) } }; let column = column.map(|c| c.saturating_sub(1)).unwrap_or_default(); let capabilities = TerminalCapabilities::default(); let empty_line = prefix_style.apply(&empty_line, &capabilities).to_string(); let mut output = empty_line.clone(); output.push('\n'); let prefix = prefix_style.apply(&error_line_prefix, &capabilities).to_string(); output.push_str(&format!("{prefix}{source_line}\n")); let indicator = format!("{}^ {error}", " ".repeat(column)); let indicator = error_style.apply(&indicator, &capabilities).to_string(); let indicator_line = format!("{empty_line}{indicator}"); output.push_str(&indicator_line); output } } #[cfg(test)] mod tests { use super::*; use crate::markdown::elements::LineColumn; trait ErrorContextBuilderExt { fn into_lines(self) -> Vec; } impl ErrorContextBuilderExt for ErrorContextBuilder<'_> { fn into_lines(self) -> Vec { let error = self.build(); error.lines().map(ToString::to_string).collect() } } fn make_builder<'a>(source_line: &'a str, error: &'a str) -> ErrorContextBuilder<'a> { let mut builder = ErrorContextBuilder::new(source_line.into(), error.into()); builder.prefix_style = Default::default(); builder.error_style = Default::default(); builder } #[test] fn position() { let lines = make_builder("foo\nbear\ntar", "'a' not allowed") .position(SourcePosition { start: LineColumn { line: 2, column: 3 } }) .into_lines(); let expected = &[ // " | ", "2 | bear", " | ^ 'a' not allowed", ]; assert_eq!(&lines, expected); } #[test] fn no_position() { let lines = make_builder("bear", "'b' not allowed").into_lines(); let expected = &[ // " | ", " | bear", " | ^ 'b' not allowed", ]; assert_eq!(&lines, expected); } #[test] fn column() { let lines = make_builder("bear", "'e' not allowed").column(2).into_lines(); let expected = &[ // " | ", " | bear", " | ^ 'e' not allowed", ]; assert_eq!(&lines, expected); } } presenterm-0.15.1/src/presentation/builder/frontmatter.rs000064400000000000000000000245321046102023000217450ustar 00000000000000use crate::{ config::OptionsConfig, markdown::{ elements::{Line, Text}, parse::MarkdownParser, text_style::TextStyle, }, presentation::{ PresentationMetadata, PresentationThemeMetadata, builder::{ BuildResult, ErrorContextBuilder, PresentationBuilder, error::{BuildError, FormatError}, }, }, render::operation::RenderOperation, theme::{AuthorPositioning, ElementType, PresentationTheme}, }; use comrak::Arena; impl PresentationBuilder<'_, '_> { pub(crate) fn process_front_matter(&mut self, contents: &str) -> BuildResult { let metadata = match self.options.strict_front_matter_parsing { true => serde_yaml::from_str::(contents).map(PresentationMetadata::from), false => serde_yaml::from_str::(contents), }; let mut metadata = metadata.map_err(|e| BuildError::InvalidFrontmatter(e.to_string().format_error()))?; if metadata.author.is_some() && !metadata.authors.is_empty() { return Err(BuildError::InvalidFrontmatter( ErrorContextBuilder::new("authors:", "cannot have both 'author' and 'authors'").build(), )); } if let Some(options) = metadata.options.take() { self.options.merge(options); } { let footer_context = &mut self.footer_vars; footer_context.title.clone_from(&metadata.title); footer_context.sub_title.clone_from(&metadata.sub_title); footer_context.location.clone_from(&metadata.location); footer_context.event.clone_from(&metadata.event); footer_context.date.clone_from(&metadata.date); footer_context.author.clone_from(&metadata.author); } self.set_theme(&metadata.theme)?; if metadata.has_frontmatter() { self.push_slide_prelude(); self.push_intro_slide(metadata)?; } Ok(()) } fn set_theme(&mut self, metadata: &PresentationThemeMetadata) -> BuildResult { if metadata.name.is_some() && metadata.path.is_some() { return Err(BuildError::InvalidFrontmatter( ErrorContextBuilder::new("path:", "cannot set both 'theme.path' and 'theme.name'").build(), )); } let mut new_theme = None; // Only override the theme if we're not forced to use the default one. if !self.options.force_default_theme { if let Some(theme_name) = &metadata.name { let theme = self.themes.presentation.load_by_name(theme_name).ok_or_else(|| { BuildError::InvalidFrontmatter( ErrorContextBuilder::new(&format!("name: {theme_name}"), "theme does not exist") .column(7) .build(), ) })?; new_theme = Some(theme); } if let Some(theme_path) = &metadata.path { let mut theme = self.resources.theme(theme_path)?; if let Some(name) = &theme.extends { let base = self.themes.presentation.load_by_name(name).ok_or_else(|| { BuildError::InvalidFrontmatter( ErrorContextBuilder::new(&format!("extends: {name}"), "extended theme does not exist") .column(10) .build(), ) })?; theme = merge_struct::merge(&theme, &base) .map_err(|e| BuildError::InvalidFrontmatter(format!("malformed theme: {e}")))?; } new_theme = Some(theme); } } if let Some(overrides) = &metadata.overrides { if let Some(extends) = &overrides.extends { return Err(BuildError::InvalidFrontmatter( ErrorContextBuilder::new(&format!("extends: {extends}"), "theme overrides can't use 'extends'") .build(), )); } let base = new_theme.as_ref().unwrap_or(self.default_raw_theme); // This shouldn't fail as the models are already correct. let theme = merge_struct::merge(base, overrides) .map_err(|e| BuildError::InvalidFrontmatter(format!("malformed theme: {e}")))?; new_theme = Some(theme); } if let Some(theme) = new_theme { self.theme = PresentationTheme::new(&theme, &self.resources, &self.options.theme_options)?; } Ok(()) } fn push_intro_slide(&mut self, metadata: PresentationMetadata) -> BuildResult { let styles = &self.theme.intro_slide; let create_text = |text: Option, style: TextStyle| -> Option { text.map(|text| Text::new(text, style)) }; let title_lines = metadata .title .map(|t| self.format_multiline(t, &self.theme.intro_slide.title.style, "title")) .transpose()?; let sub_title_lines = metadata .sub_title .map(|t| self.format_multiline(t, &self.theme.intro_slide.subtitle.style, "sub_title")) .transpose()?; let event = create_text(metadata.event, styles.event.style); let location = create_text(metadata.location, styles.location.style); let date = create_text(metadata.date, styles.date.style); let authors: Vec<_> = metadata .author .into_iter() .chain(metadata.authors) .map(|author| Text::new(author, styles.author.style)) .collect(); if !styles.footer { self.slide_state.ignore_footer = true; } self.chunk_operations.push(RenderOperation::JumpToVerticalCenter); if let Some(title_lines) = title_lines { for line in title_lines { self.push_text(line, ElementType::PresentationTitle); self.push_line_break(); } } if let Some(sub_title_lines) = sub_title_lines { for line in sub_title_lines { self.push_text(line, ElementType::PresentationSubTitle); self.push_line_break(); } } if event.is_some() || location.is_some() || date.is_some() { self.push_line_breaks(2); if let Some(event) = event { self.push_intro_slide_text(event, ElementType::PresentationEvent); } if let Some(location) = location { self.push_intro_slide_text(location, ElementType::PresentationLocation); } if let Some(date) = date { self.push_intro_slide_text(date, ElementType::PresentationDate); } } if !authors.is_empty() { match self.theme.intro_slide.author.positioning { AuthorPositioning::BelowTitle => { self.push_line_breaks(3); } AuthorPositioning::PageBottom => { self.chunk_operations.push(RenderOperation::JumpToBottomRow { index: authors.len() as u16 - 1 }); } }; for author in authors { self.push_intro_slide_text(author, ElementType::PresentationAuthor); } } self.slide_state.title = Some(Line::from("[Introduction]")); self.terminate_slide(); Ok(()) } fn push_intro_slide_text(&mut self, text: Text, element_type: ElementType) { self.push_text(Line::from(text), element_type); self.push_line_break(); } fn format_multiline( &self, text: String, style: &TextStyle, attribute: &'static str, ) -> Result, BuildError> { let arena = Arena::default(); let parser = MarkdownParser::new(&arena); let mut lines = Vec::new(); for line in text.lines() { let line = parser.parse_inlines(line).map_err(|e| { BuildError::InvalidFrontmatter( ErrorContextBuilder::new(&format!("{attribute}: ..."), &e.to_string()) .column(attribute.len() + 3) .build(), ) })?; let mut line = line.resolve(&self.theme.palette)?; line.apply_style(style); lines.push(line); } Ok(lines) } } #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct StrictPresentationMetadata { #[serde(default)] title: Option, #[serde(default)] sub_title: Option, #[serde(default)] event: Option, #[serde(default)] location: Option, #[serde(default)] date: Option, #[serde(default)] author: Option, #[serde(default)] authors: Vec, #[serde(default)] theme: PresentationThemeMetadata, #[serde(default)] options: Option, } impl From for PresentationMetadata { fn from(strict: StrictPresentationMetadata) -> Self { let StrictPresentationMetadata { title, sub_title, event, location, date, author, authors, theme, options } = strict; Self { title, sub_title, event, location, date, author, authors, theme, options } } } #[cfg(test)] mod tests { use crate::{presentation::builder::utils::Test, theme::raw}; #[test] fn multiline_centered_title() { let input = "--- title: | Beep Boop boop --- "; let theme = raw::PresentationTheme { intro_slide: raw::IntroSlideStyle { title: raw::IntroSlideTitleStyle { alignment: Some(raw::Alignment::Center { minimum_margin: raw::Margin::Fixed(2), minimum_size: 1 }), ..Default::default() }, ..Default::default() }, ..Default::default() }; let lines = Test::new(input).theme(theme).render().rows(7).columns(16).advances(0).into_lines(); let expected = &[ " ", " ", " Beep ", " Boop boop ", " ", " ", " ", ]; assert_eq!(lines, expected); } } presenterm-0.15.1/src/presentation/builder/heading.rs000064400000000000000000000126101046102023000207710ustar 00000000000000use crate::{ markdown::elements::{Line, Text}, presentation::builder::{BuildResult, LastElement, PresentationBuilder}, theme::{ElementType, raw::RawColor}, ui::separator::RenderSeparator, }; impl PresentationBuilder<'_, '_> { pub(crate) fn push_slide_title(&mut self, text: Vec>) -> BuildResult { if self.options.implicit_slide_ends && !matches!(self.slide_state.last_element, LastElement::None) { self.terminate_slide(); } let mut style = self.theme.slide_title.clone(); self.push_line_breaks(style.padding_top as usize); for title_line in text { let mut title_line = title_line.resolve(&self.theme.palette)?; self.slide_state.title.get_or_insert_with(|| title_line.clone()); if let Some(font_size) = self.slide_state.font_size { style.style = style.style.size(font_size); } title_line.apply_style(&style.style); self.push_text(title_line, ElementType::SlideTitle); self.push_line_break(); } for _ in 0..style.padding_bottom { self.push_line_break(); } if style.separator { self.chunk_operations .push(RenderSeparator::new(Line::default(), Default::default(), style.style.size).into()); self.push_line_break(); } self.push_line_break(); self.slide_state.ignore_element_line_break = true; Ok(()) } pub(crate) fn push_heading(&mut self, level: u8, text: Line) -> BuildResult { let mut text = text.resolve(&self.theme.palette)?; let (element_type, style) = match level { 1 => (ElementType::Heading1, &self.theme.headings.h1), 2 => (ElementType::Heading2, &self.theme.headings.h2), 3 => (ElementType::Heading3, &self.theme.headings.h3), 4 => (ElementType::Heading4, &self.theme.headings.h4), 5 => (ElementType::Heading5, &self.theme.headings.h5), 6 => (ElementType::Heading6, &self.theme.headings.h6), other => panic!("unexpected heading level {other}"), }; if let Some(prefix) = &style.prefix { if !prefix.is_empty() { let mut prefix = prefix.clone(); prefix.push(' '); text.0.insert(0, Text::from(prefix)); } } text.apply_style(&style.style); self.push_text(text, element_type); self.push_line_breaks(self.slide_font_size() as usize); Ok(()) } } #[cfg(test)] mod tests { use crate::{ markdown::text_style::Color, presentation::builder::{PresentationBuilderOptions, utils::Test}, theme::raw, }; #[test] fn slide_title() { let input = " title === hi "; let color = Color::new(1, 1, 1); let theme = raw::PresentationTheme { slide_title: raw::SlideTitleStyle { separator: true, padding_top: Some(1), padding_bottom: Some(1), colors: raw::RawColors { foreground: None, background: Some(raw::RawColor::Color(color)) }, ..Default::default() }, ..Default::default() }; let lines = Test::new(input).theme(theme).render().rows(8).columns(5).into_lines(); let expected = &[" ", " ", "title", " ", "—————", " ", "hi ", " "]; assert_eq!(lines, expected); } #[test] fn centered_slide_title() { let input = " hi === "; let theme = raw::PresentationTheme { slide_title: raw::SlideTitleStyle { alignment: Some(raw::Alignment::Center { minimum_margin: raw::Margin::Fixed(1), minimum_size: 0 }), ..Default::default() }, ..Default::default() }; let lines = Test::new(input).theme(theme).render().rows(3).columns(6).into_lines(); let expected = &[" ", " hi ", " "]; assert_eq!(lines, expected); } #[test] fn implicit_slide_ends() { let input = " hi === foo bye === bar "; let options = PresentationBuilderOptions { implicit_slide_ends: true, ..Default::default() }; let lines = Test::new(input).options(options).render().rows(4).columns(6).advances(1).into_lines(); let expected = &[" ", "bye ", " ", "bar "]; assert_eq!(lines, expected); } #[test] fn headings() { let input = " # A ## B ### C #### D ##### E "; let theme = raw::PresentationTheme { headings: raw::HeadingStyles { h1: raw::HeadingStyle { prefix: Some("!".to_string()), ..Default::default() }, h2: raw::HeadingStyle { prefix: Some("@@".to_string()), ..Default::default() }, h3: raw::HeadingStyle { alignment: Some(raw::Alignment::Center { minimum_margin: raw::Margin::Fixed(1), minimum_size: 0 }), ..Default::default() }, ..Default::default() }, ..Default::default() }; let lines = Test::new(input).theme(theme).render().rows(10).columns(6).advances(1).into_lines(); let expected = &[" ", "! A ", " ", "@@ B ", " ", " C ", " ", "D ", " ", "E "]; assert_eq!(lines, expected); } } presenterm-0.15.1/src/presentation/builder/images.rs000064400000000000000000000102711046102023000206400ustar 00000000000000use crate::{ markdown::elements::{Percent, PercentParseError, SourcePosition}, presentation::builder::{ BuildResult, PresentationBuilder, error::{BuildError, InvalidPresentation}, }, render::operation::{ImageRenderProperties, ImageSize, RenderOperation}, terminal::image::Image, }; use std::path::PathBuf; impl PresentationBuilder<'_, '_> { pub(crate) fn push_image_from_path( &mut self, path: PathBuf, title: String, source_position: SourcePosition, ) -> BuildResult { let base_path = self.resource_base_path(); let image = self.resources.image(&path, &base_path).map_err(|e| { self.invalid_presentation(source_position, InvalidPresentation::LoadImage { path, error: e.to_string() }) })?; self.push_image(image, title, source_position) } pub(crate) fn push_image(&mut self, image: Image, title: String, source_position: SourcePosition) -> BuildResult { let attributes = self.parse_image_attributes(&title, &self.options.image_attribute_prefix, source_position)?; let size = match attributes.width { Some(percent) => ImageSize::WidthScaled { ratio: percent.as_ratio() }, None => ImageSize::ShrinkIfNeeded, }; let properties = ImageRenderProperties { size, background_color: self.theme.default_style.style.colors.background, ..Default::default() }; self.chunk_operations.push(RenderOperation::RenderImage(image, properties)); Ok(()) } fn parse_image_attributes( &self, input: &str, attribute_prefix: &str, source_position: SourcePosition, ) -> Result { let mut attributes = ImageAttributes::default(); for attribute in input.split(',') { let Some((prefix, suffix)) = attribute.split_once(attribute_prefix) else { continue }; if !prefix.is_empty() || (attribute_prefix.is_empty() && suffix.is_empty()) { continue; } Self::parse_image_attribute(suffix, &mut attributes) .map_err(|e| self.invalid_presentation(source_position, e))?; } Ok(attributes) } fn parse_image_attribute(input: &str, attributes: &mut ImageAttributes) -> Result<(), ImageAttributeError> { let Some((key, value)) = input.split_once(':') else { return Err(ImageAttributeError::AttributeMissing); }; match key { "width" | "w" => { let width = value.parse().map_err(ImageAttributeError::InvalidWidth)?; attributes.width = Some(width); Ok(()) } _ => Err(ImageAttributeError::UnknownAttribute(key.to_string())), } } } #[derive(thiserror::Error, Debug)] pub(crate) enum ImageAttributeError { #[error("invalid width: {0}")] InvalidWidth(PercentParseError), #[error("no attribute given")] AttributeMissing, #[error("unknown attribute: '{0}'")] UnknownAttribute(String), } #[derive(Clone, Debug, Default, PartialEq)] struct ImageAttributes { width: Option, } #[cfg(test)] mod tests { use super::*; use crate::presentation::builder::utils::Test; use rstest::rstest; #[rstest] #[case::width("image:width:50%", Some(50))] #[case::w("image:w:50%", Some(50))] #[case::nothing("", None)] #[case::no_prefix("width", None)] fn image_attributes(#[case] input: &str, #[case] expectation: Option) { let attributes = Test::new("").with_builder(|builder| { builder.parse_image_attributes(input, "image:", Default::default()).expect("failed to parse") }); assert_eq!(attributes.width, expectation.map(Percent)); } #[rstest] #[case::width("width:50%", Some(50))] #[case::empty("", None)] fn image_attributes_empty_prefix(#[case] input: &str, #[case] expectation: Option) { let attributes = Test::new("").with_builder(|builder| { builder.parse_image_attributes(input, "", Default::default()).expect("failed to parse") }); assert_eq!(attributes.width, expectation.map(Percent)); } } presenterm-0.15.1/src/presentation/builder/list.rs000064400000000000000000000313231046102023000203470ustar 00000000000000use crate::{ markdown::{ elements::{ListItem, ListItemType, Text}, text_style::TextStyle, }, presentation::builder::{BuildResult, LastElement, PresentationBuilder}, render::operation::{BlockLine, RenderOperation}, }; impl<'a, 'b> PresentationBuilder<'a, 'b> { pub(crate) fn push_list(&mut self, list: Vec) -> BuildResult { let last_chunk_operation = self.slide_chunks.last().and_then(|chunk| chunk.iter_operations().last()); // If the last chunk ended in a list, pop the newline so we get them all next to each // other. if matches!(last_chunk_operation, Some(RenderOperation::RenderLineBreak)) && self.slide_state.last_chunk_ended_in_list && self.chunk_operations.is_empty() { self.slide_chunks.last_mut().unwrap().pop_last(); } // If this chunk just starts (because there was a pause), pick up from the last index. let start_index = match self.slide_state.last_element { LastElement::List { last_index } if self.chunk_operations.is_empty() => last_index + 1, _ => 0, }; let block_length = list.iter().map(|l| self.list_item_prefix(l).width() + l.contents.width()).max().unwrap_or_default() as u16; let block_length = block_length * self.slide_font_size() as u16; let incremental_lists = self.slide_state.incremental_lists.unwrap_or(self.options.incremental_lists); let iter = ListIterator::new(list, start_index); if incremental_lists && self.options.pause_before_incremental_lists { self.push_pause(); } for (index, item) in iter.enumerate() { if index > 0 && incremental_lists { self.push_pause(); } self.push_list_item(item.index, item.item, block_length)?; } if incremental_lists && self.options.pause_after_incremental_lists { self.push_pause(); } Ok(()) } fn push_list_item(&mut self, index: usize, item: ListItem, block_length: u16) -> BuildResult { let prefix = self.list_item_prefix(&item); let mut text = item.contents.resolve(&self.theme.palette)?; let font_size = self.slide_font_size(); for piece in &mut text.0 { if piece.style.is_code() { piece.style.colors = self.theme.inline_code.style.colors; } piece.style = piece.style.size(font_size); } let alignment = self.slide_state.alignment.unwrap_or_default(); self.chunk_operations.push(RenderOperation::RenderBlockLine(BlockLine { prefix: prefix.into(), right_padding_length: 0, repeat_prefix_on_wrap: false, text: text.into(), block_length, alignment, block_color: None, })); let newlines = self.slide_state.list_item_newlines.unwrap_or(self.options.list_item_newlines); self.push_line_breaks(newlines as usize); if item.depth == 0 { self.slide_state.last_element = LastElement::List { last_index: index }; } Ok(()) } fn list_item_prefix(&self, item: &ListItem) -> Text { let font_size = self.slide_font_size(); let spaces_per_indent = match item.depth { 0 => 3_u8.div_ceil(font_size), _ => { if font_size == 1 { 3 } else { 2 } } }; let padding_length = (item.depth as usize + 1) * spaces_per_indent as usize; let mut prefix: String = " ".repeat(padding_length); match item.item_type { ListItemType::Unordered => { let delimiter = match item.depth { 0 => '•', 1 => '◦', _ => '▪', }; prefix.push(delimiter); prefix.push_str(" "); } ListItemType::OrderedParens(value) => { prefix.push_str(&value.to_string()); prefix.push_str(") "); } ListItemType::OrderedPeriod(value) => { prefix.push_str(&value.to_string()); prefix.push_str(". "); } }; Text::new(prefix, TextStyle::default().size(font_size)) } } struct ListIterator { remaining: I, next_index: usize, current_depth: u8, saved_indexes: Vec, } impl ListIterator { fn new(remaining: T, next_index: usize) -> Self where I: Iterator, T: IntoIterator, { Self { remaining: remaining.into_iter(), next_index, current_depth: 0, saved_indexes: Vec::new() } } } impl Iterator for ListIterator where I: Iterator, { type Item = IndexedListItem; fn next(&mut self) -> Option { let head = self.remaining.next()?; if head.depth != self.current_depth { if head.depth > self.current_depth { // If we're going deeper, save the next index so we can continue later on and start // from 0. self.saved_indexes.push(self.next_index); self.next_index = 0; } else { // if we're getting out, recover the index we had previously saved. for _ in head.depth..self.current_depth { self.next_index = self.saved_indexes.pop().unwrap_or(0); } } self.current_depth = head.depth; } let index = self.next_index; self.next_index += 1; Some(IndexedListItem { index, item: head }) } } #[derive(Debug)] struct IndexedListItem { index: usize, item: ListItem, } #[cfg(test)] mod tests { use super::*; use crate::presentation::builder::{PresentationBuilderOptions, utils::Test}; use rstest::rstest; use std::iter; #[test] fn iterate_list() { let iter = ListIterator::new( vec![ ListItem { depth: 0, contents: "0".into(), item_type: ListItemType::Unordered }, ListItem { depth: 0, contents: "1".into(), item_type: ListItemType::Unordered }, ListItem { depth: 1, contents: "00".into(), item_type: ListItemType::Unordered }, ListItem { depth: 1, contents: "01".into(), item_type: ListItemType::Unordered }, ListItem { depth: 1, contents: "02".into(), item_type: ListItemType::Unordered }, ListItem { depth: 2, contents: "001".into(), item_type: ListItemType::Unordered }, ListItem { depth: 0, contents: "2".into(), item_type: ListItemType::Unordered }, ], 0, ); let expected_indexes = [0, 1, 0, 1, 2, 0, 2]; let indexes: Vec<_> = iter.map(|item| item.index).collect(); assert_eq!(indexes, expected_indexes); } #[test] fn iterate_list_starting_from_other() { let list = ListIterator::new( vec![ ListItem { depth: 0, contents: "0".into(), item_type: ListItemType::Unordered }, ListItem { depth: 0, contents: "1".into(), item_type: ListItemType::Unordered }, ], 3, ); let expected_indexes = [3, 4]; let indexes: Vec<_> = list.into_iter().map(|item| item.index).collect(); assert_eq!(indexes, expected_indexes); } #[test] fn unordered() { let input = " * A * AA * AAA * AB * B * BA "; let lines = Test::new(input).render().rows(7).columns(16).into_lines(); let expected = &[ " ", " • A ", " ◦ AA ", " ▪ AAA ", " ◦ AB ", " • B ", " ◦ BA ", ]; assert_eq!(lines, expected); } #[test] fn unordered_paused() { let input = " * A * B * C "; let lines = Test::new(input).render().rows(4).columns(8).into_lines(); let expected = &[" ", " • A ", " • B ", " • C "]; assert_eq!(lines, expected); } #[test] fn ordered_period() { let input = " 1. A 1. AA 1. AAA 2. AB 2. B 1. BA "; let lines = Test::new(input).render().rows(7).columns(16).into_lines(); let expected = &[ " ", " 1. A ", " 1. AA ", " 1. AAA ", " 2. AB ", " 2. B ", " 1. BA ", ]; assert_eq!(lines, expected); } #[test] fn ordered_parens() { let input = " 1) A 1) AA 2) B "; let lines = Test::new(input).render().rows(4).columns(12).into_lines(); let expected = &[" ", " 1) A ", " 1) AA ", " 2) B "]; assert_eq!(lines, expected); } #[test] fn ordered_paused() { let input = " 1. A 2. B 3. C "; let lines = Test::new(input).render().rows(4).columns(8).into_lines(); let expected = &[" ", " 1. A ", " 2. B ", " 3. C "]; assert_eq!(lines, expected); } #[rstest] #[case::zero(0)] #[case::one(1)] #[case::two(2)] fn visible_pauses(#[case] advances: usize) { let input = " * A * B * C "; let lines = Test::new(input).render().rows(4).columns(8).advances(advances).into_lines(); let mut expected = vec![" ", " • A "]; if advances >= 1 { expected.push(" • B "); } if advances >= 2 { expected.push(" • C "); } expected.extend(iter::repeat_n(" ", 4 - expected.len())); assert_eq!(lines, expected); } #[rstest] #[case::first_no_before_no_after(true, true, 0, 0)] #[case::first_no_before(false, true, 0, 1)] #[case::second_no_before_no_after(true, true, 1, 1)] #[case::second_no_before(false, true, 1, 2)] #[case::second(false, false, 2, 4)] #[case::third_no_before_no_after(true, true, 2, 2)] #[case::third_no_before(false, true, 3, 4)] #[case::third_no_after(true, false, 3, 4)] fn incremental_lists( #[case] pause_before: bool, #[case] pause_after: bool, #[case] advances: usize, #[case] visible: usize, ) { let input = " * A * B * C hi "; let options = PresentationBuilderOptions { pause_before_incremental_lists: pause_before, pause_after_incremental_lists: pause_after, ..Default::default() }; let lines = Test::new(input).options(options).render().rows(6).columns(8).advances(advances).into_lines(); let mut expected = vec![" "]; if visible >= 1 { expected.push(" • A "); } if visible >= 2 { expected.push(" • B "); } if visible >= 3 { expected.push(" • C "); } if visible >= 4 { expected.push(" "); expected.push("hi "); } expected.extend(iter::repeat_n(" ", 6 - expected.len())); assert_eq!(lines, expected); } #[test] fn font_size() { let input = " * A * B "; let lines = Test::new(input).render().rows(4).columns(12).into_lines(); let expected = &[" ", " • A ", " ", " • B "]; assert_eq!(lines, expected); } #[test] fn newlines() { let input = " * A * B "; let lines = Test::new(input).render().rows(4).columns(8).into_lines(); let expected = &[" ", " • A ", " ", " • B "]; assert_eq!(lines, expected); } #[test] fn incremental_lists_end_of_slide() { let input = " * A * B other "; // 3 moves forward should land in the second slide, not an extra pause at the end let lines = Test::new(input).render().rows(4).columns(8).advances(3).into_lines(); let expected = &[" ", "other ", " ", " "]; assert_eq!(lines, expected); } #[test] fn pause_after_list() { let input = " 1. A # hi 2. B "; let lines = Test::new(input).render().rows(4).columns(8).advances(0).into_lines(); let expected = &[" ", " 1. A ", " ", " "]; assert_eq!(lines, expected); } } presenterm-0.15.1/src/presentation/builder/mod.rs000064400000000000000000000742601046102023000201620ustar 00000000000000use crate::{ code::{ execute::SnippetExecutor, highlighting::{HighlightThemeSet, SnippetHighlighter}, snippet::SnippetLanguage, }, config::{KeyBindingsConfig, OptionsConfig}, markdown::{ elements::{Line, MarkdownElement, SourcePosition, Text}, parse::MarkdownParser, text::WeightedLine, text_style::{Color, Colors}, }, presentation::{ ChunkMutator, Modals, Presentation, PresentationState, RenderOperation, SlideBuilder, SlideChunk, builder::{ error::{BuildError, ErrorContextBuilder, FileSourcePosition, InvalidPresentation}, sources::MarkdownSources, }, }, render::operation::MarginProperties, resource::{ResourceBasePath, Resources}, terminal::image::{ Image, printer::{ImageRegistry, ImageSpec, RegisterImageError}, }, theme::{ Alignment, ElementType, PresentationTheme, ProcessingThemeError, ThemeOptions, raw::{self, RawColor}, registry::PresentationThemeRegistry, }, third_party::ThirdPartyRender, ui::{ execution::output::SnippetHandle, footer::{FooterGenerator, FooterVariables}, modals::{IndexBuilder, KeyBindingsModalBuilder}, separator::RenderSeparator, }, }; use image::DynamicImage; use std::{ collections::{HashMap, HashSet}, fs, io, iter, mem, path::Path, rc::Rc, sync::Arc, }; pub(crate) mod error; mod comment; mod frontmatter; mod heading; mod images; mod list; mod quote; mod snippet; mod sources; mod table; #[cfg(test)] mod tests; pub(crate) type BuildResult = Result<(), BuildError>; #[derive(Default)] pub struct Themes { pub presentation: PresentationThemeRegistry, pub highlight: HighlightThemeSet, } #[derive(Clone, Debug)] pub struct PresentationBuilderOptions { pub allow_mutations: bool, pub implicit_slide_ends: bool, pub command_prefix: String, pub image_attribute_prefix: String, pub incremental_lists: bool, pub force_default_theme: bool, pub end_slide_shorthand: bool, pub print_modal_background: bool, pub strict_front_matter_parsing: bool, pub enable_snippet_execution: bool, pub enable_snippet_execution_replace: bool, pub render_speaker_notes_only: bool, pub auto_render_languages: Vec, pub theme_options: ThemeOptions, pub pause_before_incremental_lists: bool, pub pause_after_incremental_lists: bool, pub pause_create_new_slide: bool, pub list_item_newlines: u8, pub validate_snippets: bool, } impl PresentationBuilderOptions { fn merge(&mut self, options: OptionsConfig) { self.implicit_slide_ends = options.implicit_slide_ends.unwrap_or(self.implicit_slide_ends); self.incremental_lists = options.incremental_lists.unwrap_or(self.incremental_lists); self.end_slide_shorthand = options.end_slide_shorthand.unwrap_or(self.end_slide_shorthand); self.strict_front_matter_parsing = options.strict_front_matter_parsing.unwrap_or(self.strict_front_matter_parsing); if let Some(prefix) = options.command_prefix { self.command_prefix = prefix; } if let Some(prefix) = options.image_attributes_prefix { self.image_attribute_prefix = prefix; } if !options.auto_render_languages.is_empty() { self.auto_render_languages = options.auto_render_languages; } if let Some(count) = options.list_item_newlines { self.list_item_newlines = count.into(); } } } impl Default for PresentationBuilderOptions { fn default() -> Self { Self { allow_mutations: true, implicit_slide_ends: false, command_prefix: String::default(), image_attribute_prefix: "image:".to_string(), incremental_lists: false, force_default_theme: false, end_slide_shorthand: false, print_modal_background: false, strict_front_matter_parsing: true, enable_snippet_execution: false, enable_snippet_execution_replace: false, render_speaker_notes_only: false, auto_render_languages: Default::default(), theme_options: ThemeOptions { font_size_supported: false }, pause_before_incremental_lists: true, pause_after_incremental_lists: true, pause_create_new_slide: false, list_item_newlines: 1, validate_snippets: false, } } } /// Builds a presentation. /// /// This type transforms [MarkdownElement]s and turns them into a presentation, which is made up of /// render operations. pub(crate) struct PresentationBuilder<'a, 'b> { slide_chunks: Vec, chunk_operations: Vec, chunk_mutators: Vec>, slide_builders: Vec, highlighter: SnippetHighlighter, snippet_executor: Arc, theme: PresentationTheme, default_raw_theme: &'a raw::PresentationTheme, resources: Resources, third_party: &'a mut ThirdPartyRender, slide_state: SlideState, presentation_state: PresentationState, footer_vars: FooterVariables, themes: &'a Themes, index_builder: IndexBuilder, image_registry: ImageRegistry, bindings_config: KeyBindingsConfig, slides_without_footer: HashSet, markdown_parser: &'a MarkdownParser<'b>, executable_snippets: HashMap, sources: MarkdownSources, options: PresentationBuilderOptions, } impl<'a, 'b> PresentationBuilder<'a, 'b> { /// Construct a new builder. #[allow(clippy::too_many_arguments)] pub(crate) fn new( default_raw_theme: &'a raw::PresentationTheme, resources: Resources, third_party: &'a mut ThirdPartyRender, code_executor: Arc, themes: &'a Themes, image_registry: ImageRegistry, bindings_config: KeyBindingsConfig, markdown_parser: &'a MarkdownParser<'b>, options: PresentationBuilderOptions, ) -> Result { let theme = PresentationTheme::new(default_raw_theme, &resources, &options.theme_options)?; Ok(Self { slide_chunks: Vec::new(), chunk_operations: Vec::new(), chunk_mutators: Vec::new(), slide_builders: Vec::new(), highlighter: SnippetHighlighter::default(), snippet_executor: code_executor, theme, default_raw_theme, resources, third_party, slide_state: Default::default(), presentation_state: Default::default(), footer_vars: Default::default(), themes, index_builder: Default::default(), image_registry, bindings_config, slides_without_footer: HashSet::new(), markdown_parser, sources: Default::default(), executable_snippets: Default::default(), options, }) } /// Build a presentation from a markdown input. pub(crate) fn build(self, path: &Path) -> Result { self.build_with_reader(path, FilesystemPresentationReader) } /// Build a presentation from already parsed elements. pub(crate) fn build_from_parsed(mut self, elements: Vec) -> Result { let mut skip_first = false; if let Some(MarkdownElement::FrontMatter(contents)) = elements.first() { self.process_front_matter(contents)?; skip_first = true; } let mut elements = elements.into_iter(); if skip_first { elements.next(); } self.set_code_theme()?; if self.chunk_operations.is_empty() { self.push_slide_prelude(); } for element in elements { self.slide_state.ignore_element_line_break = false; if self.options.render_speaker_notes_only { self.process_element_for_speaker_notes_mode(element)?; } else { self.process_element_for_presentation_mode(element)?; } self.validate_last_operation()?; if !self.slide_state.ignore_element_line_break { self.push_line_break(); } } if !self.chunk_operations.is_empty() || !self.slide_chunks.is_empty() { self.terminate_slide(); } // Always have at least one empty slide if self.slide_builders.is_empty() { self.terminate_slide(); } let mut bindings_modal_builder = KeyBindingsModalBuilder::default(); if self.options.print_modal_background { let background = self.build_modal_background()?; self.index_builder.set_background(background.clone()); bindings_modal_builder.set_background(background); }; let mut slides = Vec::new(); let builders = mem::take(&mut self.slide_builders); self.footer_vars.total_slides = builders.len(); for (index, mut builder) in builders.into_iter().enumerate() { self.footer_vars.current_slide = index + 1; if !self.slides_without_footer.contains(&index) { builder = builder.footer(self.generate_footer()?); } slides.push(builder.build()); } let bindings = bindings_modal_builder.build(&self.theme, &self.bindings_config); let slide_index = self.index_builder.build(&self.theme, self.presentation_state.clone()); let modals = Modals { slide_index, bindings }; let presentation = Presentation::new(slides, modals, self.presentation_state); Ok(presentation) } fn build_with_reader(self, path: &Path, reader: F) -> Result { let _guard = self.sources.enter(path).map_err(BuildError::EnterRoot)?; let contents = reader.read(path).map_err(|e| BuildError::ReadPresentation(path.into(), e))?; let elements = self.markdown_parser.parse(&contents).map_err(|error| { let context = ErrorContextBuilder::new(&contents, &error.kind.to_string()).position(error.sourcepos).build(); BuildError::Parse { path: path.into(), error, context } })?; self.build_from_parsed(elements) } fn build_modal_background(&self) -> Result { let color = self.theme.modals.style.colors.background.as_ref().and_then(Color::as_rgb); // If we don't have an rgb color (or we don't have a color at all), we default to a dark // background. let rgba = match color { Some((r, g, b)) => [r, g, b, 255], None => [0, 0, 0, 255], }; let mut image = DynamicImage::new_rgba8(1, 1); image.as_mut_rgba8().unwrap().get_pixel_mut(0, 0).0 = rgba; let image = self.image_registry.register(ImageSpec::Generated(image))?; Ok(image) } fn validate_last_operation(&mut self) -> BuildResult { if !self.slide_state.needs_enter_column { return Ok(()); } let Some(last) = self.chunk_operations.last() else { return Ok(()); }; if matches!(last, RenderOperation::InitColumnLayout { .. }) { return Ok(()); } self.slide_state.needs_enter_column = false; let last_valid = matches!(last, RenderOperation::EnterColumn { .. } | RenderOperation::ExitLayout); if last_valid { Ok(()) } else { let position = self.slide_state.last_layout_comment.as_ref().expect("no last position"); let context = fs::read_to_string(&position.file) .ok() .map(|s| { ErrorContextBuilder::new(&s, "layout was created here").position(position.source_position).build() }) .unwrap_or_default(); Err(BuildError::NotInsideColumn(context)) } } fn set_colors(&mut self, colors: Colors) { self.chunk_operations.push(RenderOperation::SetColors(colors)); } fn push_slide_prelude(&mut self) { let style = self.theme.default_style.style; self.set_colors(style.colors); let footer_height = self.theme.footer.height(); self.chunk_operations.extend([ RenderOperation::ClearScreen, RenderOperation::ApplyMargin(MarginProperties { horizontal: self.theme.default_style.margin, top: 0, bottom: footer_height, }), ]); self.push_line_break(); } fn process_element_for_presentation_mode(&mut self, element: MarkdownElement) -> BuildResult { let should_clear_last = !matches!(element, MarkdownElement::List(_) | MarkdownElement::Comment { .. }); match element { // This one is processed before everything else as it affects how the rest of the // elements is rendered. MarkdownElement::FrontMatter(_) => self.slide_state.ignore_element_line_break = true, MarkdownElement::SetexHeading { text } => self.push_slide_title(text)?, MarkdownElement::Heading { level, text } => self.push_heading(level, text)?, MarkdownElement::Paragraph(elements) => self.push_paragraph(elements)?, MarkdownElement::List(elements) => self.push_list(elements)?, MarkdownElement::Snippet { info, code, source_position } => self.push_code(info, code, source_position)?, MarkdownElement::Table(table) => self.push_table(table)?, MarkdownElement::ThematicBreak => self.process_thematic_break(), MarkdownElement::Comment { comment, source_position } => self.process_comment(comment, source_position)?, MarkdownElement::BlockQuote(lines) => self.push_block_quote(lines)?, MarkdownElement::Image { path, title, source_position } => { self.push_image_from_path(path, title, source_position)? } MarkdownElement::Alert { alert_type, title, lines } => self.push_alert(alert_type, title, lines)?, MarkdownElement::Footnote(line) => { let line = line.resolve(&self.theme.palette)?; self.push_text(line, ElementType::Paragraph); } }; if should_clear_last { self.slide_state.last_element = LastElement::Other; } Ok(()) } fn process_element_for_speaker_notes_mode(&mut self, element: MarkdownElement) -> BuildResult { match element { MarkdownElement::Comment { comment, source_position } => self.process_comment(comment, source_position)?, MarkdownElement::SetexHeading { text } => self.push_slide_title(text)?, MarkdownElement::ThematicBreak => { if self.options.end_slide_shorthand { self.terminate_slide(); self.slide_state.ignore_element_line_break = true; } } _ => {} } // Allows us to start the next speaker slide when a title is pushed and implicit_slide_ends is enabled. self.slide_state.last_element = LastElement::Other; self.slide_state.ignore_element_line_break = true; Ok(()) } fn set_code_theme(&mut self) -> BuildResult { let theme = &self.theme.code.theme_name; let highlighter = self.themes.highlight.load_by_name(theme).ok_or_else(|| BuildError::InvalidCodeTheme(theme.clone()))?; self.highlighter = highlighter; Ok(()) } fn invalid_presentation(&self, source_position: SourcePosition, error: E) -> BuildError where E: Into, { let error = error.into(); let source_position = self.sources.resolve_source_position(source_position); let context = fs::read_to_string(&source_position.file) .ok() .map(|s| ErrorContextBuilder::new(&s, &error.to_string()).position(source_position.source_position).build()) .unwrap_or_default(); let FileSourcePosition { source_position, file } = source_position; BuildError::InvalidPresentation { source_position, path: file, context } } fn resource_base_path(&self) -> ResourceBasePath { ResourceBasePath::Custom(self.sources.current_base_path()) } fn validate_column_layout(&self, columns: &[u8], source_position: SourcePosition) -> BuildResult { if columns.is_empty() { Err(self .invalid_presentation(source_position, InvalidPresentation::InvalidLayout("need at least one column"))) } else if columns.iter().any(|column| column == &0) { Err(self.invalid_presentation( source_position, InvalidPresentation::InvalidLayout("can't have zero sized columns"), )) } else { Ok(()) } } fn push_pause(&mut self) { if self.options.pause_create_new_slide { let operations = self.chunk_operations.clone(); self.terminate_slide(); self.chunk_operations = operations; return; } self.slide_state.last_chunk_ended_in_list = matches!(self.slide_state.last_element, LastElement::List { .. }); let chunk_operations = mem::take(&mut self.chunk_operations); let mutators = mem::take(&mut self.chunk_mutators); self.slide_chunks.push(SlideChunk::new(chunk_operations, mutators)); } fn push_paragraph(&mut self, lines: Vec>) -> BuildResult { for line in lines { let line = line.resolve(&self.theme.palette)?; self.push_text(line, ElementType::Paragraph); self.push_line_breaks(self.slide_font_size() as usize); } Ok(()) } fn process_thematic_break(&mut self) { if self.options.end_slide_shorthand { self.terminate_slide(); self.slide_state.ignore_element_line_break = true; } else { self.chunk_operations.extend([ RenderSeparator::new(Line::default(), Default::default(), self.slide_font_size()).into(), RenderOperation::RenderLineBreak, ]); } } fn push_text(&mut self, line: Line, element_type: ElementType) { let alignment = self.slide_state.alignment.unwrap_or_else(|| self.theme.alignment(&element_type)); self.push_aligned_text(line, alignment); } fn push_aligned_text(&mut self, mut block: Line, alignment: Alignment) { let default_font_size = self.slide_font_size(); for chunk in &mut block.0 { if chunk.style.is_code() { chunk.style.colors = self.theme.inline_code.style.colors; } if default_font_size > 1 { chunk.style = chunk.style.size(default_font_size); } } if !block.0.is_empty() { self.chunk_operations.push(RenderOperation::RenderText { line: WeightedLine::from(block), alignment }); } } fn push_line_break(&mut self) { self.push_line_breaks(1) } fn push_line_breaks(&mut self, count: usize) { self.chunk_operations.extend(iter::repeat_n(RenderOperation::RenderLineBreak, count)); } fn terminate_slide(&mut self) { let operations = mem::take(&mut self.chunk_operations); let mutators = mem::take(&mut self.chunk_mutators); // Don't allow a last empty pause in slide since it adds nothing if self.slide_chunks.is_empty() || !Self::is_chunk_empty(&operations) { self.slide_chunks.push(SlideChunk::new(operations, mutators)); } let chunks = mem::take(&mut self.slide_chunks); if !self.slide_state.skip_slide { let builder = SlideBuilder::default().chunks(chunks); self.index_builder .add_title(self.slide_state.title.take().unwrap_or_else(|| Text::from("").into())); if self.slide_state.ignore_footer { self.slides_without_footer.insert(self.slide_builders.len()); } self.slide_builders.push(builder); } self.push_slide_prelude(); self.slide_state = Default::default(); } fn is_chunk_empty(operations: &[RenderOperation]) -> bool { if operations.is_empty() { return true; } for operation in operations { if !matches!(operation, RenderOperation::RenderLineBreak) { return false; } } true } fn generate_footer(&self) -> Result, BuildError> { let generator = FooterGenerator::new(self.theme.footer.clone(), &self.footer_vars, &self.theme.palette)?; Ok(vec![ // Exit any layout we're in so this gets rendered on a default screen size. RenderOperation::ExitLayout, // Pop the slide margin so we're at the terminal rect. RenderOperation::PopMargin, RenderOperation::RenderDynamic(Rc::new(generator)), ]) } fn slide_font_size(&self) -> u8 { let font_size = self.slide_state.font_size.unwrap_or(1); if self.options.theme_options.font_size_supported { font_size.clamp(1, 7) } else { 1 } } } trait PresentationReader { fn read(&self, path: &Path) -> io::Result; } struct FilesystemPresentationReader; impl PresentationReader for FilesystemPresentationReader { fn read(&self, path: &Path) -> io::Result { fs::read_to_string(path) } } #[derive(Debug, Default)] struct SlideState { ignore_element_line_break: bool, ignore_footer: bool, needs_enter_column: bool, last_chunk_ended_in_list: bool, last_element: LastElement, incremental_lists: Option, list_item_newlines: Option, layout: LayoutState, title: Option, font_size: Option, alignment: Option, skip_slide: bool, last_layout_comment: Option, } #[derive(Debug, Default)] enum LayoutState { #[default] Default, InLayout { columns_count: usize, }, InColumn { column: usize, columns_count: usize, }, } #[derive(Debug, Default)] enum LastElement { #[default] None, List { last_index: usize, }, Other, } #[cfg(test)] pub(crate) mod utils { use super::*; use crate::{ render::{engine::RenderEngine, properties::WindowSize}, terminal::virt::VirtualTerminal, }; use std::{path::PathBuf, thread::sleep, time::Duration}; struct MemoryPresentationReader { contents: String, } impl PresentationReader for MemoryPresentationReader { fn read(&self, _path: &Path) -> io::Result { Ok(self.contents.clone()) } } pub(crate) enum Input { Markdown(String), Parsed(Vec), } impl From<&'_ str> for Input { fn from(value: &'_ str) -> Self { Self::Markdown(value.to_string()) } } impl From for Input { fn from(value: String) -> Self { Self::Markdown(value) } } impl From> for Input { fn from(value: Vec) -> Self { Self::Parsed(value) } } pub(crate) struct Test { input: Input, options: PresentationBuilderOptions, resources_path: PathBuf, theme: raw::PresentationTheme, } impl Test { pub(crate) fn new>(input: T) -> Self { let options = PresentationBuilderOptions { enable_snippet_execution: true, enable_snippet_execution_replace: true, theme_options: ThemeOptions { font_size_supported: true }, ..Default::default() }; Self { input: input.into(), options, resources_path: std::env::temp_dir(), theme: Default::default() } } pub(crate) fn options(mut self, options: PresentationBuilderOptions) -> Self { self.options = options; self } pub(crate) fn resources_path>(mut self, path: P) -> Self { self.resources_path = path.into(); self } pub(crate) fn theme(mut self, theme: raw::PresentationTheme) -> Self { self.theme = theme; self } pub(crate) fn disable_exec_replace(mut self) -> Self { self.options.enable_snippet_execution_replace = false; self } pub(crate) fn disable_exec(mut self) -> Self { self.options.enable_snippet_execution = false; self } pub(crate) fn with_builder(&self, callback: F) -> T where F: for<'a, 'b> Fn(PresentationBuilder<'a, 'b>) -> T, { let theme = &self.theme; let resources = Resources::new(&self.resources_path, &self.resources_path, Default::default()); let mut third_party = ThirdPartyRender::default(); let code_executor = Arc::new(SnippetExecutor::default()); let themes = Themes::default(); let bindings = KeyBindingsConfig::default(); let arena = Default::default(); let parser = MarkdownParser::new(&arena); let builder = PresentationBuilder::new( theme, resources, &mut third_party, code_executor, &themes, Default::default(), bindings, &parser, self.options.clone(), ) .expect("failed to create builder"); callback(builder) } pub(crate) fn render(self) -> PresentationRender { let presentation = self.build(); PresentationRender::new(presentation) } pub(crate) fn build(self) -> Presentation { self.try_build().expect("build failed") } pub(crate) fn expect_invalid(self) -> BuildError { self.try_build().expect_err("build succeeded") } pub(crate) fn try_build(self) -> Result { self.with_builder(|builder| match &self.input { Input::Markdown(input) => { let reader = MemoryPresentationReader { contents: input.clone() }; let path = self.resources_path.join("presentation.md"); builder.build_with_reader(&path, reader) } Input::Parsed(elements) => builder.build_from_parsed(elements.clone()), }) } } pub(crate) struct PresentationRender { presentation: Presentation, columns: Option, rows: Option, run_async_renders: bool, background_maps: Vec<(Color, char)>, advances: Option, } impl PresentationRender { fn new(presentation: Presentation) -> Self { Self { presentation, columns: None, rows: None, run_async_renders: true, background_maps: Default::default(), advances: None, } } pub(crate) fn rows(mut self, rows: u16) -> Self { self.rows = Some(rows); self } pub(crate) fn columns(mut self, columns: u16) -> Self { self.columns = Some(columns); self } pub(crate) fn advances(mut self, number: usize) -> Self { self.advances = Some(number); self } pub(crate) fn run_async_renders(mut self, value: bool) -> Self { self.run_async_renders = value; self } pub(crate) fn map_background(mut self, color: Color, c: char) -> Self { self.background_maps.push((color, c)); self } pub(crate) fn into_lines(self) -> Vec { self.into_parts().0 } pub(crate) fn into_parts(self) -> (Vec, Vec) { let Self { mut presentation, columns, rows, run_async_renders, background_maps, advances } = self; let columns = columns.expect("no columns"); let rows = rows.expect("no rows"); let dimensions = WindowSize { rows, columns, width: 0, height: 0 }; let only_visible = advances.is_some(); if let Some(advances) = advances { for _ in 0..advances { presentation.jump_next(); } } let slide = presentation.current_slide_mut(); if run_async_renders { for operation in slide.iter_operations_mut() { if let RenderOperation::RenderAsync(operation) = operation { let mut pollable = operation.pollable(); while !pollable.poll().is_completed() { sleep(Duration::from_millis(1)); } } } } let mut term = VirtualTerminal::new(dimensions, Default::default()); let engine = RenderEngine::new(&mut term, dimensions, Default::default()); if only_visible { engine.render(slide.iter_visible_operations()).expect("failed to render"); } else { engine.render(slide.iter_operations()).expect("failed to render"); } let mut lines = Vec::new(); let mut styles = Vec::new(); for row in term.into_contents().rows { let mut line = String::new(); let mut style = String::new(); for character in &row { let style_char = background_maps .iter() .filter_map(|(b, c)| (character.style.colors.background == Some(*b)).then_some(c)) .next() .unwrap_or(&' '); line.push(character.character); style.push(*style_char); } lines.push(line); styles.push(style); } (lines, styles) } } } presenterm-0.15.1/src/presentation/builder/quote.rs000064400000000000000000000126741046102023000205410ustar 00000000000000use crate::{ markdown::{ elements::{Line, Text}, text_style::{Colors, TextStyle}, }, presentation::builder::{BuildResult, PresentationBuilder}, render::operation::{BlockLine, RenderOperation}, theme::{Alignment, raw::RawColor}, }; use comrak::nodes::AlertType; use unicode_width::UnicodeWidthStr; impl PresentationBuilder<'_, '_> { pub(crate) fn push_block_quote(&mut self, lines: Vec>) -> BuildResult { let prefix = self.theme.block_quote.prefix.clone(); let prefix_style = self.theme.block_quote.prefix_style; self.push_quoted_text( lines, prefix, self.theme.block_quote.base_style.colors, prefix_style, self.theme.block_quote.alignment, ) } pub(crate) fn push_alert( &mut self, alert_type: AlertType, title: Option, mut lines: Vec>, ) -> BuildResult { let style = match alert_type { AlertType::Note => &self.theme.alert.styles.note, AlertType::Tip => &self.theme.alert.styles.tip, AlertType::Important => &self.theme.alert.styles.important, AlertType::Warning => &self.theme.alert.styles.warning, AlertType::Caution => &self.theme.alert.styles.caution, }; let title = format!("{} {}", style.icon, title.as_deref().unwrap_or(style.title.as_ref())); lines.insert(0, Line::from(Text::from(""))); lines.insert(0, Line::from(Text::new(title, style.style.into_raw()))); let prefix = self.theme.alert.prefix.clone(); self.push_quoted_text( lines, prefix, self.theme.alert.base_style.colors, style.style, self.theme.alert.alignment, ) } fn push_quoted_text( &mut self, lines: Vec>, prefix: String, base_colors: Colors, prefix_style: TextStyle, alignment: Alignment, ) -> BuildResult { let block_length = lines.iter().map(|line| line.width() + prefix.width()).max().unwrap_or(0) as u16; let font_size = self.slide_font_size(); let prefix = Text::new(prefix, prefix_style.size(font_size)); for line in lines { let mut line = line.resolve(&self.theme.palette)?; // Apply our colors to each chunk in this line. for text in &mut line.0 { if text.style.colors.background.is_none() && text.style.colors.foreground.is_none() { text.style.colors = base_colors; if text.style.is_code() { text.style.colors = self.theme.inline_code.style.colors; } } text.style = text.style.size(font_size); } self.chunk_operations.push(RenderOperation::RenderBlockLine(BlockLine { prefix: prefix.clone().into(), right_padding_length: 0, repeat_prefix_on_wrap: true, text: line.into(), block_length, alignment, block_color: base_colors.background, })); self.push_line_break(); } self.set_colors(self.theme.default_style.style.colors); Ok(()) } } #[cfg(test)] mod tests { use crate::{markdown::text_style::Color, presentation::builder::utils::Test, theme::raw}; use rstest::rstest; #[rstest] #[case::left_no_margin(raw::Alignment::Left{ margin: raw::Margin::Fixed(0) },"▍ hi ", "XXXXXXX", )] #[case::left_one_margin(raw::Alignment::Left{ margin: raw::Margin::Fixed(1) }, " ▍ hi ", " XXXXX ")] #[case::center(raw::Alignment::Center{ minimum_margin: raw::Margin::Fixed(0), minimum_size: 0 }, " ▍ hi ", " XXXX ")] #[test] fn quote(#[case] alignment: raw::Alignment, #[case] line: &str, #[case] style: &str) { let input = " > hi > hi "; let color = Color::new(1, 1, 1); let theme = raw::PresentationTheme { block_quote: raw::BlockQuoteStyle { colors: raw::BlockQuoteColors { base: raw::RawColors { foreground: None, background: Some(raw::RawColor::Color(color)) }, prefix: None, }, alignment: Some(alignment), ..Default::default() }, ..Default::default() }; let (lines, styles) = Test::new(input).theme(theme).render().map_background(color, 'X').rows(4).columns(7).into_parts(); let expected_lines = &[" ", line, line, " "]; let expected_styles = &[" ", style, style, " "]; assert_eq!(lines, expected_lines); assert_eq!(styles, expected_styles); } #[test] fn alert() { let input = " > [!note] > hi "; let theme = raw::PresentationTheme { alert: raw::AlertStyle { styles: raw::AlertTypeStyles { note: raw::AlertTypeStyle { icon: Some("!".to_string()), ..Default::default() }, ..Default::default() }, ..Default::default() }, ..Default::default() }; let lines = Test::new(input).theme(theme).render().rows(5).columns(9).into_lines(); let expected = &[" ", "▍ ! Note ", "▍ ", "▍ hi ", " "]; assert_eq!(lines, expected); } } presenterm-0.15.1/src/presentation/builder/snippet.rs000064400000000000000000000706121046102023000210620ustar 00000000000000use super::{BuildError, BuildResult}; use crate::{ code::{ execute::LanguageSnippetExecutor, snippet::{ ExternalFile, Highlight, HighlightContext, HighlightGroup, HighlightMutator, HighlightedLine, Snippet, SnippetExec, SnippetExecutorSpec, SnippetLanguage, SnippetLine, SnippetParser, SnippetRepr, SnippetSplitter, }, }, markdown::elements::SourcePosition, presentation::builder::{PresentationBuilder, error::InvalidPresentation}, render::{ operation::{AsRenderOperations, RenderAsyncStartPolicy, RenderOperation}, properties::WindowSize, }, theme::{Alignment, CodeBlockStyle}, third_party::ThirdPartyRenderRequest, ui::execution::{ RunAcquireTerminalSnippet, RunImageSnippet, SnippetExecutionDisabledOperation, SnippetOutputOperation, disabled::ExecutionType, output::{ExecIndicator, ExecIndicatorStyle, RunSnippetTrigger, SnippetHandle}, validator::ValidateSnippetOperation, }, }; use itertools::Itertools; use std::{cell::RefCell, rc::Rc}; impl PresentationBuilder<'_, '_> { pub(crate) fn push_code(&mut self, info: String, code: String, source_position: SourcePosition) -> BuildResult { let mut snippet = SnippetParser::parse(info, code) .map_err(|e| self.invalid_presentation(source_position, InvalidPresentation::Snippet(e.to_string())))?; if matches!(snippet.language, SnippetLanguage::File) { snippet = self.load_external_snippet(snippet, source_position)?; } if self.options.auto_render_languages.contains(&snippet.language) { snippet.attributes.representation = SnippetRepr::Render; } // Ids can only be used in `+exec` snippets. if snippet.attributes.id.is_some() && (!matches!(snippet.attributes.execution, SnippetExec::Exec(_)) || !matches!(snippet.attributes.representation, SnippetRepr::Snippet)) { return Err(self.invalid_presentation(source_position, InvalidPresentation::SnippetIdNonExec)); } self.push_differ(snippet.contents.clone()); // Redraw slide if attributes change self.push_differ(format!("{:?}", snippet.attributes)); let execution_allowed = self.is_execution_allowed(&snippet); match snippet.attributes.representation { SnippetRepr::Render => return self.push_rendered_code(snippet, source_position), SnippetRepr::Image => { if execution_allowed { return self.push_code_as_image(snippet); } } SnippetRepr::ExecReplace => { if execution_allowed { return self.push_replace_code_execution(snippet.clone()); } } SnippetRepr::Snippet => (), }; let block_length = self.push_code_lines(&snippet); match snippet.attributes.execution.clone() { SnippetExec::None => Ok(()), SnippetExec::Exec(_) | SnippetExec::AcquireTerminal(_) if !execution_allowed => { let exec_type = match snippet.attributes.representation { SnippetRepr::Image => ExecutionType::Image, SnippetRepr::ExecReplace => ExecutionType::ExecReplace, SnippetRepr::Render | SnippetRepr::Snippet => ExecutionType::Execute, }; self.push_execution_disabled_operation(exec_type); Ok(()) } SnippetExec::Exec(spec) => { let executor = self.snippet_executor.language_executor(&snippet.language, &spec)?; let alignment = self.code_style(&snippet).alignment; let handle = SnippetHandle::new(snippet.clone(), executor, RenderAsyncStartPolicy::OnDemand); self.chunk_operations .push(RenderOperation::RenderAsync(Rc::new(RunSnippetTrigger::new(handle.clone())))); self.push_indicator(handle.clone(), block_length, alignment); match snippet.attributes.id.clone() { Some(id) => { if self.executable_snippets.insert(id.clone(), handle).is_some() { return Err(self .invalid_presentation(source_position, InvalidPresentation::SnippetAlreadyExists(id))); } Ok(()) } None => { self.push_line_break(); self.push_code_execution(block_length, handle, alignment) } } } SnippetExec::AcquireTerminal(spec) => self.push_acquire_terminal_execution(snippet, block_length, &spec), SnippetExec::Validate(spec) => { let executor = self.snippet_executor.language_executor(&snippet.language, &spec)?; self.push_validator(&snippet, &executor); Ok(()) } } } pub(crate) fn push_detached_code_execution(&mut self, handle: SnippetHandle) -> BuildResult { let alignment = self.code_style(&handle.snippet()).alignment; self.push_code_execution(0, handle, alignment) } fn is_execution_allowed(&self, snippet: &Snippet) -> bool { match snippet.attributes.representation { SnippetRepr::Snippet => self.options.enable_snippet_execution, SnippetRepr::Image | SnippetRepr::ExecReplace => self.options.enable_snippet_execution_replace, SnippetRepr::Render => true, } } fn push_code_lines(&mut self, snippet: &Snippet) -> u16 { let lines = SnippetSplitter::new(&self.theme.code, self.snippet_executor.hidden_line_prefix(&snippet.language)) .split(snippet); let block_length = lines.iter().map(|line| line.width()).max().unwrap_or(0) * self.slide_font_size() as usize; let block_length = block_length as u16; let (lines, context) = self.highlight_lines(snippet, lines, block_length); for line in lines { self.chunk_operations.push(RenderOperation::RenderDynamic(Rc::new(line))); } self.chunk_operations.push(RenderOperation::SetColors(self.theme.default_style.style.colors)); if self.options.allow_mutations && context.borrow().groups.len() > 1 { self.chunk_mutators.push(Box::new(HighlightMutator::new(context))); } block_length } fn push_replace_code_execution(&mut self, snippet: Snippet) -> BuildResult { // TODO: representation and execution should probably be merged let SnippetExec::Exec(spec) = snippet.attributes.execution.clone() else { panic!("not an exec snippet"); }; let alignment = match self.code_style(&snippet).alignment { // If we're replacing the snippet output, we have center alignment and no background, use // center alignment but without any margins and minimum sizes so we truly center the output. Alignment::Center { .. } if snippet.attributes.no_background => { Alignment::Center { minimum_margin: Default::default(), minimum_size: 0 } } other => other, }; let executor = self.snippet_executor.language_executor(&snippet.language, &spec)?; let handle = SnippetHandle::new(snippet, executor, RenderAsyncStartPolicy::Automatic); self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(RunSnippetTrigger::new(handle.clone())))); self.push_code_execution(0, handle, alignment) } fn load_external_snippet( &mut self, mut code: Snippet, source_position: SourcePosition, ) -> Result { let file: ExternalFile = serde_yaml::from_str(&code.contents) .map_err(|e| self.invalid_presentation(source_position, InvalidPresentation::Snippet(e.to_string())))?; let path = file.path; let base_path = self.resource_base_path(); let contents = self.resources.external_text_file(&path, &base_path).map_err(|e| { self.invalid_presentation( source_position, InvalidPresentation::Snippet(format!("failed to load snippet {path:?}: {e}")), ) })?; code.language = file.language; code.contents = Self::filter_lines(contents, file.start_line, file.end_line); Ok(code) } fn filter_lines(code: String, start: Option, end: Option) -> String { let start = start.map(|s| s.saturating_sub(1)); match (start, end) { (None, None) => code, (None, Some(end)) => code.lines().take(end).join("\n"), (Some(start), None) => code.lines().skip(start).join("\n"), (Some(start), Some(end)) => code.lines().skip(start).take(end.saturating_sub(start)).join("\n"), } } fn push_rendered_code(&mut self, code: Snippet, source_position: SourcePosition) -> BuildResult { let Snippet { contents, language, attributes } = code; let request = match language { SnippetLanguage::Typst => ThirdPartyRenderRequest::Typst(contents, self.theme.typst.clone()), SnippetLanguage::Latex => ThirdPartyRenderRequest::Latex(contents, self.theme.typst.clone()), SnippetLanguage::Mermaid => ThirdPartyRenderRequest::Mermaid(contents, self.theme.mermaid.clone()), SnippetLanguage::D2 => ThirdPartyRenderRequest::D2(contents, self.theme.d2.clone()), _ => { return Err(self.invalid_presentation( source_position, InvalidPresentation::Snippet(format!("language {language:?} doesn't support rendering")), )); } }; let operation = self.third_party.render(request, &self.theme, attributes.width)?; self.chunk_operations.push(operation); Ok(()) } fn highlight_lines( &self, code: &Snippet, lines: Vec, block_length: u16, ) -> (Vec, Rc>) { let mut code_highlighter = self.highlighter.language_highlighter(&code.language); let style = self.code_style(code); let block_length = self.theme.code.alignment.adjust_size(block_length); let font_size = self.slide_font_size(); let dim_style = { let mut highlighter = self.highlighter.language_highlighter(&SnippetLanguage::Rust); highlighter.style_line("//", &style).0.first().expect("no styles").style.size(font_size) }; let groups = match self.options.allow_mutations { true => code.attributes.highlight_groups.clone(), false => vec![HighlightGroup::new(vec![Highlight::All])], }; let context = Rc::new(RefCell::new(HighlightContext { groups, current: 0, block_length, alignment: style.alignment })); let mut output = Vec::new(); for line in lines.into_iter() { let prefix = line.dim_prefix(&dim_style); let highlighted = line.highlight(&mut code_highlighter, &style, font_size); let not_highlighted = line.dim(&dim_style); let line_number = line.line_number; let context = context.clone(); output.push(HighlightedLine { prefix, right_padding_length: line.right_padding_length * font_size as u16, highlighted, not_highlighted, line_number, context, block_color: dim_style.colors.background, }); } (output, context) } fn code_style(&self, snippet: &Snippet) -> CodeBlockStyle { let mut style = self.theme.code.clone(); if snippet.attributes.no_background { style.background = false; } style } fn push_execution_disabled_operation(&mut self, exec_type: ExecutionType) { let policy = match exec_type { ExecutionType::ExecReplace | ExecutionType::Image => RenderAsyncStartPolicy::Automatic, ExecutionType::Execute => RenderAsyncStartPolicy::OnDemand, }; let operation = SnippetExecutionDisabledOperation::new( self.theme.execution_output.status.failure_style, self.theme.code.alignment, policy, exec_type, ); self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(operation))); } fn push_code_as_image(&mut self, snippet: Snippet) -> BuildResult { let executor = self.snippet_executor.language_executor(&snippet.language, &Default::default())?; self.push_validator(&snippet, &executor); let operation = RunImageSnippet::new(snippet, executor, self.image_registry.clone(), self.theme.execution_output.status); let operation = RenderOperation::RenderAsync(Rc::new(operation)); self.chunk_operations.push(operation); Ok(()) } fn push_acquire_terminal_execution( &mut self, snippet: Snippet, block_length: u16, spec: &SnippetExecutorSpec, ) -> BuildResult { let executor = self.snippet_executor.language_executor(&snippet.language, spec)?; let block_length = self.theme.code.alignment.adjust_size(block_length); let operation = RunAcquireTerminalSnippet::new( snippet, executor, self.theme.execution_output.status, block_length, self.slide_font_size(), ); let operation = RenderOperation::RenderAsync(Rc::new(operation)); self.chunk_operations.push(operation); Ok(()) } fn push_indicator(&mut self, handle: SnippetHandle, block_length: u16, alignment: Alignment) { let style = ExecIndicatorStyle { theme: self.theme.execution_output.status, block_length, font_size: self.slide_font_size(), alignment, }; let indicator = Rc::new(ExecIndicator::new(handle, style)); self.chunk_operations.push(RenderOperation::RenderDynamic(indicator)); } fn push_code_execution(&mut self, block_length: u16, handle: SnippetHandle, alignment: Alignment) -> BuildResult { let executor = handle.executor(); let snippet = handle.snippet(); self.push_validator(&snippet, &executor); let default_colors = self.theme.default_style.style.colors; let mut execution_output_style = self.theme.execution_output.clone(); if snippet.attributes.no_background { execution_output_style.style.colors.background = None; execution_output_style.padding = Default::default(); } let operation = SnippetOutputOperation::new( handle, default_colors, execution_output_style, block_length, alignment, self.slide_font_size(), ); let operation = RenderOperation::RenderDynamic(Rc::new(operation)); self.chunk_operations.push(operation); Ok(()) } fn push_differ(&mut self, text: String) { self.chunk_operations.push(RenderOperation::RenderDynamic(Rc::new(Differ(text)))); } fn push_validator(&mut self, snippet: &Snippet, executor: &LanguageSnippetExecutor) { if !self.options.validate_snippets { return; } let operation = ValidateSnippetOperation::new(snippet.clone(), executor.clone()); self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(operation))); } } #[derive(Debug)] struct Differ(String); impl AsRenderOperations for Differ { fn as_render_operations(&self, _: &WindowSize) -> Vec { Vec::new() } fn diffable_content(&self) -> Option<&str> { Some(&self.0) } } #[cfg(all(test, target_os = "linux"))] mod tests { use super::*; use crate::{markdown::text_style::Color, presentation::builder::utils::Test, theme::raw}; use rstest::rstest; use std::fs; #[rstest] #[case::no_filters(None, None, &["a", "b", "c", "d", "e"])] #[case::start_from_first(Some(1), None, &["a", "b", "c", "d", "e"])] #[case::start_from_second(Some(2), None, &["b", "c", "d", "e"])] #[case::start_from_end(Some(5), None, &["e"])] #[case::start_from_past_end(Some(6), None, &[])] #[case::end_last(None, Some(5), &["a", "b", "c", "d", "e"])] #[case::end_one_before_last(None, Some(4), &["a", "b", "c", "d"])] #[case::end_at_first(None, Some(1), &["a"])] #[case::end_at_zero(None, Some(0), &[])] #[case::start_and_end(Some(2), Some(3), &["b", "c"])] #[case::crossed(Some(2), Some(1), &[])] fn filter_lines(#[case] start: Option, #[case] end: Option, #[case] expected: &[&str]) { let code = ["a", "b", "c", "d", "e"].join("\n"); let output = PresentationBuilder::filter_lines(code, start, end); let expected = expected.join("\n"); assert_eq!(output, expected); } #[test] fn plain() { let input = " ```bash echo hi ```"; let lines = Test::new(input).render().rows(3).columns(7).into_lines(); let expected = &[" ", "echo hi", " "]; assert_eq!(lines, expected); } #[test] fn external_snippet() { let temp = tempfile::NamedTempFile::new().expect("failed to create tempfile"); let path = temp.path(); fs::write(path, "echo hi").unwrap(); let path = path.to_string_lossy(); let input = format!( " ```file path: {path} language: bash ``` " ); let lines = Test::new(input).render().rows(3).columns(7).into_lines(); let expected = &[" ", "echo hi", " "]; assert_eq!(lines, expected); } #[test] fn line_numbers() { let input = " ```bash +line_numbers hi bye ```"; let lines = Test::new(input).render().rows(4).columns(5).into_lines(); let expected = &[" ", "1 hi ", "2 bye", " "]; assert_eq!(lines, expected); } #[test] fn surroundings() { let input = " --- ```bash echo hi ``` ---"; let lines = Test::new(input).render().rows(7).columns(7).into_lines(); let expected = &[" ", "———————", " ", "echo hi", " ", "———————", " "]; assert_eq!(lines, expected); } #[test] fn padding() { let input = " ```bash echo hi ```"; let theme = raw::PresentationTheme { code: raw::CodeBlockStyle { padding: raw::PaddingRect { horizontal: Some(2), vertical: Some(1) }, ..Default::default() }, ..Default::default() }; let lines = Test::new(input).theme(theme).render().rows(5).columns(13).into_lines(); let expected = &[" ", " ", " echo hi ", " ", " "]; assert_eq!(lines, expected); } #[test] fn exec_no_run() { let input = " ```bash +exec echo hi ```"; let lines = Test::new(input).render().rows(4).columns(19).run_async_renders(false).into_lines(); let expected = &[" ", "echo hi ", " ", "—— [not started] ——"]; assert_eq!(lines, expected); } #[test] fn validate() { let input = " ```bash +validate echo hi ```"; let lines = Test::new(input).render().rows(4).columns(19).run_async_renders(false).into_lines(); let expected = &[" ", "echo hi ", " ", " "]; assert_eq!(lines, expected); } #[test] fn exec_disabled() { let input = " ```bash +exec echo hi ```"; let lines = Test::new(input).disable_exec().render().rows(6).columns(25).into_lines(); let expected = &[ " ", "echo hi ", " ", "snippet +exec is ", "disabled, run with -x to ", "enable ", ]; assert_eq!(lines, expected); } #[test] fn exec_replace_disabled() { let input = " ```bash +exec_replace echo hi ```"; let lines = Test::new(input).disable_exec_replace().render().rows(6).columns(25).into_lines(); let expected = &[ " ", "echo hi ", " ", "snippet +exec_replace is ", "disabled, run with -X to ", "enable ", ]; assert_eq!(lines, expected); } #[test] fn exec() { let input = " ```bash +exec echo hi ```"; let theme = raw::PresentationTheme { execution_output: raw::ExecutionOutputBlockStyle { colors: raw::RawColors { background: Some(raw::RawColor::Color(Color::new(45, 45, 45))), foreground: None, }, padding: raw::PaddingRect { horizontal: Some(1), vertical: Some(1) }, ..Default::default() }, ..Default::default() }; let (lines, styles) = Test::new(input) .theme(theme) .render() .map_background(Color::new(45, 45, 45), 'x') .rows(8) .columns(16) .into_parts(); let expected_lines = &[ " ", "echo hi ", " ", "—— [finished] ——", " ", " ", " hi ", " ", ]; let expected_styles = &[ " ", "xxxxxxxxxxxxxxxx", " ", " ", " ", "xxxxxxxxxxxxxxxx", "xxxxxxxxxxxxxxxx", "xxxxxxxxxxxxxxxx", ]; assert_eq!(lines, expected_lines); assert_eq!(styles, expected_styles); } #[test] fn exec_font_size() { let input = " ```bash +exec echo hi ```"; let lines = Test::new(input).render().rows(8).columns(32).into_lines(); let expected = &[ " ", "e c h o h i ", " ", " ", "— — [ f i n i s h e d ] — — ", " ", " ", "h i ", ]; assert_eq!(lines, expected); } #[test] fn exec_font_size_centered() { let input = " ```bash +exec echo hi ```"; let theme = raw::PresentationTheme { code: raw::CodeBlockStyle { alignment: Some(raw::Alignment::Center { minimum_margin: raw::Margin::Fixed(0), minimum_size: 40 }), ..Default::default() }, execution_output: raw::ExecutionOutputBlockStyle { colors: raw::RawColors { background: Some(raw::RawColor::Color(Color::new(45, 45, 45))), foreground: None, }, padding: raw::PaddingRect { horizontal: Some(1), vertical: Some(1) }, ..Default::default() }, ..Default::default() }; let (lines, styles) = Test::new(input) .theme(theme) .render() .map_background(Color::new(45, 45, 45), 'x') .rows(10) .columns(40) .into_parts(); let expected_lines = &[ " ", "e c h o h i ", " ", " ", "— — — — [ f i n i s h e d ] — — — — ", " ", " ", " ", " ", " h i ", ]; let expected_styles = &[ " ", "x x x x x x x x x x x x x x x x x x x x ", " ", " ", " ", " ", " ", "x x x x x x x x x x x x x x x x x x x x ", " ", "x x x x x x x x x x x x x x x x x x x x ", ]; assert_eq!(lines, expected_lines); assert_eq!(styles, expected_styles); } #[test] fn exec_adjacent_detached_output() { let input = " ```bash +exec +id:foo echo hi ``` "; let lines = Test::new(input).render().rows(4).columns(19).run_async_renders(false).into_lines(); // this should look exactly the same as if we hadn't detached the output let expected = &[" ", "echo hi ", " ", "—— [not started] ——"]; assert_eq!(lines, expected); } #[test] fn exec_detached_output() { let input = " ```bash +exec +id:foo echo hi ``` bar "; let lines = Test::new(input).render().rows(8).columns(16).into_lines(); let expected = &[ " ", "echo hi ", " ", "—— [finished] ——", " ", "bar ", " ", "hi ", ]; assert_eq!(lines, expected); } #[test] fn exec_replace() { let input = " ```bash +exec_replace echo hi ```"; let lines = Test::new(input).render().rows(3).columns(7).into_lines(); let expected = &[" ", "hi ", " "]; assert_eq!(lines, expected); } #[test] fn snippet_exec_replace_centered() { let input = " ```bash +exec_replace echo hi ```"; let theme = raw::PresentationTheme { code: raw::CodeBlockStyle { alignment: Some(raw::Alignment::Center { minimum_margin: raw::Margin::Fixed(1), minimum_size: 1 }), ..Default::default() }, ..Default::default() }; let lines = Test::new(input).theme(theme).render().rows(3).columns(6).into_lines(); let expected = &[" ", " hi ", " "]; assert_eq!(lines, expected); } #[test] fn exec_replace_font_size() { let input = " ```bash +exec_replace echo hi ```"; let lines = Test::new(input).render().rows(3).columns(7).into_lines(); let expected = &[" ", "h i ", " "]; assert_eq!(lines, expected); } #[test] fn exec_replace_long() { let qr = [ "█▀▀▀▀▀█ ▄▀ ▄▀ █▀▀▀▀▀█", "█ ███ █ ▄▀ ▄ █ ███ █", "█ ▀▀▀ █ ▄▄█▀█ █ ▀▀▀ █", "▀▀▀▀▀▀▀ ▀ █▄█ ▀▀▀▀▀▀▀", "█▀▀██ ▀▀█▀ █▀ █ ▀ ▀▄", "▄▄██▀▄▀▀▄ █▀ ▀ ▄█▀█▀ ", "▀ ▀▀ ▀▀▄█▄█▄█▄▄▀ ▄ █", "█▀▀▀▀▀█ ▀▀ ▄█▄█▀ ▄█▀▄", "█ ███ █ ██▀ █ ▄█▄ ▀ ", "█ ▀▀▀ █ █ ▄▀ ▀ ▄██ ", "▀▀▀▀▀▀▀ ▀▀ ▀ ▀ ▀ ▀ ", ] .join("\n"); let input = format!( r#" ```bash +exec_replace echo "{qr}" ``` "# ); let rows = 13; let columns = 21; let lines = Test::new(input).render().rows(rows).columns(columns).into_lines(); let empty = " ".repeat(columns as usize); let expected: Vec<_> = [empty.as_str()].into_iter().chain(qr.lines()).chain([empty.as_str()]).collect(); assert_eq!(lines, expected); } } presenterm-0.15.1/src/presentation/builder/sources.rs000064400000000000000000000046241046102023000210630ustar 00000000000000use crate::{markdown::elements::SourcePosition, presentation::builder::error::FileSourcePosition}; use std::{cell::RefCell, path::PathBuf, rc::Rc}; #[derive(Default)] struct Inner { include_paths: Vec, } #[derive(Default)] pub(crate) struct MarkdownSources { inner: Rc>, } impl MarkdownSources { pub(crate) fn enter>(&self, path: P) -> Result { let path = path.into(); if path.parent().is_none() { return Err(MarkdownSourceError::NoParent); } let mut inner = self.inner.borrow_mut(); if inner.include_paths.contains(&path) { return Err(MarkdownSourceError::IncludeCycle(path)); } inner.include_paths.push(path); Ok(SourceGuard(self.inner.clone())) } pub(crate) fn current_base_path(&self) -> PathBuf { self.inner .borrow() .include_paths .last() // SAFETY: we validate we know the parent before pushing into `include_paths` .map(|path| path.parent().expect("no parent").to_path_buf()) .unwrap_or_else(|| PathBuf::from(".")) } pub(crate) fn resolve_source_position(&self, source_position: SourcePosition) -> FileSourcePosition { let file = self.inner.borrow().include_paths.last().cloned().unwrap_or_else(|| PathBuf::from(".")); FileSourcePosition { source_position, file } } } #[must_use] pub(crate) struct SourceGuard(Rc>); impl Drop for SourceGuard { fn drop(&mut self) { self.0.borrow_mut().include_paths.pop(); } } #[derive(Debug, thiserror::Error)] pub(crate) enum MarkdownSourceError { #[error("cannot detect path's parent")] NoParent, #[error("{0:?} was already imported")] IncludeCycle(PathBuf), } #[cfg(test)] mod tests { use super::*; use std::path::Path; #[test] fn paths() { let sources = MarkdownSources::default(); assert_eq!(sources.current_base_path(), Path::new(".")); { let _guard1 = sources.enter("foo.md"); assert_eq!(sources.current_base_path(), Path::new("")); { let _guard2 = sources.enter("inner/bar.md"); assert_eq!(sources.current_base_path(), Path::new("inner")); } assert_eq!(sources.current_base_path(), Path::new("")); } } } presenterm-0.15.1/src/presentation/builder/table.rs000064400000000000000000000055521046102023000204700ustar 00000000000000use crate::{ markdown::elements::{Line, Table, TableRow, Text}, presentation::builder::{BuildResult, PresentationBuilder, error::BuildError}, theme::ElementType, }; use std::iter; impl PresentationBuilder<'_, '_> { pub(crate) fn push_table(&mut self, table: Table) -> BuildResult { let widths: Vec<_> = (0..table.columns()) .map(|column| table.iter_column(column).map(|text| text.width()).max().unwrap_or(0)) .collect(); let flattened_header = self.prepare_table_row(table.header, &widths)?; self.push_text(flattened_header, ElementType::Table); self.push_line_break(); let mut separator = Line(Vec::new()); for (index, width) in widths.iter().enumerate() { let mut contents = String::new(); let mut margin = 1; if index > 0 { contents.push('┼'); // Append an extra dash to have 1 column margin on both sides if index < widths.len() - 1 { margin += 1; } } contents.extend(iter::repeat_n("─", *width + margin)); separator.0.push(Text::from(contents)); } self.push_text(separator, ElementType::Table); self.push_line_break(); for row in table.rows { let flattened_row = self.prepare_table_row(row, &widths)?; self.push_text(flattened_row, ElementType::Table); self.push_line_break(); } Ok(()) } fn prepare_table_row(&self, row: TableRow, widths: &[usize]) -> Result { let mut flattened_row = Line(Vec::new()); for (column, text) in row.0.into_iter().enumerate() { let text = text.resolve(&self.theme.palette)?; if column > 0 { flattened_row.0.push(Text::from(" │ ")); } let text_length = text.width(); flattened_row.0.extend(text.0.into_iter()); let cell_width = widths[column]; if text_length < cell_width { let padding = " ".repeat(cell_width - text_length); flattened_row.0.push(Text::from(padding)); } } Ok(flattened_row) } } #[cfg(test)] mod tests { use crate::presentation::builder::utils::Test; #[test] fn table() { let input = " | Name | Taste | | ------ | ------ | | Potato | Great | | Carrot | Yuck | "; let lines = Test::new(input).render().rows(6).columns(22).into_lines(); let expected_lines = &[ " ", "Name │ Taste ", "───────┼────── ", "Potato │ Great ", "Carrot │ Yuck ", " ", ]; assert_eq!(lines, expected_lines); } } presenterm-0.15.1/src/presentation/builder/tests.rs000064400000000000000000000047221046102023000205410ustar 00000000000000use super::*; use crate::presentation::builder::utils::Test; #[test] fn prelude_appears_once() { let input = "--- author: bob --- # hello # bye "; let presentation = Test::new(input).build(); for (index, slide) in presentation.iter_slides().enumerate() { let clear_screen_count = slide.iter_visible_operations().filter(|op| matches!(op, RenderOperation::ClearScreen)).count(); let set_colors_count = slide.iter_visible_operations().filter(|op| matches!(op, RenderOperation::SetColors(_))).count(); assert_eq!(clear_screen_count, 1, "{clear_screen_count} clear screens in slide {index}"); assert_eq!(set_colors_count, 1, "{set_colors_count} clear screens in slide {index}"); } } #[test] fn slides_start_with_one_newline() { let input = r#"--- author: bob --- # hello # bye "#; // land in first slide after into let lines = Test::new(input).render().rows(2).columns(5).advances(1).into_lines(); assert_eq!(lines, &[" ", "hello"]); // land in second one let lines = Test::new(input).render().rows(2).columns(5).advances(2).into_lines(); assert_eq!(lines, &[" ", "bye "]); } #[test] fn extra_fields_in_metadata() { let element = MarkdownElement::FrontMatter("nope: 42".into()); Test::new(vec![element]).expect_invalid(); } #[test] fn end_slide_shorthand() { let input = " hola --- hi "; // first slide let options = PresentationBuilderOptions { end_slide_shorthand: true, ..Default::default() }; let lines = Test::new(input).options(options.clone()).render().rows(2).columns(5).into_lines(); assert_eq!(lines, &[" ", "hola "]); // second slide let lines = Test::new(input).options(options).render().rows(2).columns(5).advances(1).into_lines(); assert_eq!(lines, &[" ", "hi "]); } #[test] fn parse_front_matter_strict() { let options = PresentationBuilderOptions { strict_front_matter_parsing: false, ..Default::default() }; let elements = vec![MarkdownElement::FrontMatter("potato: yes".into())]; let result = Test::new(elements).options(options).try_build(); assert!(result.is_ok()); } #[test] fn footnote() { let elements = vec![MarkdownElement::Footnote(Line::from("hi")), MarkdownElement::Footnote(Line::from("bye"))]; let lines = Test::new(elements).render().rows(3).columns(5).into_lines(); let expected = &[" ", "hi ", "bye "]; assert_eq!(lines, expected); } presenterm-0.15.1/src/presentation/diff.rs000064400000000000000000000277321046102023000166670ustar 00000000000000use crate::presentation::{Presentation, RenderOperation, SlideChunk}; use std::{any::Any, cmp::Ordering, fmt::Debug, mem}; /// Allow diffing presentations. pub(crate) struct PresentationDiffer; impl PresentationDiffer { /// Find the first modification between two presentations. pub(crate) fn find_first_modification(original: &Presentation, updated: &Presentation) -> Option { let original_slides = original.iter_slides(); let updated_slides = updated.iter_slides(); for (slide_index, (original, updated)) in original_slides.zip(updated_slides).enumerate() { for (chunk_index, (original, updated)) in original.iter_chunks().zip(updated.iter_chunks()).enumerate() { if original.is_content_different(updated) { return Some(Modification { slide_index, chunk_index }); } } let total_original = original.iter_chunks().count(); let total_updated = updated.iter_chunks().count(); match total_original.cmp(&total_updated) { Ordering::Equal => (), Ordering::Less => return Some(Modification { slide_index, chunk_index: total_original }), Ordering::Greater => { return Some(Modification { slide_index, chunk_index: total_updated.saturating_sub(1) }); } } } let total_original = original.iter_slides().count(); let total_updated = updated.iter_slides().count(); match total_original.cmp(&total_updated) { // If they have the same number of slides there's no difference. Ordering::Equal => None, // If the original had fewer, let's scroll to the first new one. Ordering::Less => Some(Modification { slide_index: total_original, chunk_index: 0 }), // If the original had more, let's scroll to the last one. Ordering::Greater => { Some(Modification { slide_index: total_updated.saturating_sub(1), chunk_index: usize::MAX }) } } } } #[derive(Clone, Debug, PartialEq)] pub(crate) struct Modification { pub(crate) slide_index: usize, pub(crate) chunk_index: usize, } trait ContentDiff { fn is_content_different(&self, other: &Self) -> bool; } impl ContentDiff for SlideChunk { fn is_content_different(&self, other: &Self) -> bool { self.iter_operations().is_content_different(&other.iter_operations()) } } impl ContentDiff for RenderOperation { fn is_content_different(&self, other: &Self) -> bool { use RenderOperation::*; let same_variant = mem::discriminant(self) == mem::discriminant(other); // If variants don't even match, content is different. if !same_variant { return true; } match (self, other) { (SetColors(original), SetColors(updated)) if original != updated => false, (RenderText { line: original, .. }, RenderText { line: updated, .. }) if original != updated => true, (RenderText { alignment: original, .. }, RenderText { alignment: updated, .. }) if original != updated => { false } (RenderImage(original, original_properties), RenderImage(updated, updated_properties)) if original != updated || original_properties != updated_properties => { true } (RenderBlockLine(original), RenderBlockLine(updated)) if original != updated => true, (InitColumnLayout { columns: original }, InitColumnLayout { columns: updated }) if original != updated => { true } (EnterColumn { column: original }, EnterColumn { column: updated }) if original != updated => true, (RenderDynamic(original), RenderDynamic(updated)) if original.type_id() != updated.type_id() => true, (RenderDynamic(original), RenderDynamic(updated)) => { original.diffable_content() != updated.diffable_content() } (RenderAsync(original), RenderAsync(updated)) if original.type_id() != updated.type_id() => true, (RenderAsync(original), RenderAsync(updated)) => original.diffable_content() != updated.diffable_content(), _ => false, } } } impl<'a, T, U> ContentDiff for T where T: IntoIterator + Clone, U: ContentDiff + 'a, { fn is_content_different(&self, other: &Self) -> bool { let lhs = self.clone().into_iter(); let rhs = other.clone().into_iter(); for (lhs, rhs) in lhs.zip(rhs) { if lhs.is_content_different(rhs) { return true; } } // If either have more than the other, they've changed self.clone().into_iter().count() != other.clone().into_iter().count() } } #[cfg(test)] mod test { use super::*; use crate::{ markdown::{ text::WeightedLine, text_style::{Color, Colors}, }, presentation::{Slide, SlideBuilder}, render::{ operation::{AsRenderOperations, BlockLine, Pollable, RenderAsync, ToggleState}, properties::WindowSize, }, theme::{Alignment, Margin}, }; use rstest::rstest; use std::rc::Rc; #[derive(Debug)] struct Dynamic; impl AsRenderOperations for Dynamic { fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { Vec::new() } } impl RenderAsync for Dynamic { fn pollable(&self) -> Box { // Use some random one, we don't care Box::new(ToggleState::new(Default::default())) } } #[rstest] #[case(RenderOperation::ClearScreen)] #[case(RenderOperation::JumpToVerticalCenter)] #[case(RenderOperation::JumpToBottomRow{ index: 0 })] #[case(RenderOperation::RenderLineBreak)] #[case(RenderOperation::SetColors(Colors{background: None, foreground: None}))] #[case(RenderOperation::RenderText{line: String::from("asd").into(), alignment: Default::default()})] #[case(RenderOperation::RenderBlockLine( BlockLine{ prefix: "".into(), right_padding_length: 0, repeat_prefix_on_wrap: false, text: WeightedLine::from("".to_string()), alignment: Default::default(), block_length: 42, block_color: None, } ))] #[case(RenderOperation::RenderDynamic(Rc::new(Dynamic)))] #[case(RenderOperation::RenderAsync(Rc::new(Dynamic)))] #[case(RenderOperation::InitColumnLayout{ columns: vec![1, 2] })] #[case(RenderOperation::EnterColumn{ column: 1 })] #[case(RenderOperation::ExitLayout)] fn same_not_modified(#[case] operation: RenderOperation) { let diff = operation.is_content_different(&operation); assert!(!diff); } #[test] fn different_text() { let lhs = RenderOperation::RenderText { line: String::from("foo").into(), alignment: Default::default() }; let rhs = RenderOperation::RenderText { line: String::from("bar").into(), alignment: Default::default() }; assert!(lhs.is_content_different(&rhs)); } #[test] fn different_text_alignment() { let lhs = RenderOperation::RenderText { line: String::from("foo").into(), alignment: Alignment::Left { margin: Margin::Fixed(42) }, }; let rhs = RenderOperation::RenderText { line: String::from("foo").into(), alignment: Alignment::Left { margin: Margin::Fixed(1337) }, }; assert!(!lhs.is_content_different(&rhs)); } #[test] fn different_colors() { let lhs = RenderOperation::SetColors(Colors { background: None, foreground: Some(Color::new(1, 2, 3)) }); let rhs = RenderOperation::SetColors(Colors { background: None, foreground: Some(Color::new(3, 2, 1)) }); assert!(!lhs.is_content_different(&rhs)); } #[test] fn different_column_layout() { let lhs = RenderOperation::InitColumnLayout { columns: vec![1, 2] }; let rhs = RenderOperation::InitColumnLayout { columns: vec![1, 3] }; assert!(lhs.is_content_different(&rhs)); } #[test] fn different_column() { let lhs = RenderOperation::EnterColumn { column: 0 }; let rhs = RenderOperation::EnterColumn { column: 1 }; assert!(lhs.is_content_different(&rhs)); } #[test] fn no_slide_changes() { let presentation = Presentation::from(vec![ Slide::from(vec![RenderOperation::ClearScreen]), Slide::from(vec![RenderOperation::ClearScreen]), Slide::from(vec![RenderOperation::ClearScreen]), ]); assert_eq!(PresentationDiffer::find_first_modification(&presentation, &presentation), None); } #[test] fn slides_truncated() { let lhs = Presentation::from(vec![ Slide::from(vec![RenderOperation::ClearScreen]), Slide::from(vec![RenderOperation::ClearScreen]), ]); let rhs = Presentation::from(vec![Slide::from(vec![RenderOperation::ClearScreen])]); assert_eq!( PresentationDiffer::find_first_modification(&lhs, &rhs), Some(Modification { slide_index: 0, chunk_index: usize::MAX }) ); } #[test] fn slides_added() { let lhs = Presentation::from(vec![Slide::from(vec![RenderOperation::ClearScreen])]); let rhs = Presentation::from(vec![ Slide::from(vec![RenderOperation::ClearScreen]), Slide::from(vec![RenderOperation::ClearScreen]), ]); assert_eq!( PresentationDiffer::find_first_modification(&lhs, &rhs), Some(Modification { slide_index: 1, chunk_index: 0 }) ); } #[test] fn second_slide_content_changed() { let lhs = Presentation::from(vec![ Slide::from(vec![RenderOperation::ClearScreen]), Slide::from(vec![RenderOperation::ClearScreen]), Slide::from(vec![RenderOperation::ClearScreen]), ]); let rhs = Presentation::from(vec![ Slide::from(vec![RenderOperation::ClearScreen]), Slide::from(vec![RenderOperation::JumpToVerticalCenter]), Slide::from(vec![RenderOperation::ClearScreen]), ]); assert_eq!( PresentationDiffer::find_first_modification(&lhs, &rhs), Some(Modification { slide_index: 1, chunk_index: 0 }) ); } #[test] fn presentation_changed_style() { let lhs = Presentation::from(vec![Slide::from(vec![RenderOperation::SetColors(Colors { background: None, foreground: Some(Color::new(255, 0, 0)), })])]); let rhs = Presentation::from(vec![Slide::from(vec![RenderOperation::SetColors(Colors { background: None, foreground: Some(Color::new(0, 0, 0)), })])]); assert_eq!(PresentationDiffer::find_first_modification(&lhs, &rhs), None); } #[test] fn chunk_change() { let lhs = Presentation::from(vec![ Slide::from(vec![RenderOperation::ClearScreen]), SlideBuilder::default() .chunks(vec![SlideChunk::default(), SlideChunk::new(vec![RenderOperation::ClearScreen], vec![])]) .build(), ]); let rhs = Presentation::from(vec![ Slide::from(vec![RenderOperation::ClearScreen]), SlideBuilder::default() .chunks(vec![ SlideChunk::default(), SlideChunk::new(vec![RenderOperation::ClearScreen, RenderOperation::ClearScreen], vec![]), ]) .build(), ]); assert_eq!( PresentationDiffer::find_first_modification(&lhs, &rhs), Some(Modification { slide_index: 1, chunk_index: 1 }) ); assert_eq!( PresentationDiffer::find_first_modification(&rhs, &lhs), Some(Modification { slide_index: 1, chunk_index: 1 }) ); } } presenterm-0.15.1/src/presentation/mod.rs000064400000000000000000000453711046102023000165350ustar 00000000000000use crate::{config::OptionsConfig, render::operation::RenderOperation}; use serde::Deserialize; use std::{ cell::RefCell, fmt::Debug, ops::Deref, rc::Rc, sync::{Arc, Mutex}, }; pub(crate) mod builder; pub(crate) mod diff; pub(crate) mod poller; #[derive(Debug)] pub(crate) struct Modals { pub(crate) slide_index: Vec, pub(crate) bindings: Vec, } /// A presentation. #[derive(Debug)] pub(crate) struct Presentation { slides: Vec, modals: Modals, pub(crate) state: PresentationState, } impl Presentation { /// Construct a new presentation. pub(crate) fn new(slides: Vec, modals: Modals, state: PresentationState) -> Self { Self { slides, modals, state } } /// Iterate the slides in this presentation. pub(crate) fn iter_slides(&self) -> impl Iterator { self.slides.iter() } /// Iterate the slides in this presentation. pub(crate) fn iter_slides_mut(&mut self) -> impl Iterator { self.slides.iter_mut() } /// Iterate the operations that render the slide index. pub(crate) fn iter_slide_index_operations(&self) -> impl Iterator { self.modals.slide_index.iter() } /// Iterate the operations that render the key bindings modal. pub(crate) fn iter_bindings_operations(&self) -> impl Iterator { self.modals.bindings.iter() } /// Consume this presentation and return its slides. pub(crate) fn into_slides(self) -> Vec { self.slides } /// Get the current slide. pub(crate) fn current_slide(&self) -> &Slide { &self.slides[self.current_slide_index()] } /// Get the current slide index. pub(crate) fn current_slide_index(&self) -> usize { self.state.current_slide_index() } /// Jump forwards. pub(crate) fn jump_next(&mut self) -> bool { let current_slide = self.current_slide_mut(); if current_slide.move_next() { return true; } self.jump_next_slide() } /// Jump to the next slide, ignoring any chunks and modifiers. pub(crate) fn jump_next_fast(&mut self) -> bool { self.jump_next_slide() } /// Jump backwards. pub(crate) fn jump_previous(&mut self) -> bool { let current_slide = self.current_slide_mut(); if current_slide.move_previous() { return true; } self.jump_previous_slide() } /// Jump to the previous slide ignoring any chunks and modifiers. pub(crate) fn jump_previous_fast(&mut self) -> bool { let output = self.jump_previous_slide(); self.current_slide_mut().show_first_chunk(); output } /// Jump to the first slide. pub(crate) fn jump_first_slide(&mut self) -> bool { self.go_to_slide(0) } /// Jump to the last slide. pub(crate) fn jump_last_slide(&mut self) -> bool { let last_slide_index = self.slides.len().saturating_sub(1); self.go_to_slide(last_slide_index) } /// Jump to a specific slide. pub(crate) fn go_to_slide(&mut self, slide_index: usize) -> bool { if slide_index < self.slides.len() { self.state.set_current_slide_index(slide_index); // Always show only the first slide when jumping to a particular one. self.current_slide_mut().show_first_chunk(); true } else { false } } /// Jump to a specific chunk within the current slide. pub(crate) fn jump_chunk(&mut self, chunk_index: usize) { self.current_slide_mut().jump_chunk(chunk_index); } /// Get the current slide's chunk. pub(crate) fn current_chunk(&self) -> usize { self.current_slide().current_chunk_index() } pub(crate) fn current_slide_mut(&mut self) -> &mut Slide { let index = self.current_slide_index(); &mut self.slides[index] } /// Show all chunks in the current slide. pub(crate) fn show_all_slide_chunks(&mut self) { self.current_slide_mut().show_all_chunks(); } fn jump_next_slide(&mut self) -> bool { let current_slide_index = self.current_slide_index(); if current_slide_index < self.slides.len() - 1 { self.state.set_current_slide_index(current_slide_index + 1); // Going forward we show only the first chunk. self.current_slide_mut().show_first_chunk(); true } else { false } } fn jump_previous_slide(&mut self) -> bool { let current_slide_index = self.current_slide_index(); if current_slide_index > 0 { self.state.set_current_slide_index(current_slide_index - 1); // Going backwards we show all chunks. self.current_slide_mut().show_all_chunks(); true } else { false } } } impl From> for Presentation { fn from(slides: Vec) -> Self { let modals = Modals { slide_index: vec![], bindings: vec![] }; Self::new(slides, modals, Default::default()) } } #[derive(Debug)] pub(crate) struct AsyncPresentationError { pub(crate) slide: usize, pub(crate) error: String, } pub(crate) type AsyncPresentationErrorHolder = Arc>>; #[derive(Debug, Default)] pub(crate) struct PresentationStateInner { current_slide_index: usize, async_error_holder: AsyncPresentationErrorHolder, } #[derive(Clone, Debug, Default)] pub(crate) struct PresentationState { inner: Rc>, } impl PresentationState { pub(crate) fn async_error_holder(&self) -> AsyncPresentationErrorHolder { self.inner.deref().borrow().async_error_holder.clone() } pub(crate) fn current_slide_index(&self) -> usize { self.inner.deref().borrow().current_slide_index } fn set_current_slide_index(&self, value: usize) { self.inner.deref().borrow_mut().current_slide_index = value; } } /// A slide builder. #[derive(Default)] pub(crate) struct SlideBuilder { chunks: Vec, footer: Vec, } impl SlideBuilder { pub(crate) fn chunks(mut self, chunks: Vec) -> Self { self.chunks = chunks; self } pub(crate) fn footer(mut self, footer: Vec) -> Self { self.footer = footer; self } pub(crate) fn build(self) -> Slide { Slide::new(self.chunks, self.footer) } } /// A slide. /// /// Slides are composed of render operations that can be carried out to materialize this slide into /// the terminal's screen. #[derive(Debug)] pub(crate) struct Slide { chunks: Vec, footer: Vec, visible_chunks: usize, } impl Slide { pub(crate) fn new(chunks: Vec, footer: Vec) -> Self { Self { chunks, footer, visible_chunks: 1 } } pub(crate) fn iter_operations(&self) -> impl Iterator + Clone { self.chunks.iter().flat_map(|chunk| chunk.operations.iter()).chain(self.footer.iter()) } pub(crate) fn iter_operations_mut(&mut self) -> impl Iterator { self.chunks.iter_mut().flat_map(|chunk| chunk.operations.iter_mut()).chain(self.footer.iter_mut()) } pub(crate) fn iter_visible_operations(&self) -> impl Iterator + Clone { self.chunks.iter().take(self.visible_chunks).flat_map(|chunk| chunk.operations.iter()).chain(self.footer.iter()) } pub(crate) fn iter_visible_operations_mut(&mut self) -> impl Iterator { self.chunks .iter_mut() .take(self.visible_chunks) .flat_map(|chunk| chunk.operations.iter_mut()) .chain(self.footer.iter_mut()) } pub(crate) fn iter_chunks(&self) -> impl Iterator { self.chunks.iter() } fn jump_chunk(&mut self, chunk_index: usize) { self.visible_chunks = chunk_index.saturating_add(1).min(self.chunks.len()); for chunk in self.chunks.iter().take(self.visible_chunks - 1) { chunk.apply_all_mutations(); } } fn current_chunk_index(&self) -> usize { self.visible_chunks.saturating_sub(1) } fn current_chunk(&self) -> &SlideChunk { &self.chunks[self.current_chunk_index()] } fn show_first_chunk(&mut self) { self.visible_chunks = 1; self.current_chunk().reset_mutations(); } pub(crate) fn show_all_chunks(&mut self) { self.visible_chunks = self.chunks.len(); for chunk in &self.chunks { chunk.apply_all_mutations(); } } fn move_next(&mut self) -> bool { if self.chunks[self.current_chunk_index()].mutate_next() { return true; } if self.visible_chunks == self.chunks.len() { false } else { self.visible_chunks += 1; self.current_chunk().reset_mutations(); true } } fn move_previous(&mut self) -> bool { if self.chunks[self.current_chunk_index()].mutate_previous() { return true; } if self.visible_chunks == 1 { false } else { self.visible_chunks -= 1; self.current_chunk().apply_all_mutations(); true } } } impl From> for Slide { fn from(operations: Vec) -> Self { Self::new(vec![SlideChunk::new(operations, Vec::new())], vec![]) } } #[derive(Debug, Default)] pub(crate) struct SlideChunk { operations: Vec, mutators: Vec>, } impl SlideChunk { pub(crate) fn new(operations: Vec, mutators: Vec>) -> Self { Self { operations, mutators } } pub(crate) fn iter_operations(&self) -> impl Iterator + Clone { self.operations.iter() } pub(crate) fn pop_last(&mut self) -> Option { self.operations.pop() } fn mutate_next(&self) -> bool { for mutator in &self.mutators { if mutator.mutate_next() { return true; } } false } fn mutate_previous(&self) -> bool { for mutator in self.mutators.iter().rev() { if mutator.mutate_previous() { return true; } } false } fn reset_mutations(&self) { for mutator in &self.mutators { mutator.reset_mutations(); } } fn apply_all_mutations(&self) { for mutator in &self.mutators { mutator.apply_all_mutations(); } } } pub(crate) trait ChunkMutator: Debug { fn mutate_next(&self) -> bool; fn mutate_previous(&self) -> bool; fn reset_mutations(&self); fn apply_all_mutations(&self); #[allow(dead_code)] fn mutations(&self) -> (usize, usize); } /// The metadata for a presentation. #[derive(Clone, Debug, Deserialize)] pub(crate) struct PresentationMetadata { /// The presentation title. pub(crate) title: Option, /// The presentation sub-title. #[serde(default)] pub(crate) sub_title: Option, /// The presentation event. #[serde(default)] pub(crate) event: Option, /// The presentation location. #[serde(default)] pub(crate) location: Option, /// The presentation date. #[serde(default)] pub(crate) date: Option, /// The presentation author. #[serde(default)] pub(crate) author: Option, /// The presentation authors. #[serde(default)] pub(crate) authors: Vec, /// The presentation's theme metadata. #[serde(default)] pub(crate) theme: PresentationThemeMetadata, /// The presentation's options. #[serde(default)] pub(crate) options: Option, } impl PresentationMetadata { /// Check if this presentation has frontmatter. pub(crate) fn has_frontmatter(&self) -> bool { self.title.is_some() || self.sub_title.is_some() || self.event.is_some() || self.location.is_some() || self.date.is_some() || self.author.is_some() || !self.authors.is_empty() } } /// A presentation's theme metadata. #[derive(Clone, Debug, Default, Deserialize)] pub(crate) struct PresentationThemeMetadata { /// The theme name. #[serde(default)] pub(crate) name: Option, /// the theme path. #[serde(default)] pub(crate) path: Option, /// Any specific overrides for the presentation's theme. #[serde(default, rename = "override")] pub(crate) overrides: Option, } #[cfg(test)] mod test { use super::*; use rstest::rstest; use std::cell::RefCell; #[derive(Clone)] enum Jump { First, Last, Next, NextFast, Previous, PreviousFast, Specific(usize), } impl Jump { fn apply(&self, presentation: &mut Presentation) { use Jump::*; match self { First => presentation.jump_first_slide(), Last => presentation.jump_last_slide(), Next => presentation.jump_next(), NextFast => presentation.jump_next_fast(), Previous => presentation.jump_previous(), PreviousFast => presentation.jump_previous_fast(), Specific(index) => presentation.go_to_slide(*index), }; } fn repeat(&self, count: usize) -> Vec { vec![self.clone(); count] } } #[derive(Debug)] struct DummyMutator { current: RefCell, limit: usize, } impl DummyMutator { fn new(limit: usize) -> Self { Self { current: 0.into(), limit } } } impl ChunkMutator for DummyMutator { fn mutate_next(&self) -> bool { let mut current = self.current.borrow_mut(); if *current < self.limit { *current += 1; true } else { false } } fn mutate_previous(&self) -> bool { let mut current = self.current.borrow_mut(); if *current > 0 { *current -= 1; true } else { false } } fn reset_mutations(&self) { *self.current.borrow_mut() = 0; } fn apply_all_mutations(&self) { *self.current.borrow_mut() = self.limit; } fn mutations(&self) -> (usize, usize) { (*self.current.borrow(), self.limit) } } #[rstest] #[case::previous_from_first(0, &[Jump::Previous], 0, 0)] #[case::next_from_first(0, &[Jump::Next], 0, 1)] #[case::next_next_from_first(0, &[Jump::Next, Jump::Next], 0, 2)] #[case::next_next_next_from_first(0, &[Jump::Next, Jump::Next, Jump::Next], 1, 0)] #[case::next_fast_from_first(0, &[Jump::NextFast], 1, 0)] #[case::next_fast_twice_from_first(0, &[Jump::NextFast, Jump::NextFast], 2, 0)] #[case::last_from_first(0, &[Jump::Last], 2, 0)] #[case::previous_from_second(1, &[Jump::Previous], 0, 2)] #[case::previous_fast_from_second(1, &[Jump::PreviousFast], 0, 0)] #[case::previous_fast_twice_from_second(1, &[Jump::PreviousFast, Jump::PreviousFast], 0, 0)] #[case::next_from_second(1, &[Jump::Next], 1, 1)] #[case::specific_first_from_second(1, &[Jump::Specific(0)], 0, 0)] #[case::specific_last_from_second(1, &[Jump::Specific(2)], 2, 0)] #[case::first_from_last(2, &[Jump::First], 0, 0)] fn jumping( #[case] from: usize, #[case] jumps: &[Jump], #[case] expected_slide: usize, #[case] expected_chunk: usize, ) { let mut presentation = Presentation::from(vec![ Slide::new(vec![SlideChunk::default(), SlideChunk::default(), SlideChunk::default()], vec![]), Slide::new(vec![SlideChunk::default(), SlideChunk::default()], vec![]), Slide::new(vec![SlideChunk::default(), SlideChunk::default()], vec![]), ]); presentation.go_to_slide(from); for jump in jumps { jump.apply(&mut presentation); } assert_eq!(presentation.current_slide_index(), expected_slide); assert_eq!(presentation.current_slide().visible_chunks - 1, expected_chunk); } #[rstest] #[case::next_1(0, &[Jump::Next], [1, 0, 0], 0, 0)] #[case::next_previous(0, &[Jump::Next, Jump::Previous], [0, 0, 0], 0, 0)] #[case::next_2(0, &Jump::Next.repeat(2), [1, 1, 0], 0, 0)] #[case::next_3(0, &Jump::Next.repeat(3), [1, 2, 0], 0, 0)] #[case::next_4(0, &Jump::Next.repeat(4), [1, 2, 0], 0, 1)] #[case::next_4_back_4( 0, &[Jump::Next.repeat(4), Jump::Previous.repeat(4)].concat(), [0, 0, 0], 0, 0 )] #[case::last_first(0, &[Jump::Last, Jump::First], [0, 0, 0], 0, 0)] #[case::back_from_second(0, &[Jump::Specific(1), Jump::Previous], [1, 2, 0], 0, 1)] #[case::specific_from_second(0, &[Jump::Specific(1), Jump::Previous, Jump::Specific(0)], [0, 0, 0], 0, 0)] fn jumping_with_mutations( #[case] from: usize, #[case] jumps: &[Jump], #[case] mutations: [usize; 3], #[case] expected_slide: usize, #[case] expected_chunk: usize, ) { let mut presentation = Presentation::from(vec![ SlideBuilder::default() .chunks(vec![ SlideChunk::new(vec![], vec![Box::new(DummyMutator::new(1)), Box::new(DummyMutator::new(2))]), SlideChunk::default(), ]) .build(), SlideBuilder::default() .chunks(vec![SlideChunk::new(vec![], vec![Box::new(DummyMutator::new(2))]), SlideChunk::default()]) .build(), ]); presentation.go_to_slide(from); for jump in jumps { jump.apply(&mut presentation); } let mutators: Vec<_> = presentation .iter_slides() .flat_map(|slide| slide.chunks.iter()) .flat_map(|chunk| chunk.mutators.iter()) .collect(); assert_eq!(mutators.len(), mutations.len(), "unexpected mutation count"); for (index, (mutator, expected_mutations)) in mutators.into_iter().zip(mutations).enumerate() { assert_eq!(mutator.mutations().0, expected_mutations, "diff on {index}"); } assert_eq!(presentation.current_slide_index(), expected_slide, "slide differs"); assert_eq!(presentation.current_slide().visible_chunks - 1, expected_chunk, "chunk differs"); } } presenterm-0.15.1/src/presentation/poller.rs000064400000000000000000000075421046102023000172510ustar 00000000000000use crate::render::operation::{Pollable, PollableState}; use std::{ sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}, thread, time::Duration, }; const POLL_INTERVAL: Duration = Duration::from_millis(25); pub(crate) struct Poller { sender: Sender, receiver: Receiver, } impl Poller { pub(crate) fn launch() -> Self { let (command_sender, command_receiver) = channel(); let (effect_sender, effect_receiver) = channel(); let worker = PollerWorker::new(command_receiver, effect_sender); thread::spawn(move || { worker.run(); }); Self { sender: command_sender, receiver: effect_receiver } } pub(crate) fn send(&self, command: PollerCommand) { let _ = self.sender.send(command); } pub(crate) fn next_effect(&mut self) -> Option { self.receiver.try_recv().ok() } } /// An effect caused by a pollable. #[derive(Clone)] pub(crate) enum PollableEffect { /// Refresh the given slide. RefreshSlide(usize), /// Display an error for the given slide. DisplayError { slide: usize, error: String }, } /// A poller command. pub(crate) enum PollerCommand { /// Start polling a pollable that's positioned in the given slide. Poll { pollable: Box, slide: usize }, /// Reset all pollables. Reset, } struct PollerWorker { receiver: Receiver, sender: Sender, pollables: Vec<(Box, usize)>, } impl PollerWorker { fn new(receiver: Receiver, sender: Sender) -> Self { Self { receiver, sender, pollables: Default::default() } } fn run(mut self) { loop { match self.receiver.recv_timeout(POLL_INTERVAL) { Ok(command) => self.process_command(command), // TODO don't loop forever. Err(RecvTimeoutError::Timeout) => self.poll(), Err(RecvTimeoutError::Disconnected) => break, }; } } fn process_command(&mut self, command: PollerCommand) { match command { PollerCommand::Poll { mut pollable, slide } => { // Poll and only insert if it's still running. match pollable.poll() { PollableState::Unmodified | PollableState::Modified => { self.pollables.push((pollable, slide)); } PollableState::Done => { let _ = self.sender.send(PollableEffect::RefreshSlide(slide)); } PollableState::Failed { error } => { let _ = self.sender.send(PollableEffect::DisplayError { slide, error }); } }; } PollerCommand::Reset => self.pollables.clear(), } } fn poll(&mut self) { let mut removables = Vec::new(); for (index, (pollable, slide)) in self.pollables.iter_mut().enumerate() { let slide = *slide; let (effect, remove) = match pollable.poll() { PollableState::Unmodified => (None, false), PollableState::Modified => (Some(PollableEffect::RefreshSlide(slide)), false), PollableState::Done => (Some(PollableEffect::RefreshSlide(slide)), true), PollableState::Failed { error } => (Some(PollableEffect::DisplayError { slide, error }), true), }; if let Some(effect) = effect { let _ = self.sender.send(effect); } if remove { removables.push(index); } } // Walk back and swap remove to avoid invalidating indexes. for index in removables.iter().rev() { self.pollables.swap_remove(*index); } } } presenterm-0.15.1/src/presenter.rs000064400000000000000000000636201046102023000152470ustar 00000000000000use crate::{ code::execute::SnippetExecutor, commands::{ listener::{Command, CommandListener}, speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventPublisher}, }, config::{KeyBindingsConfig, SlideTransitionConfig, SlideTransitionStyleConfig}, markdown::parse::MarkdownParser, presentation::{ Presentation, Slide, builder::{PresentationBuilder, PresentationBuilderOptions, Themes, error::BuildError}, diff::PresentationDiffer, poller::{PollableEffect, Poller, PollerCommand}, }, render::{ ErrorSource, RenderError, RenderResult, TerminalDrawer, TerminalDrawerOptions, ascii_scaler::AsciiScaler, engine::{MaxSize, RenderEngine, RenderEngineOptions}, operation::{Pollable, RenderAsyncStartPolicy, RenderOperation}, properties::WindowSize, validate::OverflowValidator, }, resource::Resources, terminal::{ image::printer::{ImagePrinter, ImageRegistry}, printer::{TerminalCommand, TerminalIo}, virt::{ImageBehavior, TerminalGrid, VirtualTerminal}, }, theme::{ProcessingThemeError, raw::PresentationTheme}, third_party::ThirdPartyRender, transitions::{ AnimateTransition, AnimationFrame, LinesFrame, TransitionDirection, collapse_horizontal::CollapseHorizontalAnimation, fade::FadeAnimation, slide_horizontal::SlideHorizontalAnimation, }, }; use std::{ fmt::Display, io::{self}, mem, ops::Deref, path::Path, sync::Arc, time::{Duration, Instant}, }; pub struct PresenterOptions { pub mode: PresentMode, pub builder_options: PresentationBuilderOptions, pub font_size_fallback: u8, pub bindings: KeyBindingsConfig, pub validate_overflows: bool, pub max_size: MaxSize, pub transition: Option, } /// A slideshow presenter. /// /// This type puts everything else together. pub struct Presenter<'a> { default_theme: &'a PresentationTheme, listener: CommandListener, parser: MarkdownParser<'a>, resources: Resources, third_party: ThirdPartyRender, code_executor: Arc, state: PresenterState, image_printer: Arc, themes: Themes, options: PresenterOptions, speaker_notes_event_publisher: Option, poller: Poller, } impl<'a> Presenter<'a> { /// Construct a new presenter. #[allow(clippy::too_many_arguments)] pub fn new( default_theme: &'a PresentationTheme, listener: CommandListener, parser: MarkdownParser<'a>, resources: Resources, third_party: ThirdPartyRender, code_executor: Arc, themes: Themes, image_printer: Arc, options: PresenterOptions, speaker_notes_event_publisher: Option, ) -> Self { Self { default_theme, listener, parser, resources, third_party, code_executor, state: PresenterState::Empty, image_printer, themes, options, speaker_notes_event_publisher, poller: Poller::launch(), } } /// Run a presentation. pub fn present(mut self, path: &Path) -> Result<(), PresentationError> { if matches!(self.options.mode, PresentMode::Development) { self.resources.watch_presentation_file(path.to_path_buf()); } self.state = PresenterState::Presenting(Presentation::from(vec![])); self.try_reload(path, true)?; let drawer_options = TerminalDrawerOptions { font_size_fallback: self.options.font_size_fallback, max_size: self.options.max_size.clone(), }; let mut drawer = TerminalDrawer::new(self.image_printer.clone(), drawer_options)?; loop { // Poll async renders once before we draw just in case. self.render(&mut drawer)?; loop { if self.process_poller_effects()? { self.render(&mut drawer)?; } let command = match self.listener.try_next_command()? { Some(command) => command, _ => match self.resources.resources_modified() { true => Command::Reload, false => { if self.check_async_error() { break; } continue; } }, }; match self.apply_command(command) { CommandSideEffect::Exit => { self.publish_event(SpeakerNotesEvent::Exit)?; return Ok(()); } CommandSideEffect::Suspend => { self.suspend(&mut drawer); break; } CommandSideEffect::Reload => { self.try_reload(path, false)?; break; } CommandSideEffect::Redraw => { self.try_scale_transition_images()?; break; } CommandSideEffect::AnimateNextSlide => { self.animate_next_slide(&mut drawer)?; break; } CommandSideEffect::AnimatePreviousSlide => { self.animate_previous_slide(&mut drawer)?; break; } CommandSideEffect::None => (), }; } self.publish_event(SpeakerNotesEvent::GoToSlide { slide: self.state.presentation().current_slide_index() as u32 + 1, })?; } } fn process_poller_effects(&mut self) -> Result { let current_slide = match &self.state { PresenterState::Presenting(presentation) | PresenterState::SlideIndex(presentation) | PresenterState::KeyBindings(presentation) | PresenterState::Failure { presentation, .. } => presentation.current_slide_index(), PresenterState::Empty => usize::MAX, }; let mut refreshed = false; let mut needs_render = false; while let Some(effect) = self.poller.next_effect() { match effect { PollableEffect::RefreshSlide(index) => { needs_render = needs_render || index == current_slide; refreshed = true; } PollableEffect::DisplayError { slide, error } => { let presentation = mem::take(&mut self.state).into_presentation(); self.state = PresenterState::failure(error, presentation, ErrorSource::Slide(slide + 1), FailureMode::Other); needs_render = true; } } } if refreshed { self.try_scale_transition_images()?; } Ok(needs_render) } fn publish_event(&self, event: SpeakerNotesEvent) -> io::Result<()> { if let Some(publisher) = &self.speaker_notes_event_publisher { publisher.send(event)?; } Ok(()) } fn check_async_error(&mut self) -> bool { let error_holder = self.state.presentation().state.async_error_holder(); let error_holder = error_holder.lock().unwrap(); match error_holder.deref() { Some(error) => { let presentation = mem::take(&mut self.state).into_presentation(); self.state = PresenterState::failure( &error.error, presentation, ErrorSource::Slide(error.slide), FailureMode::Other, ); true } None => false, } } fn render(&mut self, drawer: &mut TerminalDrawer) -> RenderResult { let result = match &self.state { PresenterState::Presenting(presentation) => { drawer.render_operations(presentation.current_slide().iter_visible_operations()) } PresenterState::SlideIndex(presentation) => { drawer.render_operations(presentation.current_slide().iter_visible_operations())?; drawer.render_operations(presentation.iter_slide_index_operations()) } PresenterState::KeyBindings(presentation) => { drawer.render_operations(presentation.current_slide().iter_visible_operations())?; drawer.render_operations(presentation.iter_bindings_operations()) } PresenterState::Failure { error, source, .. } => drawer.render_error(error, source), PresenterState::Empty => panic!("cannot render without state"), }; // If the screen is too small, simply ignore this. Eventually the user will resize the // screen. if matches!(result, Err(RenderError::TerminalTooSmall)) { Ok(()) } else { result } } fn apply_command(&mut self, command: Command) -> CommandSideEffect { // These ones always happens no matter our state. match command { Command::Reload => { return CommandSideEffect::Reload; } Command::HardReload => { if matches!(self.options.mode, PresentMode::Development) { self.resources.clear(); } return CommandSideEffect::Reload; } Command::Exit => return CommandSideEffect::Exit, Command::Suspend => return CommandSideEffect::Suspend, _ => (), }; if matches!(command, Command::Redraw) { if !self.is_displaying_other_error() { let presentation = mem::take(&mut self.state).into_presentation(); self.state = self.validate_overflows(presentation); } return CommandSideEffect::Redraw; } // Now apply the commands that require a presentation. let presentation = match &mut self.state { PresenterState::Presenting(presentation) | PresenterState::SlideIndex(presentation) | PresenterState::KeyBindings(presentation) => presentation, _ => { return CommandSideEffect::None; } }; let needs_redraw = match command { Command::Next => { let current_slide = presentation.current_slide_index(); if !presentation.jump_next() { false } else if presentation.current_slide_index() != current_slide { return CommandSideEffect::AnimateNextSlide; } else { true } } Command::NextFast => presentation.jump_next_fast(), Command::Previous => { let current_slide = presentation.current_slide_index(); if !presentation.jump_previous() { false } else if presentation.current_slide_index() != current_slide { return CommandSideEffect::AnimatePreviousSlide; } else { true } } Command::PreviousFast => presentation.jump_previous_fast(), Command::FirstSlide => presentation.jump_first_slide(), Command::LastSlide => presentation.jump_last_slide(), Command::GoToSlide(number) => presentation.go_to_slide(number.saturating_sub(1) as usize), Command::RenderAsyncOperations => { let pollables = Self::trigger_slide_async_renders(presentation); if !pollables.is_empty() { for pollable in pollables { self.poller.send(PollerCommand::Poll { pollable, slide: presentation.current_slide_index() }); } return CommandSideEffect::Redraw; } else { return CommandSideEffect::None; } } Command::ToggleSlideIndex => { self.toggle_slide_index(); true } Command::ToggleKeyBindingsConfig => { self.toggle_key_bindings(); true } Command::CloseModal => { let presentation = mem::take(&mut self.state).into_presentation(); self.state = PresenterState::Presenting(presentation); true } Command::SkipPauses => { presentation.show_all_slide_chunks(); true } // These are handled above as they don't require the presentation Command::Reload | Command::HardReload | Command::Exit | Command::Suspend | Command::Redraw => { panic!("unreachable commands") } }; if needs_redraw { CommandSideEffect::Redraw } else { CommandSideEffect::None } } fn try_reload(&mut self, path: &Path, force: bool) -> RenderResult { if matches!(self.options.mode, PresentMode::Presentation) && !force { return Ok(()); } self.poller.send(PollerCommand::Reset); self.resources.clear_watches(); match self.load_presentation(path) { Ok(mut presentation) => { let current = self.state.presentation(); if let Some(modification) = PresentationDiffer::find_first_modification(current, &presentation) { presentation.go_to_slide(modification.slide_index); presentation.jump_chunk(modification.chunk_index); } else { presentation.go_to_slide(current.current_slide_index()); presentation.jump_chunk(current.current_chunk()); } self.start_automatic_async_renders(&mut presentation); self.state = self.validate_overflows(presentation); self.try_scale_transition_images()?; } Err(e) => { let presentation = mem::take(&mut self.state).into_presentation(); self.state = PresenterState::failure(e, presentation, ErrorSource::Presentation, FailureMode::Other); } }; Ok(()) } fn try_scale_transition_images(&self) -> RenderResult { if self.options.transition.is_none() { return Ok(()); } let options = RenderEngineOptions { max_size: self.options.max_size.clone(), ..Default::default() }; let scaler = AsciiScaler::new(options); let dimensions = WindowSize::current(self.options.font_size_fallback)?; scaler.process(self.state.presentation(), &dimensions)?; Ok(()) } fn trigger_slide_async_renders(presentation: &mut Presentation) -> Vec> { let slide = presentation.current_slide_mut(); let mut pollables = Vec::new(); for operation in slide.iter_visible_operations_mut() { if let RenderOperation::RenderAsync(operation) = operation { if let RenderAsyncStartPolicy::OnDemand = operation.start_policy() { pollables.push(operation.pollable()); } } } pollables } fn is_displaying_other_error(&self) -> bool { matches!(self.state, PresenterState::Failure { mode: FailureMode::Other, .. }) } fn validate_overflows(&self, presentation: Presentation) -> PresenterState { if self.options.validate_overflows { let dimensions = match WindowSize::current(self.options.font_size_fallback) { Ok(dimensions) => dimensions, Err(e) => { return PresenterState::failure(e, presentation, ErrorSource::Presentation, FailureMode::Other); } }; match OverflowValidator::validate(&presentation, dimensions) { Ok(()) => PresenterState::Presenting(presentation), Err(e) => PresenterState::failure(e, presentation, ErrorSource::Presentation, FailureMode::Overflow), } } else { PresenterState::Presenting(presentation) } } fn load_presentation(&mut self, path: &Path) -> Result { let presentation = PresentationBuilder::new( self.default_theme, self.resources.clone(), &mut self.third_party, self.code_executor.clone(), &self.themes, ImageRegistry::new(self.image_printer.clone()), self.options.bindings.clone(), &self.parser, self.options.builder_options.clone(), )? .build(path)?; Ok(presentation) } fn toggle_slide_index(&mut self) { let state = mem::take(&mut self.state); match state { PresenterState::Presenting(presentation) | PresenterState::KeyBindings(presentation) => { self.state = PresenterState::SlideIndex(presentation) } PresenterState::SlideIndex(presentation) => self.state = PresenterState::Presenting(presentation), other => self.state = other, } } fn toggle_key_bindings(&mut self) { let state = mem::take(&mut self.state); match state { PresenterState::Presenting(presentation) | PresenterState::SlideIndex(presentation) => { self.state = PresenterState::KeyBindings(presentation) } PresenterState::KeyBindings(presentation) => self.state = PresenterState::Presenting(presentation), other => self.state = other, } } fn suspend(&self, drawer: &mut TerminalDrawer) { #[cfg(unix)] unsafe { drawer.terminal.suspend(); libc::raise(libc::SIGTSTP); drawer.terminal.resume(); } } fn animate_next_slide(&mut self, drawer: &mut TerminalDrawer) -> RenderResult { let Some(config) = self.options.transition.clone() else { return Ok(()); }; let options = drawer.render_engine_options(); let presentation = self.state.presentation_mut(); let dimensions = WindowSize::current(self.options.font_size_fallback)?; presentation.jump_previous(); let left = Self::virtual_render(presentation.current_slide(), dimensions, &options)?; presentation.jump_next(); let right = Self::virtual_render(presentation.current_slide(), dimensions, &options)?; let direction = TransitionDirection::Next; self.animate_transition(drawer, left, right, direction, dimensions, config) } fn animate_previous_slide(&mut self, drawer: &mut TerminalDrawer) -> RenderResult { let Some(config) = self.options.transition.clone() else { return Ok(()); }; let options = drawer.render_engine_options(); let presentation = self.state.presentation_mut(); let dimensions = WindowSize::current(self.options.font_size_fallback)?; presentation.jump_next(); // Re-borrow to avoid calling fns above while mutably borrowing let presentation = self.state.presentation_mut(); let right = Self::virtual_render(presentation.current_slide(), dimensions, &options)?; presentation.jump_previous(); let left = Self::virtual_render(presentation.current_slide(), dimensions, &options)?; let direction = TransitionDirection::Previous; self.animate_transition(drawer, left, right, direction, dimensions, config) } fn animate_transition( &mut self, drawer: &mut TerminalDrawer, left: TerminalGrid, right: TerminalGrid, direction: TransitionDirection, dimensions: WindowSize, config: SlideTransitionConfig, ) -> RenderResult { let first = match &direction { TransitionDirection::Next => left.clone(), TransitionDirection::Previous => right.clone(), }; match &config.animation { SlideTransitionStyleConfig::SlideHorizontal => self.run_animation( drawer, first, SlideHorizontalAnimation::new(left, right, dimensions, direction), config, ), SlideTransitionStyleConfig::Fade => { self.run_animation(drawer, first, FadeAnimation::new(left, right, direction), config) } SlideTransitionStyleConfig::CollapseHorizontal => { self.run_animation(drawer, first, CollapseHorizontalAnimation::new(left, right, direction), config) } } } fn run_animation( &mut self, drawer: &mut TerminalDrawer, first: TerminalGrid, animation: T, config: SlideTransitionConfig, ) -> RenderResult where T: AnimateTransition, { let total_time = Duration::from_millis(config.duration_millis as u64); let frames: usize = config.frames; let total_frames = animation.total_frames(); let step = total_time / (frames as u32 * 2); let mut last_frame_index = 0; let mut frame_index = 1; // Render the first frame as text to have images as ascii Self::render_frame(&LinesFrame::from(&first).build_commands(), drawer)?; while frame_index < total_frames { let start = Instant::now(); let frame = animation.build_frame(frame_index, last_frame_index); let commands = frame.build_commands(); Self::render_frame(&commands, drawer)?; let elapsed = start.elapsed(); let sleep_needed = step.saturating_sub(elapsed); if sleep_needed.as_millis() > 0 { std::thread::sleep(step); } last_frame_index = frame_index; frame_index += total_frames.div_ceil(frames); } Ok(()) } fn render_frame(commands: &[TerminalCommand<'_>], drawer: &mut TerminalDrawer) -> RenderResult { drawer.terminal.execute(&TerminalCommand::BeginUpdate)?; for command in commands { drawer.terminal.execute(command)?; } drawer.terminal.execute(&TerminalCommand::EndUpdate)?; drawer.terminal.execute(&TerminalCommand::Flush)?; Ok(()) } fn virtual_render( slide: &Slide, dimensions: WindowSize, options: &RenderEngineOptions, ) -> Result { let mut term = VirtualTerminal::new(dimensions, ImageBehavior::PrintAscii); let engine = RenderEngine::new(&mut term, dimensions, options.clone()); engine.render(slide.iter_visible_operations())?; Ok(term.into_contents()) } fn start_automatic_async_renders(&self, presentation: &mut Presentation) { for (index, slide) in presentation.iter_slides_mut().enumerate() { for operation in slide.iter_operations_mut() { if let RenderOperation::RenderAsync(operation) = operation { if let RenderAsyncStartPolicy::Automatic = operation.start_policy() { let pollable = operation.pollable(); self.poller.send(PollerCommand::Poll { pollable, slide: index }); } } } } } } enum CommandSideEffect { Exit, Suspend, Redraw, Reload, AnimateNextSlide, AnimatePreviousSlide, None, } #[derive(Default)] enum PresenterState { #[default] Empty, Presenting(Presentation), SlideIndex(Presentation), KeyBindings(Presentation), Failure { error: String, presentation: Presentation, source: ErrorSource, mode: FailureMode, }, } impl PresenterState { pub(crate) fn failure( error: E, presentation: Presentation, source: ErrorSource, mode: FailureMode, ) -> Self { PresenterState::Failure { error: error.to_string(), presentation, source, mode } } fn presentation(&self) -> &Presentation { match self { Self::Presenting(presentation) | Self::SlideIndex(presentation) | Self::KeyBindings(presentation) | Self::Failure { presentation, .. } => presentation, Self::Empty => panic!("state is empty"), } } fn presentation_mut(&mut self) -> &mut Presentation { match self { Self::Presenting(presentation) | Self::SlideIndex(presentation) | Self::KeyBindings(presentation) | Self::Failure { presentation, .. } => presentation, Self::Empty => panic!("state is empty"), } } fn into_presentation(self) -> Presentation { match self { Self::Presenting(presentation) | Self::SlideIndex(presentation) | Self::KeyBindings(presentation) | Self::Failure { presentation, .. } => presentation, Self::Empty => panic!("state is empty"), } } } enum FailureMode { Overflow, Other, } /// This presentation mode. pub enum PresentMode { /// We are developing the presentation so we want live reloads when the input changes. Development, /// This is a live presentation so we don't want hot reloading. Presentation, } /// An error when loading a presentation. #[derive(thiserror::Error, Debug)] pub enum LoadPresentationError { #[error(transparent)] Processing(#[from] BuildError), #[error("processing theme: {0}")] ProcessingTheme(#[from] ProcessingThemeError), } /// An error during the presentation. #[derive(thiserror::Error, Debug)] pub enum PresentationError { #[error(transparent)] Render(#[from] RenderError), #[error("io: {0}")] Io(#[from] io::Error), } presenterm-0.15.1/src/render/ascii_scaler.rs000064400000000000000000000065241046102023000171400ustar 00000000000000use super::{ RenderError, engine::{RenderEngine, RenderEngineOptions}, }; use crate::{ WindowSize, presentation::Presentation, terminal::{ image::Image, printer::{TerminalCommand, TerminalError, TerminalIo}, }, }; use std::thread; use unicode_width::UnicodeWidthStr; pub(crate) struct AsciiScaler { options: RenderEngineOptions, } impl AsciiScaler { pub(crate) fn new(options: RenderEngineOptions) -> Self { Self { options } } pub(crate) fn process(self, presentation: &Presentation, dimensions: &WindowSize) -> Result<(), RenderError> { let mut collector = ImageCollector::default(); for slide in presentation.iter_slides() { let engine = RenderEngine::new(&mut collector, *dimensions, self.options.clone()); engine.render(slide.iter_operations())?; } thread::spawn(move || Self::scale(collector.images)); Ok(()) } fn scale(images: Vec) { for image in images { let ascii_image = image.image.to_ascii(); ascii_image.cache_scaling(image.columns, image.rows); } } } struct ScalableImage { image: Image, rows: u16, columns: u16, } struct ImageCollector { current_column: u16, current_row: u16, current_row_height: u16, images: Vec, } impl Default for ImageCollector { fn default() -> Self { Self { current_row: 0, current_column: 0, current_row_height: 1, images: Default::default() } } } impl TerminalIo for ImageCollector { fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> { use TerminalCommand::*; match command { MoveTo { column, row } => { self.current_column = *column; self.current_row = *row; } MoveToRow(row) => self.current_row = *row, MoveToColumn(column) => self.current_column = *column, MoveDown(amount) => self.current_row = self.current_row.saturating_add(*amount), MoveRight(amount) => self.current_column = self.current_column.saturating_add(*amount), MoveLeft(amount) => self.current_column = self.current_column.saturating_sub(*amount), MoveToNextLine => { self.current_row = self.current_row.saturating_add(1); self.current_column = 0; self.current_row_height = 1; } PrintText { content, style } => { self.current_column = self.current_column.saturating_add(content.width() as u16); self.current_row_height = self.current_row_height.max(style.size as u16); } PrintImage { image, options } => { // we can only really cache filesystem images for now let image = ScalableImage { image: image.clone(), rows: options.rows * 2, columns: options.columns }; self.images.push(image); } ClearScreen => { self.current_column = 0; self.current_row = 0; self.current_row_height = 1; } BeginUpdate | EndUpdate | Flush | SetColors(_) | SetBackgroundColor(_) | SetCursorBoundaries { .. } => (), }; Ok(()) } fn cursor_row(&self) -> u16 { self.current_row } } presenterm-0.15.1/src/render/engine.rs000064400000000000000000001110111046102023000157500ustar 00000000000000use super::{ RenderError, RenderResult, layout::Layout, operation::ImagePosition, properties::CursorPosition, text::TextDrawer, }; use crate::{ config::{MaxColumnsAlignment, MaxRowsAlignment}, markdown::{text::WeightedLine, text_style::Colors}, render::{ operation::{ AsRenderOperations, BlockLine, ImageRenderProperties, ImageSize, MarginProperties, RenderAsync, RenderOperation, }, properties::WindowSize, }, terminal::{ image::{ Image, printer::{ImageProperties, PrintOptions}, scale::{ImageScaler, ScaleImage}, }, printer::{TerminalCommand, TerminalIo}, }, theme::Alignment, }; use std::mem; const MINIMUM_LINE_LENGTH: u16 = 10; #[derive(Clone, Debug)] pub(crate) struct MaxSize { pub(crate) max_columns: u16, pub(crate) max_columns_alignment: MaxColumnsAlignment, pub(crate) max_rows: u16, pub(crate) max_rows_alignment: MaxRowsAlignment, } impl Default for MaxSize { fn default() -> Self { Self { max_columns: u16::MAX, max_columns_alignment: Default::default(), max_rows: u16::MAX, max_rows_alignment: Default::default(), } } } #[derive(Clone, Debug)] pub(crate) struct RenderEngineOptions { pub(crate) validate_overflows: bool, pub(crate) max_size: MaxSize, pub(crate) column_layout_margin: u16, } impl Default for RenderEngineOptions { fn default() -> Self { Self { validate_overflows: false, max_size: Default::default(), column_layout_margin: 4 } } } pub(crate) struct RenderEngine<'a, T> where T: TerminalIo, { terminal: &'a mut T, window_rects: Vec, colors: Colors, max_modified_row: u16, layout: LayoutState, options: RenderEngineOptions, image_scaler: Box, } impl<'a, T> RenderEngine<'a, T> where T: TerminalIo, { pub(crate) fn new(terminal: &'a mut T, window_dimensions: WindowSize, options: RenderEngineOptions) -> Self { let max_modified_row = terminal.cursor_row(); let current_rect = Self::starting_rect(window_dimensions, &options); let window_rects = vec![current_rect.clone()]; Self { terminal, window_rects, colors: Default::default(), max_modified_row, layout: Default::default(), options, image_scaler: Box::::default(), } } fn starting_rect(mut dimensions: WindowSize, options: &RenderEngineOptions) -> WindowRect { let mut start_row = 0; let mut start_column = 0; if dimensions.columns > options.max_size.max_columns { let extra_width = dimensions.columns - options.max_size.max_columns; dimensions = dimensions.shrink_columns(extra_width); start_column = match options.max_size.max_columns_alignment { MaxColumnsAlignment::Left => 0, MaxColumnsAlignment::Center => extra_width / 2, MaxColumnsAlignment::Right => extra_width, }; } if dimensions.rows > options.max_size.max_rows { let extra_height = dimensions.rows - options.max_size.max_rows; dimensions = dimensions.shrink_rows(extra_height); start_row = match options.max_size.max_rows_alignment { MaxRowsAlignment::Top => 0, MaxRowsAlignment::Center => extra_height / 2, MaxRowsAlignment::Bottom => extra_height, }; } WindowRect { dimensions, start_column, start_row } } pub(crate) fn render<'b>(mut self, operations: impl Iterator) -> RenderResult { let current_rect = self.current_rect().clone(); self.terminal.execute(&TerminalCommand::SetCursorBoundaries { rows: current_rect.dimensions.rows.saturating_add(current_rect.start_row), })?; self.terminal.execute(&TerminalCommand::BeginUpdate)?; if current_rect.start_row != 0 || current_rect.start_column != 0 { self.terminal .execute(&TerminalCommand::MoveTo { column: current_rect.start_column, row: current_rect.start_row })?; } for operation in operations { self.render_one(operation)?; } self.terminal.execute(&TerminalCommand::EndUpdate)?; self.terminal.execute(&TerminalCommand::Flush)?; if self.options.validate_overflows && self.max_modified_row > self.window_rects[0].dimensions.rows { return Err(RenderError::VerticalOverflow); } Ok(()) } fn render_one(&mut self, operation: &RenderOperation) -> RenderResult { match operation { RenderOperation::ClearScreen => self.clear_screen(), RenderOperation::ApplyMargin(properties) => self.apply_margin(properties), RenderOperation::PopMargin => self.pop_margin(), RenderOperation::SetColors(colors) => self.set_colors(colors), RenderOperation::JumpToVerticalCenter => self.jump_to_vertical_center(), RenderOperation::JumpToRow { index } => self.jump_to_row(*index), RenderOperation::JumpToBottomRow { index } => self.jump_to_bottom(*index), RenderOperation::JumpToColumn { index } => self.jump_to_column(*index), RenderOperation::RenderText { line, alignment } => self.render_text(line, *alignment), RenderOperation::RenderLineBreak => self.render_line_break(), RenderOperation::RenderImage(image, properties) => self.render_image(image, properties), RenderOperation::RenderBlockLine(operation) => self.render_block_line(operation), RenderOperation::RenderDynamic(generator) => self.render_dynamic(generator.as_ref()), RenderOperation::RenderAsync(generator) => self.render_async(generator.as_ref()), RenderOperation::InitColumnLayout { columns } => self.init_column_layout(columns), RenderOperation::EnterColumn { column } => self.enter_column(*column), RenderOperation::ExitLayout => self.exit_layout(), }?; if let LayoutState::EnteredColumn { column, columns } = &mut self.layout { columns[*column].current_row = self.terminal.cursor_row(); }; self.max_modified_row = self.max_modified_row.max(self.terminal.cursor_row()); Ok(()) } fn current_rect(&self) -> &WindowRect { // This invariant is enforced when popping. self.window_rects.last().expect("no rects") } fn current_dimensions(&self) -> &WindowSize { &self.current_rect().dimensions } fn clear_screen(&mut self) -> RenderResult { let current = self.current_rect().clone(); self.terminal.execute(&TerminalCommand::ClearScreen)?; self.terminal.execute(&TerminalCommand::MoveTo { column: current.start_column, row: current.start_row })?; self.max_modified_row = 0; Ok(()) } fn apply_margin(&mut self, properties: &MarginProperties) -> RenderResult { let MarginProperties { horizontal: horizontal_margin, top, bottom } = properties; let current = self.current_rect(); let margin = horizontal_margin.as_characters(current.dimensions.columns); let new_rect = current.shrink_horizontal(margin).shrink_bottom(*bottom).shrink_top(*top); if new_rect.start_row != self.terminal.cursor_row() { self.terminal.execute(&TerminalCommand::MoveToRow(new_rect.start_row))?; } self.window_rects.push(new_rect); Ok(()) } fn pop_margin(&mut self) -> RenderResult { if self.window_rects.len() == 1 { return Err(RenderError::PopDefaultScreen); } self.window_rects.pop(); Ok(()) } fn set_colors(&mut self, colors: &Colors) -> RenderResult { self.colors = *colors; self.apply_colors() } fn apply_colors(&mut self) -> RenderResult { self.terminal.execute(&TerminalCommand::SetColors(self.colors))?; Ok(()) } fn jump_to_vertical_center(&mut self) -> RenderResult { let current = self.current_rect(); let center_row = current.dimensions.rows / 2; let center_row = center_row.saturating_add(current.start_row); self.terminal.execute(&TerminalCommand::MoveToRow(center_row))?; Ok(()) } fn jump_to_row(&mut self, row: u16) -> RenderResult { // Make this relative to the beginning of the current rect. let row = self.current_rect().start_row.saturating_add(row); self.terminal.execute(&TerminalCommand::MoveToRow(row))?; Ok(()) } fn jump_to_bottom(&mut self, index: u16) -> RenderResult { let current = self.current_rect(); let target_row = current.dimensions.rows.saturating_sub(index).saturating_sub(1); let target_row = target_row.saturating_add(current.start_row); self.terminal.execute(&TerminalCommand::MoveToRow(target_row))?; Ok(()) } fn jump_to_column(&mut self, column: u16) -> RenderResult { // Make this relative to the beginning of the current rect. let column = self.current_rect().start_column.saturating_add(column); self.terminal.execute(&TerminalCommand::MoveToColumn(column))?; Ok(()) } fn render_text(&mut self, text: &WeightedLine, alignment: Alignment) -> RenderResult { let layout = self.build_layout(alignment); let dimensions = self.current_dimensions(); let positioning = layout.compute(dimensions, text.width() as u16); let prefix = "".into(); let text_drawer = TextDrawer::new(&prefix, 0, text, positioning, &self.colors, MINIMUM_LINE_LENGTH)?; let center_newlines = matches!(alignment, Alignment::Center { .. }); let text_drawer = text_drawer.center_newlines(center_newlines); text_drawer.draw(self.terminal)?; // Restore colors self.apply_colors() } fn render_line_break(&mut self) -> RenderResult { self.terminal.execute(&TerminalCommand::MoveToNextLine)?; Ok(()) } fn render_image(&mut self, image: &Image, properties: &ImageRenderProperties) -> RenderResult { let rect = self.current_rect().clone(); let starting_row = self.terminal.cursor_row(); let starting_cursor = CursorPosition { row: starting_row.saturating_sub(rect.start_row), column: rect.start_column }; let (width, height) = image.image().dimensions(); let (columns, rows) = match properties.size { ImageSize::ShrinkIfNeeded => { let image_scale = self.image_scaler.fit_image_to_rect(&rect.dimensions, width, height, &starting_cursor); (image_scale.columns, image_scale.rows) } ImageSize::Specific(columns, rows) => (columns, rows), ImageSize::WidthScaled { ratio } => { let extra_columns = (rect.dimensions.columns as f64 * (1.0 - ratio)).ceil() as u16; let dimensions = rect.dimensions.shrink_columns(extra_columns); let image_scale = self.image_scaler.scale_image(&dimensions, &rect.dimensions, width, height, &starting_cursor); (image_scale.columns, image_scale.rows) } }; let cursor = match &properties.position { ImagePosition::Cursor => starting_cursor.clone(), ImagePosition::Center => Self::center_cursor(columns, &rect.dimensions, &starting_cursor), ImagePosition::Right => Self::align_cursor_right(columns, &rect.dimensions, &starting_cursor), }; self.terminal.execute(&TerminalCommand::MoveToColumn(cursor.column))?; let options = PrintOptions { columns, rows, z_index: properties.z_index, column_width: rect.dimensions.pixels_per_column() as u16, row_height: rect.dimensions.pixels_per_row() as u16, background_color: properties.background_color, }; self.terminal.execute(&TerminalCommand::PrintImage { image: image.clone(), options })?; if properties.restore_cursor { self.terminal.execute(&TerminalCommand::MoveTo { column: starting_cursor.column, row: starting_row })?; } else { self.terminal.execute(&TerminalCommand::MoveToRow(starting_row + rows))?; } self.apply_colors() } fn center_cursor(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition { let start_column = window.columns / 2 - (columns / 2); let start_column = start_column + cursor.column; CursorPosition { row: cursor.row, column: start_column } } fn align_cursor_right(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition { let start_column = window.columns.saturating_sub(columns).saturating_add(cursor.column); CursorPosition { row: cursor.row, column: start_column } } fn render_block_line(&mut self, operation: &BlockLine) -> RenderResult { let BlockLine { text, block_length, alignment, block_color, prefix, right_padding_length, repeat_prefix_on_wrap, } = operation; let layout = self.build_layout(*alignment).with_font_size(text.font_size()); let dimensions = self.current_dimensions(); let positioning = layout.compute(dimensions, *block_length); if self.options.validate_overflows && text.width() as u16 > positioning.max_line_length { return Err(RenderError::HorizontalOverflow); } self.terminal.execute(&TerminalCommand::MoveToColumn(positioning.start_column))?; let text_drawer = TextDrawer::new(prefix, *right_padding_length, text, positioning, &self.colors, MINIMUM_LINE_LENGTH)? .with_surrounding_block(*block_color) .repeat_prefix_on_wrap(*repeat_prefix_on_wrap); text_drawer.draw(self.terminal)?; // Restore colors self.apply_colors()?; Ok(()) } fn render_dynamic(&mut self, generator: &dyn AsRenderOperations) -> RenderResult { let operations = generator.as_render_operations(self.current_dimensions()); for operation in operations { self.render_one(&operation)?; } Ok(()) } fn render_async(&mut self, generator: &dyn RenderAsync) -> RenderResult { let operations = generator.as_render_operations(self.current_dimensions()); for operation in operations { self.render_one(&operation)?; } Ok(()) } fn init_column_layout(&mut self, columns: &[u8]) -> RenderResult { if !matches!(self.layout, LayoutState::Default) { self.exit_layout()?; } let columns = columns .iter() .map(|width| Column { width: *width as u16, current_row: self.terminal.cursor_row() }) .collect(); self.layout = LayoutState::InitializedColumn { columns }; Ok(()) } fn enter_column(&mut self, column_index: usize) -> RenderResult { let columns = match mem::take(&mut self.layout) { LayoutState::Default => return Err(RenderError::InvalidLayoutEnter), LayoutState::InitializedColumn { columns, .. } | LayoutState::EnteredColumn { columns, .. } if column_index >= columns.len() => { return Err(RenderError::InvalidLayoutEnter); } LayoutState::InitializedColumn { columns } => columns, LayoutState::EnteredColumn { columns, .. } => { // Pop this one and start clean self.pop_margin()?; columns } }; let total_column_units: u16 = columns.iter().map(|c| c.width).sum(); let column_units_before: u16 = columns.iter().take(column_index).map(|c| c.width).sum(); let current_rect = self.current_rect(); let unit_width = current_rect.dimensions.columns as f64 / total_column_units as f64; let start_column = current_rect.start_column + (unit_width * column_units_before as f64) as u16; let start_row = columns[column_index].current_row; let new_column_count = (total_column_units - columns[column_index].width) * unit_width as u16; let new_size = current_rect .dimensions .shrink_columns(new_column_count) .shrink_rows(start_row.saturating_sub(current_rect.start_row)); let mut dimensions = WindowRect { dimensions: new_size, start_column, start_row }; // Shrink every column's right edge except for last if column_index < columns.len() - 1 { dimensions = dimensions.shrink_right(self.options.column_layout_margin); } // Shrink every column's left edge except for first if column_index > 0 { dimensions = dimensions.shrink_left(self.options.column_layout_margin); } self.window_rects.push(dimensions); self.terminal.execute(&TerminalCommand::MoveToRow(start_row))?; self.layout = LayoutState::EnteredColumn { column: column_index, columns }; Ok(()) } fn exit_layout(&mut self) -> RenderResult { match &self.layout { LayoutState::Default | LayoutState::InitializedColumn { .. } => Ok(()), LayoutState::EnteredColumn { .. } => { self.terminal.execute(&TerminalCommand::MoveTo { column: 0, row: self.max_modified_row })?; self.layout = LayoutState::Default; self.pop_margin()?; Ok(()) } } } fn build_layout(&self, alignment: Alignment) -> Layout { Layout::new(alignment).with_start_column(self.current_rect().start_column) } } #[derive(Default)] enum LayoutState { #[default] Default, InitializedColumn { columns: Vec, }, EnteredColumn { column: usize, columns: Vec, }, } struct Column { width: u16, current_row: u16, } #[derive(Clone, Debug)] struct WindowRect { dimensions: WindowSize, start_column: u16, start_row: u16, } impl WindowRect { fn shrink_horizontal(&self, margin: u16) -> Self { let dimensions = self.dimensions.shrink_columns(margin.saturating_mul(2)); let start_column = self.start_column + margin; Self { dimensions, start_column, start_row: self.start_row } } fn shrink_left(&self, size: u16) -> Self { let dimensions = self.dimensions.shrink_columns(size); let start_column = self.start_column.saturating_add(size); Self { dimensions, start_column, start_row: self.start_row } } fn shrink_right(&self, size: u16) -> Self { let dimensions = self.dimensions.shrink_columns(size); Self { dimensions, start_column: self.start_column, start_row: self.start_row } } fn shrink_top(&self, rows: u16) -> Self { let dimensions = self.dimensions.shrink_rows(rows); let start_row = self.start_row.saturating_add(rows); Self { dimensions, start_column: self.start_column, start_row } } fn shrink_bottom(&self, rows: u16) -> Self { let dimensions = self.dimensions.shrink_rows(rows); Self { dimensions, start_column: self.start_column, start_row: self.start_row } } } #[cfg(test)] mod tests { use super::*; use crate::{ markdown::text_style::{Color, TextStyle}, terminal::{ image::{ ImageSource, printer::{PrintImageError, TerminalImage}, scale::TerminalRect, }, printer::TerminalError, }, theme::Margin, }; use ::image::{ColorType, DynamicImage}; use rstest::rstest; use std::io; use unicode_width::UnicodeWidthStr; #[derive(Debug, PartialEq)] enum Instruction { MoveTo(u16, u16), MoveToRow(u16), MoveToColumn(u16), MoveDown(u16), MoveRight(u16), MoveLeft(u16), MoveToNextLine, PrintText(String), ClearScreen, SetBackgroundColor(Color), PrintImage(PrintOptions), } #[derive(Default)] struct TerminalBuf { instructions: Vec, cursor_row: u16, } impl TerminalBuf { fn push(&mut self, instruction: Instruction) -> io::Result<()> { self.instructions.push(instruction); Ok(()) } fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> { self.cursor_row = row; self.push(Instruction::MoveTo(column, row)) } fn move_to_row(&mut self, row: u16) -> io::Result<()> { self.cursor_row = row; self.push(Instruction::MoveToRow(row)) } fn move_to_column(&mut self, column: u16) -> io::Result<()> { self.push(Instruction::MoveToColumn(column)) } fn move_down(&mut self, amount: u16) -> io::Result<()> { self.push(Instruction::MoveDown(amount)) } fn move_right(&mut self, amount: u16) -> io::Result<()> { self.push(Instruction::MoveRight(amount)) } fn move_left(&mut self, amount: u16) -> io::Result<()> { self.push(Instruction::MoveLeft(amount)) } fn move_to_next_line(&mut self) -> io::Result<()> { self.push(Instruction::MoveToNextLine) } fn print_text(&mut self, content: &str, _style: &TextStyle) -> io::Result<()> { let content = content.to_string(); if content.is_empty() { return Ok(()); } self.cursor_row = content.width() as u16; self.push(Instruction::PrintText(content)) } fn clear_screen(&mut self) -> io::Result<()> { self.cursor_row = 0; self.push(Instruction::ClearScreen) } fn set_colors(&mut self, _colors: Colors) -> io::Result<()> { Ok(()) } fn set_background_color(&mut self, color: Color) -> io::Result<()> { self.push(Instruction::SetBackgroundColor(color)) } fn flush(&mut self) -> io::Result<()> { Ok(()) } fn print_image(&mut self, _image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> { let _ = self.push(Instruction::PrintImage(options.clone())); Ok(()) } } impl TerminalIo for TerminalBuf { fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> { use TerminalCommand::*; match command { BeginUpdate => (), EndUpdate => (), MoveTo { column, row } => self.move_to(*column, *row)?, MoveToRow(row) => self.move_to_row(*row)?, MoveToColumn(column) => self.move_to_column(*column)?, MoveDown(amount) => self.move_down(*amount)?, MoveRight(amount) => self.move_right(*amount)?, MoveLeft(amount) => self.move_left(*amount)?, MoveToNextLine => self.move_to_next_line()?, PrintText { content, style } => self.print_text(content, style)?, ClearScreen => self.clear_screen()?, SetColors(colors) => self.set_colors(*colors)?, SetBackgroundColor(color) => self.set_background_color(*color)?, Flush => self.flush()?, PrintImage { image, options } => self.print_image(image, options)?, SetCursorBoundaries { .. } => (), }; Ok(()) } fn cursor_row(&self) -> u16 { self.cursor_row } } struct DummyImageScaler; impl ScaleImage for DummyImageScaler { fn scale_image( &self, _scale_size: &WindowSize, _window_dimensions: &WindowSize, image_width: u32, image_height: u32, _position: &CursorPosition, ) -> TerminalRect { TerminalRect { rows: image_width as u16, columns: image_height as u16 } } fn fit_image_to_rect( &self, _dimensions: &WindowSize, image_width: u32, image_height: u32, _position: &CursorPosition, ) -> TerminalRect { TerminalRect { rows: image_width as u16, columns: image_height as u16 } } } fn do_render(max_size: MaxSize, operations: &[RenderOperation]) -> Vec { let mut buf = TerminalBuf::default(); let dimensions = WindowSize { rows: 100, columns: 100, height: 200, width: 200 }; let options = RenderEngineOptions { validate_overflows: false, max_size, column_layout_margin: 0 }; let mut engine = RenderEngine::new(&mut buf, dimensions, options); engine.image_scaler = Box::new(DummyImageScaler); engine.render(operations.iter()).expect("render failed"); buf.instructions } fn render(operations: &[RenderOperation]) -> Vec { do_render(Default::default(), operations) } fn render_with_max_size(operations: &[RenderOperation]) -> Vec { let max_size = MaxSize { max_rows: 10, max_rows_alignment: MaxRowsAlignment::Center, max_columns: 20, max_columns_alignment: MaxColumnsAlignment::Center, }; do_render(max_size, operations) } #[test] fn columns() { let ops = render(&[ RenderOperation::InitColumnLayout { columns: vec![1, 1] }, // print on column 0 RenderOperation::EnterColumn { column: 0 }, RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, // print on column 1 RenderOperation::EnterColumn { column: 1 }, RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, // go back to column 0 and print RenderOperation::EnterColumn { column: 0 }, RenderOperation::RenderText { line: "1".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, ]); let expected = [ Instruction::MoveToRow(0), Instruction::MoveToColumn(0), Instruction::PrintText("A".into()), Instruction::MoveToRow(0), Instruction::MoveToColumn(50), Instruction::PrintText("B".into()), // when we go back we should proceed from where we left off (row == 1) Instruction::MoveToRow(1), Instruction::MoveToColumn(0), Instruction::PrintText("1".into()), ]; assert_eq!(ops, expected); } #[test] fn bottom_margin() { let ops = render(&[ RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }), RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, RenderOperation::JumpToBottomRow { index: 0 }, RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, ]); let expected = [ Instruction::MoveToColumn(1), Instruction::PrintText("A".into()), // 100 - 10 (bottom margin) Instruction::MoveToRow(89), Instruction::MoveToColumn(1), Instruction::PrintText("B".into()), ]; assert_eq!(ops, expected); } #[test] fn top_margin() { let ops = render(&[ RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 3, bottom: 0 }), RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, ]); let expected = [Instruction::MoveToRow(3), Instruction::MoveToColumn(1), Instruction::PrintText("A".into())]; assert_eq!(ops, expected); } #[test] fn margins() { let ops = render(&[ RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 3, bottom: 10 }), RenderOperation::JumpToRow { index: 0 }, RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, RenderOperation::JumpToBottomRow { index: 0 }, RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, ]); let expected = [ Instruction::MoveToRow(3), Instruction::MoveToRow(3), Instruction::MoveToColumn(1), Instruction::PrintText("A".into()), // 100 - 10 (bottom margin) Instruction::MoveToRow(89), Instruction::MoveToColumn(1), Instruction::PrintText("B".into()), ]; assert_eq!(ops, expected); } #[test] fn nested_margins() { let ops = render(&[ RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }), RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }), RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, RenderOperation::JumpToBottomRow { index: 0 }, RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, // pop and go to bottom, this should go back up to the end of the first margin RenderOperation::PopMargin, RenderOperation::JumpToBottomRow { index: 0 }, RenderOperation::RenderText { line: "C".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, ]); let expected = [ Instruction::MoveToColumn(2), Instruction::PrintText("A".into()), // 100 - 10 (margin) - 10 (second margin) Instruction::MoveToRow(79), Instruction::MoveToColumn(2), Instruction::PrintText("B".into()), // 100 - 10 (margin) Instruction::MoveToRow(89), Instruction::MoveToColumn(1), Instruction::PrintText("C".into()), ]; assert_eq!(ops, expected); } #[test] fn margin_with_max_size() { let ops = render_with_max_size(&[ RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 2, bottom: 1 }), RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, RenderOperation::JumpToBottomRow { index: 0 }, RenderOperation::RenderText { line: "C".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, ]); let expected = [ // centered 20x10 Instruction::MoveTo(40, 45), Instruction::MoveToColumn(40), Instruction::PrintText("A".into()), // jump 2 down because of top margin Instruction::MoveToRow(47), // jump 1 right because of horizontal margin Instruction::MoveToColumn(41), Instruction::PrintText("B".into()), // rows go from 47 to 53 (7 total) Instruction::MoveToRow(53), Instruction::MoveToColumn(41), Instruction::PrintText("C".into()), ]; assert_eq!(ops, expected); } // print the same 2x2 image with all size configs, they should all yield the same #[rstest] #[case::shrink(ImageSize::ShrinkIfNeeded)] #[case::specific(ImageSize::Specific(2, 2))] #[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })] fn image(#[case] size: ImageSize) { let image = DynamicImage::new(2, 2, ColorType::Rgba8); let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated); let properties = ImageRenderProperties { z_index: 0, size, restore_cursor: false, background_color: None, position: ImagePosition::Cursor, }; let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]); let expected = [ // centered 20x10, the image is 2x2 so we stand one away from center Instruction::MoveTo(40, 45), Instruction::MoveToColumn(40), Instruction::PrintImage(PrintOptions { columns: 2, rows: 2, z_index: 0, background_color: None, column_width: 2, row_height: 2, }), // place cursor after the image Instruction::MoveToRow(47), ]; assert_eq!(ops, expected); } // same as the above but center it #[rstest] #[case::shrink(ImageSize::ShrinkIfNeeded)] #[case::specific(ImageSize::Specific(2, 2))] #[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })] fn centered_image(#[case] size: ImageSize) { let image = DynamicImage::new(2, 2, ColorType::Rgba8); let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated); let properties = ImageRenderProperties { z_index: 0, size, restore_cursor: false, background_color: None, position: ImagePosition::Center, }; let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]); let expected = [ // centered 20x10, the image is 2x2 so we stand one away from center Instruction::MoveTo(40, 45), Instruction::MoveToColumn(49), Instruction::PrintImage(PrintOptions { columns: 2, rows: 2, z_index: 0, background_color: None, column_width: 2, row_height: 2, }), // place cursor after the image Instruction::MoveToRow(47), ]; assert_eq!(ops, expected); } // same as the above but use right alignment #[rstest] #[case::shrink(ImageSize::ShrinkIfNeeded)] #[case::specific(ImageSize::Specific(2, 2))] #[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })] fn right_aligned_image(#[case] size: ImageSize) { let image = DynamicImage::new(2, 2, ColorType::Rgba8); let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated); let properties = ImageRenderProperties { z_index: 0, size, restore_cursor: false, background_color: None, position: ImagePosition::Right, }; let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]); let expected = [ // right aligned 20x10, the image is 2x2 so we stand one away from the right Instruction::MoveTo(40, 45), Instruction::MoveToColumn(58), Instruction::PrintImage(PrintOptions { columns: 2, rows: 2, z_index: 0, background_color: None, column_width: 2, row_height: 2, }), // place cursor after the image Instruction::MoveToRow(47), ]; assert_eq!(ops, expected); } // same as the above but center it #[rstest] fn restore_cursor_after_image() { let image = DynamicImage::new(2, 2, ColorType::Rgba8); let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated); let properties = ImageRenderProperties { z_index: 0, size: ImageSize::ShrinkIfNeeded, restore_cursor: true, background_color: None, position: ImagePosition::Center, }; let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]); let expected = [ // centered 20x10, the image is 2x2 so we stand one away from center Instruction::MoveTo(40, 45), Instruction::MoveToColumn(49), Instruction::PrintImage(PrintOptions { columns: 2, rows: 2, z_index: 0, background_color: None, column_width: 2, row_height: 2, }), // place cursor after the image Instruction::MoveTo(40, 45), ]; assert_eq!(ops, expected); } } presenterm-0.15.1/src/render/layout.rs000064400000000000000000000150261046102023000160310ustar 00000000000000use crate::{render::properties::WindowSize, theme::Alignment}; #[derive(Debug)] pub(crate) struct Layout { alignment: Alignment, start_column_offset: u16, font_size: u16, } impl Layout { pub(crate) fn new(alignment: Alignment) -> Self { Self { alignment, start_column_offset: 0, font_size: 1 } } pub(crate) fn with_start_column(mut self, column: u16) -> Self { self.start_column_offset = column; self } pub(crate) fn with_font_size(mut self, font_size: u8) -> Self { self.font_size = font_size as u16; self } pub(crate) fn compute(&self, dimensions: &WindowSize, text_length: u16) -> Positioning { let text_length = text_length * self.font_size; let max_line_length; let mut start_column; match &self.alignment { Alignment::Left { margin } => { let margin = margin.as_characters(dimensions.columns); // Ignore the margin if it's larger than the screen: we can't satisfy it so we // might as well not do anything about it. let margin = Self::fit_to_columns(dimensions, margin.saturating_mul(2), margin); start_column = margin; max_line_length = dimensions.columns - margin.saturating_mul(2); } Alignment::Right { margin } => { let margin = margin.as_characters(dimensions.columns); let margin = Self::fit_to_columns(dimensions, margin.saturating_mul(2), margin); start_column = dimensions.columns.saturating_sub(margin).saturating_sub(text_length).max(margin); max_line_length = (dimensions.columns - margin) - start_column; } Alignment::Center { minimum_margin, minimum_size } => { let minimum_margin = minimum_margin.as_characters(dimensions.columns); // Respect minimum size as much as we can if both together overflow. let minimum_size = dimensions.columns.min(*minimum_size); let minimum_margin = Self::fit_to_columns( dimensions, minimum_margin.saturating_mul(2).saturating_add(minimum_size), minimum_margin, ); max_line_length = text_length.min(dimensions.columns - minimum_margin.saturating_mul(2)).max(minimum_size); if max_line_length > dimensions.columns { start_column = minimum_margin; } else { start_column = (dimensions.columns - max_line_length) / 2; start_column = start_column.max(minimum_margin); } } }; start_column += self.start_column_offset; Positioning { max_line_length, start_column } } fn fit_to_columns(dimensions: &WindowSize, required_fit: u16, actual_fit: u16) -> u16 { if required_fit > dimensions.columns { 0 } else { actual_fit } } } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Positioning { pub(crate) max_line_length: u16, pub(crate) start_column: u16, } #[cfg(test)] mod test { use super::*; use crate::theme::Margin; use rstest::rstest; #[rstest] #[case::left_no_margin( Alignment::Left{ margin: Margin::Fixed(0) }, 10, Positioning{ max_line_length: 100, start_column: 0 } )] #[case::left_some_margin( Alignment::Left{ margin: Margin::Fixed(5) }, 10, Positioning{ max_line_length: 90, start_column: 5 } )] #[case::left_line_overflows( Alignment::Left{ margin: Margin::Fixed(5) }, 150, Positioning{ max_line_length: 90, start_column: 5 } )] #[case::left_large_margin( Alignment::Left{ margin: Margin::Fixed(60) }, 10, Positioning{ max_line_length: 100, start_column: 0 } )] #[case::left_margin_too_large( Alignment::Left{ margin: Margin::Fixed(105) }, 10, Positioning{ max_line_length: 100, start_column: 0 } )] #[case::right_no_margin( Alignment::Right{ margin: Margin::Fixed(0) }, 10, Positioning{ max_line_length: 10, start_column: 90 } )] #[case::right_some_margin( Alignment::Right{ margin: Margin::Fixed(5) }, 10, Positioning{ max_line_length: 10, start_column: 85 } )] #[case::right_line_overflows( Alignment::Right{ margin: Margin::Fixed(5) }, 150, Positioning{ max_line_length: 90, start_column: 5 } )] #[case::right_large_margin( Alignment::Right{ margin: Margin::Fixed(60) }, 10, Positioning{ max_line_length: 10, start_column: 90 } )] #[case::right_margin_too_large( Alignment::Right{ margin: Margin::Fixed(105) }, 10, Positioning{ max_line_length: 10, start_column: 90 } )] #[case::center_no_minimums( Alignment::Center{ minimum_margin: Margin::Fixed(0), minimum_size: 0 }, 10, Positioning{ max_line_length: 10, start_column: 45 } )] #[case::center_minimum_margin( Alignment::Center{ minimum_margin: Margin::Fixed(10), minimum_size: 0 }, 100, Positioning{ max_line_length: 80, start_column: 10 } )] #[case::center_minimum_size( Alignment::Center{ minimum_margin: Margin::Fixed(0), minimum_size: 50 }, 10, Positioning{ max_line_length: 50, start_column: 25 } )] #[case::center_large_minimum_margin( Alignment::Center{ minimum_margin: Margin::Fixed(60), minimum_size: 0 }, 10, Positioning{ max_line_length: 10, start_column: 45 } )] #[case::center_minimum_margin_too_large( Alignment::Center{ minimum_margin: Margin::Fixed(105), minimum_size: 0 }, 10, Positioning{ max_line_length: 10, start_column: 45 } )] #[case::center_minimum_size_too_large( Alignment::Center{ minimum_margin: Margin::Fixed(0), minimum_size: 105 }, 10, Positioning{ max_line_length: 100, start_column: 0 } )] #[case::center_margin_and_size_overflows( Alignment::Center{ minimum_margin: Margin::Fixed(30), minimum_size: 60 }, 10, Positioning{ max_line_length: 60, start_column: 20 } )] fn layout(#[case] alignment: Alignment, #[case] length: u16, #[case] expected: Positioning) { let dimensions = WindowSize { rows: 0, columns: 100, width: 0, height: 0 }; let positioning = Layout::new(alignment).compute(&dimensions, length); assert_eq!(positioning, expected); } } presenterm-0.15.1/src/render/mod.rs000064400000000000000000000127511046102023000152750ustar 00000000000000pub(crate) mod ascii_scaler; pub(crate) mod engine; pub(crate) mod layout; pub(crate) mod operation; pub(crate) mod properties; pub(crate) mod text; pub(crate) mod validate; use crate::{ markdown::{ elements::Text, text::WeightedLine, text_style::{Color, Colors, PaletteColorError, TextStyle}, }, render::{operation::RenderOperation, properties::WindowSize}, terminal::{ Terminal, ansi::AnsiParser, image::printer::{ImagePrinter, PrintImageError}, printer::TerminalError, }, theme::Margin, }; use engine::{MaxSize, RenderEngine, RenderEngineOptions}; use operation::{AsRenderOperations, MarginProperties}; use std::{ io::{self, Stdout}, iter, rc::Rc, sync::Arc, }; /// The result of a render operation. pub(crate) type RenderResult = Result<(), RenderError>; pub(crate) struct TerminalDrawerOptions { pub(crate) font_size_fallback: u8, pub(crate) max_size: MaxSize, } impl Default for TerminalDrawerOptions { fn default() -> Self { Self { font_size_fallback: 1, max_size: Default::default() } } } /// Allows drawing on the terminal. pub(crate) struct TerminalDrawer { pub(crate) terminal: Terminal, options: TerminalDrawerOptions, } impl TerminalDrawer { pub(crate) fn new(image_printer: Arc, options: TerminalDrawerOptions) -> io::Result { let terminal = Terminal::new(io::stdout(), image_printer)?; Ok(Self { terminal, options }) } pub(crate) fn render_operations<'a>( &mut self, operations: impl Iterator, ) -> RenderResult { let dimensions = WindowSize::current(self.options.font_size_fallback)?; let engine = self.create_engine(dimensions); engine.render(operations)?; Ok(()) } pub(crate) fn render_error(&mut self, message: &str, source: &ErrorSource) -> RenderResult { let (lines, _) = AnsiParser::new(Default::default()).parse_lines(message.lines()); let lines = lines.into_iter().map(Into::into).collect(); let operation = RenderErrorOperation { lines, source: source.clone() }; let operation = RenderOperation::RenderDynamic(Rc::new(operation)); let dimensions = WindowSize::current(self.options.font_size_fallback)?; let engine = self.create_engine(dimensions); engine.render(iter::once(&operation))?; Ok(()) } pub(crate) fn render_engine_options(&self) -> RenderEngineOptions { RenderEngineOptions { max_size: self.options.max_size.clone(), ..Default::default() } } fn create_engine(&mut self, dimensions: WindowSize) -> RenderEngine> { let options = self.render_engine_options(); RenderEngine::new(&mut self.terminal, dimensions, options) } } /// A rendering error. #[derive(thiserror::Error, Debug)] pub(crate) enum RenderError { #[error("io: {0}")] Io(#[from] io::Error), #[error("terminal: {0}")] Terminal(#[from] TerminalError), #[error("screen is too small")] TerminalTooSmall, #[error("tried to move to non existent layout location")] InvalidLayoutEnter, #[error("tried to pop default screen")] PopDefaultScreen, #[error("printing image: {0}")] PrintImage(#[from] PrintImageError), #[error("horizontal overflow")] HorizontalOverflow, #[error("vertical overflow")] VerticalOverflow, #[error(transparent)] PaletteColor(#[from] PaletteColorError), } #[derive(Clone, Debug)] pub(crate) enum ErrorSource { Presentation, Slide(usize), } #[derive(Debug)] struct RenderErrorOperation { lines: Vec, source: ErrorSource, } impl AsRenderOperations for RenderErrorOperation { fn as_render_operations(&self, dimensions: &WindowSize) -> Vec { let heading_text = match self.source { ErrorSource::Presentation => "Error loading presentation".to_string(), ErrorSource::Slide(slide) => { format!("Error in slide {slide}") } }; let heading = vec![Text::new(heading_text, TextStyle::default().bold().fg_color(Color::Red)), Text::from(": ")]; let content_width: u16 = self.lines.iter().map(|l| l.width()).max().unwrap_or_default().try_into().unwrap_or(u16::MAX); let minimum_margin = (dimensions.columns as f32 * 0.1) as u16; let margin = dimensions.columns.saturating_sub(content_width).max(minimum_margin) / 2; let total_lines = self.lines.len(); let starting_row = (dimensions.rows / 2).saturating_sub(total_lines as u16 / 2 + 3); let mut operations = vec![ RenderOperation::SetColors(Colors { background: Some(Color::Rgb { r: 0, g: 0, b: 0 }), foreground: Some(Color::White), }), RenderOperation::ClearScreen, RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(margin), top: starting_row, bottom: 0, }), RenderOperation::RenderText { line: WeightedLine::from(heading), alignment: Default::default() }, RenderOperation::RenderLineBreak, RenderOperation::RenderLineBreak, ]; for line in self.lines.iter().cloned() { let op = RenderOperation::RenderText { line, alignment: Default::default() }; operations.extend([op, RenderOperation::RenderLineBreak]); } operations } } presenterm-0.15.1/src/render/operation.rs000064400000000000000000000143121046102023000165110ustar 00000000000000use super::properties::WindowSize; use crate::{ markdown::{ text::{WeightedLine, WeightedText}, text_style::{Color, Colors}, }, terminal::image::Image, theme::{Alignment, Margin}, }; use std::{ fmt::Debug, rc::Rc, sync::{Arc, Mutex}, }; const DEFAULT_IMAGE_Z_INDEX: i32 = -2; /// A line of preformatted text to be rendered. #[derive(Clone, Debug, PartialEq)] pub(crate) struct BlockLine { pub(crate) prefix: WeightedText, pub(crate) right_padding_length: u16, pub(crate) repeat_prefix_on_wrap: bool, pub(crate) text: WeightedLine, pub(crate) block_length: u16, pub(crate) block_color: Option, pub(crate) alignment: Alignment, } /// A render operation. /// /// Render operations are primitives that allow the input markdown file to be decoupled with what /// we draw on the screen. #[derive(Clone, Debug)] pub(crate) enum RenderOperation { /// Clear the entire screen. ClearScreen, /// Set the colors to be used for any subsequent operations. SetColors(Colors), /// Jump the draw cursor into the vertical center, that is, at `screen_height / 2`. JumpToVerticalCenter, /// Jumps to the N-th row in the current layout. /// /// The index is zero based where 0 represents the top row. JumpToRow { index: u16 }, /// Jumps to the N-th to last row in the current layout. /// /// The index is zero based where 0 represents the bottom row. JumpToBottomRow { index: u16 }, /// Jump to the N-th column in the current layout. JumpToColumn { index: u16 }, /// Render text. RenderText { line: WeightedLine, alignment: Alignment }, /// Render a line break. RenderLineBreak, /// Render an image. RenderImage(Image, ImageRenderProperties), /// Render a line. RenderBlockLine(BlockLine), /// Render a dynamically generated sequence of render operations. /// /// This allows drawing something on the screen that requires knowing dynamic properties of the /// screen, like window size, without coupling the transformation of markdown into /// [RenderOperation] with the screen itself. RenderDynamic(Rc), /// An operation that is rendered asynchronously. RenderAsync(Rc), /// Initialize a column layout. /// /// The value for each column is the width of the column in column-unit units, where the entire /// screen contains `columns.sum()` column-units. InitColumnLayout { columns: Vec }, /// Enter a column in a column layout. /// /// The index is 0-index based and will be tied to a previous `InitColumnLayout` operation. EnterColumn { column: usize }, /// Exit the current layout and go back to the default one. ExitLayout, /// Apply a margin to every following operation. ApplyMargin(MarginProperties), /// Pop an `ApplyMargin` operation. PopMargin, } /// The properties of an image being rendered. #[derive(Clone, Debug, PartialEq)] pub(crate) struct ImageRenderProperties { pub(crate) z_index: i32, pub(crate) size: ImageSize, pub(crate) restore_cursor: bool, pub(crate) background_color: Option, pub(crate) position: ImagePosition, } impl Default for ImageRenderProperties { fn default() -> Self { Self { z_index: DEFAULT_IMAGE_Z_INDEX, size: Default::default(), restore_cursor: false, background_color: None, position: ImagePosition::Center, } } } #[derive(Clone, Debug, PartialEq)] pub(crate) enum ImagePosition { Cursor, Center, Right, } /// The size used when printing an image. #[derive(Clone, Debug, Default, PartialEq)] pub(crate) enum ImageSize { #[default] ShrinkIfNeeded, Specific(u16, u16), WidthScaled { ratio: f64, }, } /// Slide properties, set on initialization. #[derive(Clone, Debug, Default)] pub(crate) struct MarginProperties { /// The horizontal margin. pub(crate) horizontal: Margin, /// The margin at the top. pub(crate) top: u16, /// The margin at the bottom. pub(crate) bottom: u16, } /// A type that can generate render operations. pub(crate) trait AsRenderOperations: Debug + 'static { /// Generate render operations. fn as_render_operations(&self, dimensions: &WindowSize) -> Vec; /// Get the content in this type to diff it against another `AsRenderOperations`. fn diffable_content(&self) -> Option<&str> { None } } /// An operation that can be rendered asynchronously. pub(crate) trait RenderAsync: AsRenderOperations { /// Create a pollable for this render async. /// /// The pollable will be used to poll this by a separate thread, so all state that will /// be loaded asynchronously should be shared between this operation and any pollables /// generated from it. fn pollable(&self) -> Box; /// Get the start policy for this render. fn start_policy(&self) -> RenderAsyncStartPolicy { RenderAsyncStartPolicy::OnDemand } } /// The start policy for an async render. #[derive(Copy, Clone, Debug)] pub(crate) enum RenderAsyncStartPolicy { /// Start automatically. Automatic, /// Start on demand. OnDemand, } /// A pollable that can be used to pull and update the state of an operation asynchronously. pub(crate) trait Pollable: Send + 'static { /// Update the internal state and return the updated state. fn poll(&mut self) -> PollableState; } /// The state of a [Pollable]. #[derive(Clone, Debug, PartialEq)] pub(crate) enum PollableState { Unmodified, Modified, Done, Failed { error: String }, } impl PollableState { #[cfg(test)] pub(crate) fn is_completed(&self) -> bool { match self { Self::Unmodified | Self::Modified => false, Self::Done | Self::Failed { .. } => true, } } } pub(crate) struct ToggleState { toggled: Arc>, } impl ToggleState { pub(crate) fn new(toggled: Arc>) -> Self { Self { toggled } } } impl Pollable for ToggleState { fn poll(&mut self) -> PollableState { *self.toggled.lock().unwrap() = true; PollableState::Done } } presenterm-0.15.1/src/render/properties.rs000064400000000000000000000074721046102023000167160ustar 00000000000000use crossterm::terminal; use std::io::{self, ErrorKind}; /// The size of the terminal window. /// /// This is the same as [crossterm::terminal::window_size] except with some added functionality, /// like implementing `Clone`. #[derive(Debug, Clone, Copy)] pub(crate) struct WindowSize { pub(crate) rows: u16, pub(crate) columns: u16, pub(crate) height: u16, pub(crate) width: u16, } impl WindowSize { /// Get the current window size. pub(crate) fn current(font_size_fallback: u8) -> io::Result { let mut size: Self = match terminal::window_size() { Ok(size) => size.into(), Err(e) if e.kind() == ErrorKind::Unsupported => { // Fall back to a `WindowSize` that doesn't have pixel support. let size = terminal::size()?; size.into() } Err(e) => return Err(e), }; let font_size_fallback = font_size_fallback as u16; if size.width == 0 { size.width = size.columns * font_size_fallback.max(1); } if size.height == 0 { size.height = size.rows * font_size_fallback.max(1) * 2; } Ok(size) } /// Shrink a window by the given number of rows. /// /// This preserves the relationship between rows and pixels. pub(crate) fn shrink_rows(&self, amount: u16) -> WindowSize { let pixels_per_row = self.pixels_per_row(); let height_to_shrink = (pixels_per_row * amount as f64) as u16; WindowSize { rows: self.rows.saturating_sub(amount), columns: self.columns, height: self.height.saturating_sub(height_to_shrink), width: self.width, } } /// Shrink a window by the given number of columns. /// /// This preserves the relationship between columns and pixels. pub(crate) fn shrink_columns(&self, amount: u16) -> WindowSize { let pixels_per_column = self.pixels_per_column(); let width_to_shrink = (pixels_per_column * amount as f64) as u16; WindowSize { rows: self.rows, columns: self.columns.saturating_sub(amount), height: self.height, width: self.width.saturating_sub(width_to_shrink), } } /// The number of pixels per column. pub(crate) fn pixels_per_column(&self) -> f64 { self.width as f64 / self.columns as f64 } /// The number of pixels per row. pub(crate) fn pixels_per_row(&self) -> f64 { self.height as f64 / self.rows as f64 } /// The aspect ratio for this size. pub(crate) fn aspect_ratio(&self) -> f64 { (self.rows as f64 / self.height as f64) / (self.columns as f64 / self.width as f64) } } impl From for WindowSize { fn from(size: crossterm::terminal::WindowSize) -> Self { Self { rows: size.rows, columns: size.columns, width: size.width, height: size.height } } } impl From<(u16, u16)> for WindowSize { fn from((columns, rows): (u16, u16)) -> Self { Self { columns, rows, width: 0, height: 0 } } } /// The cursor's position. #[derive(Debug, Clone, Default, PartialEq)] pub(crate) struct CursorPosition { pub(crate) column: u16, pub(crate) row: u16, } #[cfg(test)] mod test { use super::*; #[test] fn shrink() { let dimensions = WindowSize { rows: 10, columns: 10, width: 200, height: 100 }; assert_eq!(dimensions.pixels_per_column(), 20.0); assert_eq!(dimensions.pixels_per_row(), 10.0); let new_dimensions = dimensions.shrink_rows(3); assert_eq!(new_dimensions.rows, 7); assert_eq!(new_dimensions.height, 70); let new_dimensions = new_dimensions.shrink_columns(3); assert_eq!(new_dimensions.columns, 7); assert_eq!(new_dimensions.width, 140); } } presenterm-0.15.1/src/render/text.rs000064400000000000000000000334421046102023000155020ustar 00000000000000use crate::{ markdown::{ elements::Text, text::{WeightedLine, WeightedText}, text_style::{Color, Colors, TextStyle}, }, render::{RenderError, RenderResult, layout::Positioning}, terminal::printer::{TerminalCommand, TerminalIo}, }; /// Draws text on the screen. /// /// This deals with splitting words and doing word wrapping based on the given positioning. pub(crate) struct TextDrawer<'a> { prefix: &'a WeightedText, right_padding_length: u16, line: &'a WeightedLine, positioning: Positioning, prefix_width: u16, default_colors: &'a Colors, draw_block: bool, block_color: Option, repeat_prefix: bool, center_newlines: bool, } impl<'a> TextDrawer<'a> { pub(crate) fn new( prefix: &'a WeightedText, right_padding_length: u16, line: &'a WeightedLine, positioning: Positioning, default_colors: &'a Colors, minimum_line_length: u16, ) -> Result { let text_length = (line.width() + prefix.width() + right_padding_length as usize) as u16; // If our line doesn't fit and it's just too small then abort if text_length > positioning.max_line_length && positioning.max_line_length <= minimum_line_length { return Err(RenderError::TerminalTooSmall); } let prefix_width = prefix.width() as u16; let positioning = Positioning { max_line_length: positioning .max_line_length .saturating_sub(prefix_width) .saturating_sub(right_padding_length), start_column: positioning.start_column, }; Ok(Self { prefix, right_padding_length, line, positioning, prefix_width, default_colors, draw_block: false, block_color: None, repeat_prefix: false, center_newlines: false, }) } pub(crate) fn with_surrounding_block(mut self, block_color: Option) -> Self { self.draw_block = true; self.block_color = block_color; self } pub(crate) fn repeat_prefix_on_wrap(mut self, value: bool) -> Self { self.repeat_prefix = value; self } pub(crate) fn center_newlines(mut self, value: bool) -> Self { self.center_newlines = value; self } /// Draw text on the given handle. /// /// This performs word splitting and word wrapping. pub(crate) fn draw(self, terminal: &mut T) -> RenderResult where T: TerminalIo, { let mut line_length: u16 = 0; terminal.execute(&TerminalCommand::MoveToColumn(self.positioning.start_column))?; let font_size = self.line.font_size(); // Print the prefix at the beginning of the line. if self.prefix_width > 0 { let Text { content, style } = self.prefix.text(); terminal.execute(&TerminalCommand::PrintText { content, style: *style })?; } for (line_index, line) in self.line.split(self.positioning.max_line_length as usize).enumerate() { if line_index > 0 { // Complete the current line's block to the right before moving down. self.print_block_background(line_length, terminal)?; terminal.execute(&TerminalCommand::MoveDown(font_size as u16))?; let start_column = match self.center_newlines { true => { let line_width = line.iter().map(|l| l.width()).sum::() as u16; let extra_space = self.positioning.max_line_length.saturating_sub(line_width); self.positioning.start_column + extra_space / 2 } false => self.positioning.start_column, }; terminal.execute(&TerminalCommand::MoveToColumn(start_column))?; line_length = 0; // Complete the new line in this block to the left where the prefix would be. if self.prefix_width > 0 { if self.repeat_prefix { let Text { content, style } = self.prefix.text(); terminal.execute(&TerminalCommand::PrintText { content, style: *style })?; } else { if let Some(color) = self.block_color { terminal.execute(&TerminalCommand::SetBackgroundColor(color))?; } let text = " ".repeat(self.prefix_width as usize / font_size as usize); let style = TextStyle::default().size(font_size); terminal.execute(&TerminalCommand::PrintText { content: &text, style })?; } } } for chunk in line { line_length = line_length.saturating_add(chunk.width() as u16); let (text, style) = chunk.into_parts(); terminal.execute(&TerminalCommand::PrintText { content: text, style })?; // Crossterm resets colors if any attributes are set so let's just re-apply colors // if the format has anything on it at all. if style != Default::default() { terminal.execute(&TerminalCommand::SetColors(*self.default_colors))?; } } } self.print_block_background(line_length, terminal)?; Ok(()) } fn print_block_background(&self, line_length: u16, terminal: &mut T) -> RenderResult where T: TerminalIo, { if self.draw_block { let remaining = self.positioning.max_line_length.saturating_sub(line_length).saturating_add(self.right_padding_length); if remaining > 0 { let font_size = self.line.font_size(); if let Some(color) = self.block_color { terminal.execute(&TerminalCommand::SetBackgroundColor(color))?; } let text = " ".repeat(remaining as usize / font_size as usize); let style = TextStyle::default().size(font_size); terminal.execute(&TerminalCommand::PrintText { content: &text, style })?; } } Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::terminal::printer::TerminalError; use std::io; use unicode_width::UnicodeWidthStr; #[derive(Debug, PartialEq)] enum Instruction { MoveDown(u16), MoveToColumn(u16), PrintText { content: String, font_size: u8 }, } #[derive(Default)] struct TerminalBuf { instructions: Vec, cursor_row: u16, } impl TerminalBuf { fn push(&mut self, instruction: Instruction) -> io::Result<()> { self.instructions.push(instruction); Ok(()) } fn move_to_column(&mut self, column: u16) -> std::io::Result<()> { self.push(Instruction::MoveToColumn(column)) } fn move_down(&mut self, amount: u16) -> std::io::Result<()> { self.push(Instruction::MoveDown(amount)) } fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> { let content = content.to_string(); if content.is_empty() { return Ok(()); } self.cursor_row = content.width() as u16; self.push(Instruction::PrintText { content, font_size: style.size })?; Ok(()) } fn clear_screen(&mut self) -> std::io::Result<()> { unimplemented!() } fn set_colors(&mut self, _colors: Colors) -> std::io::Result<()> { Ok(()) } fn set_background_color(&mut self, _color: Color) -> std::io::Result<()> { Ok(()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } impl TerminalIo for TerminalBuf { fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> { use TerminalCommand::*; match command { BeginUpdate | EndUpdate | MoveToRow(_) | MoveToNextLine | MoveTo { .. } | MoveRight(_) | MoveLeft(_) | PrintImage { .. } | SetCursorBoundaries { .. } => { unimplemented!() } MoveToColumn(column) => self.move_to_column(*column)?, MoveDown(amount) => self.move_down(*amount)?, PrintText { content, style } => self.print_text(content, style)?, ClearScreen => self.clear_screen()?, SetColors(colors) => self.set_colors(*colors)?, SetBackgroundColor(color) => self.set_background_color(*color)?, Flush => self.flush()?, }; Ok(()) } fn cursor_row(&self) -> u16 { self.cursor_row } } struct TestDrawer { prefix: WeightedText, positioning: Positioning, right_padding_length: u16, repeat_prefix_on_wrap: bool, center_newlines: bool, } impl TestDrawer { fn prefix>(mut self, prefix: T) -> Self { self.prefix = prefix.into(); self } fn start_column(mut self, column: u16) -> Self { self.positioning.start_column = column; self } fn max_line_length(mut self, length: u16) -> Self { self.positioning.max_line_length = length; self } fn repeat_prefix_on_wrap(mut self) -> Self { self.repeat_prefix_on_wrap = true; self } fn center_newlines(mut self) -> Self { self.center_newlines = true; self } fn draw>(self, line: L) -> Vec { let line = line.into(); let colors = Default::default(); let drawer = TextDrawer::new(&self.prefix, self.right_padding_length, &line, self.positioning, &colors, 0) .expect("failed to create drawer") .repeat_prefix_on_wrap(self.repeat_prefix_on_wrap) .center_newlines(self.center_newlines); let mut buf = TerminalBuf::default(); drawer.draw(&mut buf).expect("drawing failed"); buf.instructions } } impl Default for TestDrawer { fn default() -> Self { Self { prefix: WeightedText::from(""), positioning: Positioning { max_line_length: 100, start_column: 0 }, right_padding_length: 0, repeat_prefix_on_wrap: false, center_newlines: false, } } } #[test] fn prefix_on_long_line() { let instructions = TestDrawer::default().prefix("P").max_line_length(3).start_column(1).draw("AAAA"); let expected = &[ Instruction::MoveToColumn(1), Instruction::PrintText { content: "P".into(), font_size: 1 }, Instruction::PrintText { content: "AA".into(), font_size: 1 }, Instruction::MoveDown(1), Instruction::MoveToColumn(1), Instruction::PrintText { content: " ".into(), font_size: 1 }, Instruction::PrintText { content: "AA".into(), font_size: 1 }, ]; assert_eq!(instructions, expected); } #[test] fn prefix_on_long_line_with_font_size() { let text = WeightedLine::from(vec![Text::new("AAAA", TextStyle::default().size(2))]); let prefix = WeightedText::from(Text::new("P", TextStyle::default().size(2))); let instructions = TestDrawer::default().prefix(prefix).max_line_length(6).start_column(1).draw(text); let expected = &[ Instruction::MoveToColumn(1), Instruction::PrintText { content: "P".into(), font_size: 2 }, Instruction::PrintText { content: "AA".into(), font_size: 2 }, Instruction::MoveDown(2), Instruction::MoveToColumn(1), Instruction::PrintText { content: " ".into(), font_size: 2 }, Instruction::PrintText { content: "AA".into(), font_size: 2 }, ]; assert_eq!(instructions, expected); } #[test] fn prefix_on_long_line_with_font_size_and_repeat_prefix() { let text = WeightedLine::from(vec![Text::new("AAAA", TextStyle::default().size(2))]); let prefix = WeightedText::from(Text::new("P", TextStyle::default().size(2))); let instructions = TestDrawer::default().prefix(prefix).max_line_length(6).start_column(1).repeat_prefix_on_wrap().draw(text); let expected = &[ Instruction::MoveToColumn(1), Instruction::PrintText { content: "P".into(), font_size: 2 }, Instruction::PrintText { content: "AA".into(), font_size: 2 }, Instruction::MoveDown(2), Instruction::MoveToColumn(1), Instruction::PrintText { content: "P".into(), font_size: 2 }, Instruction::PrintText { content: "AA".into(), font_size: 2 }, ]; assert_eq!(instructions, expected); } #[test] fn center_newlines() { let text = WeightedLine::from(vec![Text::from("hello world foo")]); let instructions = TestDrawer::default().center_newlines().max_line_length(11).draw(text); let expected = &[ Instruction::MoveToColumn(0), Instruction::PrintText { content: "hello world".into(), font_size: 1 }, Instruction::MoveDown(1), Instruction::MoveToColumn(4), Instruction::PrintText { content: "foo".into(), font_size: 1 }, ]; assert_eq!(instructions, expected); } } presenterm-0.15.1/src/render/validate.rs000064400000000000000000000033021046102023000162770ustar 00000000000000use super::properties::WindowSize; use crate::{ ImagePrinter, presentation::Presentation, render::{ RenderError, engine::{RenderEngine, RenderEngineOptions}, }, terminal::{Terminal, TerminalWrite}, }; use std::{io, sync::Arc}; pub(crate) struct OverflowValidator; impl OverflowValidator { pub(crate) fn validate(presentation: &Presentation, dimensions: WindowSize) -> Result<(), OverflowError> { let printer = Arc::new(ImagePrinter::Null); for (index, slide) in presentation.iter_slides().enumerate() { let index = index + 1; let mut terminal = Terminal::new(io::Empty::default(), printer.clone()).map_err(RenderError::from)?; let options = RenderEngineOptions { validate_overflows: true, ..Default::default() }; let engine = RenderEngine::new(&mut terminal, dimensions, options); match engine.render(slide.iter_visible_operations()) { Ok(()) => (), Err(RenderError::HorizontalOverflow) => return Err(OverflowError::Horizontal(index)), Err(RenderError::VerticalOverflow) => return Err(OverflowError::Vertical(index)), Err(e) => return Err(OverflowError::Render(e)), }; } Ok(()) } } impl TerminalWrite for io::Empty { fn init(&mut self) -> io::Result<()> { Ok(()) } fn deinit(&mut self) {} } #[derive(Debug, thiserror::Error)] pub(crate) enum OverflowError { #[error("presentation overflows horizontally on slide {0}")] Horizontal(usize), #[error("presentation overflows vertically on slide {0}")] Vertical(usize), #[error(transparent)] Render(#[from] RenderError), } presenterm-0.15.1/src/resource.rs000064400000000000000000000205451046102023000150660ustar 00000000000000use crate::{ terminal::image::{ Image, printer::{ImageRegistry, ImageSpec, RegisterImageError}, }, theme::{raw::PresentationTheme, registry::LoadThemeError}, }; use std::{ cell::RefCell, collections::HashMap, fs, io, mem, path::{Path, PathBuf}, rc::Rc, sync::{ Arc, atomic::{AtomicBool, Ordering}, mpsc::{Receiver, Sender, channel}, }, thread, time::{Duration, SystemTime}, }; const LOOP_INTERVAL: Duration = Duration::from_millis(250); #[derive(Debug)] struct ResourcesInner { themes: HashMap, external_text_files: HashMap, base_path: PathBuf, themes_path: PathBuf, image_registry: ImageRegistry, watcher: FileWatcherHandle, } /// Manages resources pulled from the filesystem such as images. /// /// All resources are cached so once a specific resource is loaded, looking it up with the same /// path will involve an in-memory lookup. #[derive(Clone, Debug)] pub struct Resources { inner: Rc>, } impl Resources { /// Construct a new resource manager over the provided based path. /// /// Any relative paths will be assumed to be relative to the given base. pub fn new(base_path: P1, themes_path: P2, image_registry: ImageRegistry) -> Self where P1: Into, P2: Into, { let watcher = FileWatcher::spawn(); let inner = ResourcesInner { base_path: base_path.into(), themes_path: themes_path.into(), themes: Default::default(), external_text_files: Default::default(), image_registry, watcher, }; Self { inner: Rc::new(RefCell::new(inner)) } } pub(crate) fn watch_presentation_file(&self, path: PathBuf) { let inner = self.inner.borrow(); inner.watcher.send(WatchEvent::WatchFile { path, watch_forever: true }); } /// Get the image at the given path. pub(crate) fn image>( &self, path: P, base_path: &ResourceBasePath, ) -> Result { let path = self.resolve_path(path, base_path); let inner = self.inner.borrow(); let image = inner.image_registry.register(ImageSpec::Filesystem(path.clone()))?; Ok(image) } pub(crate) fn theme_image>(&self, path: P) -> Result { match self.image(&path, &ResourceBasePath::Presentation) { Ok(image) => return Ok(image), Err(RegisterImageError::Io(e)) if e.kind() != io::ErrorKind::NotFound => return Err(e.into()), _ => (), }; let inner = self.inner.borrow(); let path = inner.themes_path.join(path); let image = inner.image_registry.register(ImageSpec::Filesystem(path.clone()))?; Ok(image) } /// Get the theme at the given path. pub(crate) fn theme>(&self, path: P) -> Result { let mut inner = self.inner.borrow_mut(); let path = inner.base_path.join(path); if let Some(theme) = inner.themes.get(&path) { return Ok(theme.clone()); } let theme = PresentationTheme::from_path(&path)?; inner.themes.insert(path, theme.clone()); Ok(theme) } /// Get the external text file at the given path. pub(crate) fn external_text_file>( &self, path: P, base_path: &ResourceBasePath, ) -> io::Result { let path = self.resolve_path(path, base_path); let mut inner = self.inner.borrow_mut(); if let Some(contents) = inner.external_text_files.get(&path) { return Ok(contents.clone()); } let contents = fs::read_to_string(&path)?; inner.watcher.send(WatchEvent::WatchFile { path: path.clone(), watch_forever: false }); inner.external_text_files.insert(path, contents.clone()); Ok(contents) } pub(crate) fn resources_modified(&self) -> bool { let mut inner = self.inner.borrow_mut(); inner.watcher.has_modifications() } pub(crate) fn clear_watches(&self) { let mut inner = self.inner.borrow_mut(); inner.watcher.send(WatchEvent::ClearWatches); // We could do better than this but this works for now. inner.external_text_files.clear(); } /// Clears all resources. pub(crate) fn clear(&self) { let mut inner = self.inner.borrow_mut(); inner.image_registry.clear(); inner.themes.clear(); } pub(crate) fn resolve_path>(&self, path: P, base_path: &ResourceBasePath) -> PathBuf { match base_path { ResourceBasePath::Presentation => { let inner = self.inner.borrow(); inner.base_path.join(path) } ResourceBasePath::Custom(base) => base.join(path), } } } #[derive(Clone, Debug, Default)] pub(crate) enum ResourceBasePath { #[default] Presentation, Custom(PathBuf), } /// Watches for file changes. /// /// This uses polling rather than something fancier like `inotify`. The latter turned out to make /// code too complex for little added gain. This instead keeps the last modified time for all /// watched paths and uses that to determine if they've changed. struct FileWatcher { receiver: Receiver, watches: HashMap, modifications: Arc, } impl FileWatcher { fn spawn() -> FileWatcherHandle { let (sender, receiver) = channel(); let modifications = Arc::new(AtomicBool::default()); let handle = FileWatcherHandle { sender, modifications: modifications.clone() }; thread::spawn(move || { let watcher = FileWatcher { receiver, watches: Default::default(), modifications }; watcher.run(); }); handle } fn run(mut self) { loop { if let Ok(event) = self.receiver.try_recv() { self.handle_event(event); } if self.watches_modified() { self.modifications.store(true, Ordering::Relaxed); } thread::sleep(LOOP_INTERVAL); } } fn handle_event(&mut self, event: WatchEvent) { match event { WatchEvent::ClearWatches => { let new_watches = mem::take(&mut self.watches).into_iter().filter(|(_, meta)| meta.watch_forever).collect(); self.watches = new_watches; } WatchEvent::WatchFile { path, watch_forever } => { // If we're already watching this forever, don't reset it if self.watches.get(&path).is_some_and(|w| w.watch_forever) { return; } let last_modification = fs::metadata(&path).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH); let meta = WatchMetadata { last_modification, watch_forever }; self.watches.insert(path, meta); } } } fn watches_modified(&mut self) -> bool { let mut modifications = false; for (path, meta) in &mut self.watches { let Ok(metadata) = fs::metadata(path) else { // If the file no longer exists, it's technically changed since last time. modifications = true; continue; }; let Ok(modified_time) = metadata.modified() else { continue; }; if modified_time > meta.last_modification { meta.last_modification = modified_time; modifications = true; } } modifications } } struct WatchMetadata { last_modification: SystemTime, watch_forever: bool, } #[derive(Debug)] struct FileWatcherHandle { sender: Sender, modifications: Arc, } impl FileWatcherHandle { fn send(&self, event: WatchEvent) { let _ = self.sender.send(event); } fn has_modifications(&mut self) -> bool { self.modifications.swap(false, Ordering::Relaxed) } } enum WatchEvent { /// Clear all watched files. ClearWatches, /// Add a file to the watch list. WatchFile { path: PathBuf, watch_forever: bool }, } presenterm-0.15.1/src/terminal/ansi.rs000064400000000000000000000207671046102023000160120ustar 00000000000000use crate::markdown::{ elements::{Line, Text}, text_style::{Color, TextStyle}, }; use std::mem; use vte::{ParamsIter, Parser, Perform}; pub(crate) struct AnsiParser { starting_style: TextStyle, } impl AnsiParser { pub(crate) fn new(current_style: TextStyle) -> Self { Self { starting_style: current_style } } pub(crate) fn parse_lines(self, lines: I) -> (Vec, TextStyle) where I: IntoIterator, S: AsRef, { let mut output_lines = Vec::new(); let mut style = self.starting_style; for line in lines { let mut handler = Handler::new(style); let mut parser = Parser::new(); parser.advance(&mut handler, line.as_ref().as_bytes()); let (line, ending_style) = handler.into_parts(); output_lines.push(line); style = ending_style; } (output_lines, style) } } struct Handler { line: Line, pending_text: Text, style: TextStyle, } impl Handler { fn new(style: TextStyle) -> Self { Self { line: Default::default(), pending_text: Default::default(), style } } fn into_parts(mut self) -> (Line, TextStyle) { self.save_pending_text(); (self.line, self.style) } fn save_pending_text(&mut self) { if !self.pending_text.content.is_empty() { self.line.0.push(mem::take(&mut self.pending_text)); } } fn parse_standard_color(value: u16) -> Option { let color = match value { 0 | 8 => Color::Black, 1 | 9 => Color::Red, 2 | 10 => Color::Green, 3 | 11 => Color::Yellow, 4 | 12 => Color::Blue, 5 | 13 => Color::Magenta, 6 | 14 => Color::Cyan, 7 | 15 => Color::White, _ => return None, }; Some(color) } fn parse_color(iter: &mut ParamsIter) -> Option { match iter.next()? { [2] => { let r = iter.next()?.first()?; let g = iter.next()?.first()?; let b = iter.next()?.first()?; Self::try_build_rgb_color(*r, *g, *b) } [5] => { let color = *iter.next()?.first()?; match color { 0..=15 => Self::parse_standard_color(color), 16..=231 => { let mapping = [0, 95, 95 + 40, 95 + 80, 95 + 120, 95 + 160]; let mut value = color - 16; let b = (value % 6) as usize; value /= 6; let g = (value % 6) as usize; value /= 6; let r = (value % 6) as usize; Some(Color::new(mapping[r], mapping[g], mapping[b])) } _ => None, } } _ => None, } } fn try_build_rgb_color(r: u16, g: u16, b: u16) -> Option { let r = r.try_into().ok()?; let g = g.try_into().ok()?; let b = b.try_into().ok()?; Some(Color::new(r, g, b)) } fn update_style(&self, mut codes: ParamsIter) -> TextStyle { let mut style = self.style; loop { let Some(&[next]) = codes.next() else { break; }; match next { 0 => style = Default::default(), 1 => style = style.bold(), 3 => style = style.italics(), 4 => style = style.underlined(), 9 => style = style.strikethrough(), 39 => { style.colors.foreground = None; } 49 => { style.colors.background = None; } 30..=37 => { if let Some(color) = Self::parse_standard_color(next - 30) { style = style.fg_color(color); } } 40..=47 => { if let Some(color) = Self::parse_standard_color(next - 40) { style = style.bg_color(color); } } 38 => { if let Some(color) = Self::parse_color(&mut codes) { style = style.fg_color(color); } } 48 => { if let Some(color) = Self::parse_color(&mut codes) { style = style.bg_color(color); } } _ => (), }; } style } } impl Perform for Handler { fn print(&mut self, c: char) { self.pending_text.content.push(c); } fn csi_dispatch(&mut self, params: &vte::Params, _intermediates: &[u8], _ignore: bool, action: char) { if action == 'm' { self.save_pending_text(); self.style = self.update_style(params.iter()); self.pending_text.style = self.style; } } } #[cfg(test)] mod tests { use super::*; use rstest::rstest; #[rstest] #[case::text("hi", Line::from("hi"))] #[case::single_attribute("\x1b[1mhi", Line::from(Text::new("hi", TextStyle::default().bold())))] #[case::two_attributes("\x1b[1;3mhi", Line::from(Text::new("hi", TextStyle::default().bold().italics())))] #[case::three_attributes("\x1b[1;3;4mhi", Line::from(Text::new("hi", TextStyle::default().bold().italics().underlined())))] #[case::four_attributes( "\x1b[1;3;4;9mhi", Line::from(Text::new("hi", TextStyle::default().bold().italics().underlined().strikethrough())) )] #[case::standard_foreground1( "\x1b[38;5;1mhi", Line::from(Text::new("hi", TextStyle::default().fg_color(Color::Red))) )] #[case::standard_foreground2( "\x1b[31mhi", Line::from(Text::new("hi", TextStyle::default().fg_color(Color::Red))) )] #[case::rgb_foreground( "\x1b[38;2;3;4;5mhi", Line::from(Text::new("hi", TextStyle::default().fg_color(Color::new(3, 4, 5)))) )] #[case::standard_background1( "\x1b[48;5;1mhi", Line::from(Text::new("hi", TextStyle::default().bg_color(Color::Red))) )] #[case::standard_background2( "\x1b[41mhi", Line::from(Text::new("hi", TextStyle::default().bg_color(Color::Red))) )] #[case::rgb_background( "\x1b[48;2;3;4;5mhi", Line::from(Text::new("hi", TextStyle::default().bg_color(Color::new(3, 4, 5)))) )] #[case::accumulate( "\x1b[1mhi\x1b[3mbye", Line(vec![ Text::new("hi", TextStyle::default().bold()), Text::new("bye", TextStyle::default().bold().italics()) ]) )] #[case::reset( "\x1b[1mhi\x1b[0;3mbye", Line(vec![ Text::new("hi", TextStyle::default().bold()), Text::new("bye", TextStyle::default().italics()) ]) )] #[case::different_action( "\x1b[01m\x1b[Khi", Line::from(Text::new("hi", TextStyle::default().bold())) )] fn parse_single(#[case] input: &str, #[case] expected: Line) { let splitter = AnsiParser::new(Default::default()); let (lines, _) = splitter.parse_lines([input]); assert_eq!(lines, vec![expected]); } #[rstest] #[case::reset_all("\x1b[0mhi", Line::from("hi"))] #[case::reset_foreground( "\x1b[39mhi", Line::from( Text::new( "hi", TextStyle::default() .bold() .italics() .underlined() .strikethrough() .bg_color(Color::Black) ) ) )] #[case::reset_background( "\x1b[49mhi", Line::from( Text::new( "hi", TextStyle::default() .bold() .italics() .underlined() .strikethrough() .fg_color(Color::Red) ) ) )] fn resets(#[case] input: &str, #[case] expected: Line) { let style = TextStyle::default() .bold() .italics() .underlined() .strikethrough() .fg_color(Color::Red) .bg_color(Color::Black); let splitter = AnsiParser::new(style); let (lines, _) = splitter.parse_lines([input]); assert_eq!(lines, vec![expected]); } } presenterm-0.15.1/src/terminal/capabilities.rs000064400000000000000000000252601046102023000175020ustar 00000000000000use super::image::protocols::kitty::{Action, ControlCommand, ControlOption, ImageFormat, TransmissionMedium}; use base64::{Engine, engine::general_purpose::STANDARD}; use crossterm::{ QueueableCommand, cursor::{self}, style::Print, terminal, }; use image::{DynamicImage, EncodableLayout}; use std::{ env, io::{self, Write}, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, thread, time::Duration, }; use tempfile::NamedTempFile; #[derive(Default, Debug, Clone)] pub(crate) struct TerminalCapabilities { pub(crate) kitty_local: bool, pub(crate) kitty_remote: bool, pub(crate) sixel: bool, pub(crate) tmux: bool, pub(crate) font_size: bool, pub(crate) fractional_font_size: bool, } impl TerminalCapabilities { pub(crate) fn is_inside_tmux() -> bool { env::var("TERM_PROGRAM").ok().as_deref() == Some("tmux") } pub(crate) fn query() -> io::Result { let tmux = Self::is_inside_tmux(); let mut file = NamedTempFile::new()?; let image = DynamicImage::new_rgba8(1, 1).into_rgba8(); let image_bytes = image.as_raw().as_bytes(); file.write_all(image_bytes)?; file.flush()?; let Some(path) = file.path().as_os_str().to_str() else { return Ok(Default::default()); }; let encoded_path = STANDARD.encode(path); let base_image_id = fastrand::u32(0..=u32::MAX); let ids = KittyImageIds { local: base_image_id, remote: base_image_id.wrapping_add(1) }; Self::write_kitty_local_query(ids.local, encoded_path, tmux)?; Self::write_kitty_remote_query(ids.remote, image_bytes, tmux)?; let (start, sequence, end) = match tmux { true => ("\x1bPtmux;", "\x1b\x1b", "\x1b\\"), false => ("", "\x1b", ""), }; let _guard = RawModeGuard::new()?; let mut stdout = io::stdout(); write!(stdout, "{start}{sequence}[c{end}")?; stdout.flush()?; // Spawn a thread to "save us" in case we don't get an answer from the terminal. let running = Arc::new(AtomicBool::new(true)); Self::launch_timeout_trigger(running.clone()); let response = Self::build_capabilities(ids); running.store(false, Ordering::Relaxed); let mut response = response?; response.tmux = tmux; Ok(response) } fn build_capabilities(ids: KittyImageIds) -> io::Result { let mut response = Self::parse_response(io::stdin(), ids)?; // Use kitty's font size protocol to write 1 character using size 2. If after writing the // cursor has moves 2 columns, the protocol is supported. let mut stdout = io::stdout(); stdout.queue(terminal::EnterAlternateScreen)?; stdout.queue(cursor::MoveTo(0, 0))?; stdout.queue(Print("\x1b]66;s=2; \x1b\\"))?; stdout.queue(Print("\x1b]66;n=1:d=2; \x1b\\"))?; stdout.flush()?; let position = cursor::position()?.0; if position == 1 { // If we only moved one, then only the fractional worked. response.fractional_font_size = true; } else if position == 2 { // If we only moved 2 then the scaled font size one worked. response.font_size = true; } else if position == 3 { // 3 -> both worked. response.font_size = true; response.fractional_font_size = true; } stdout.queue(terminal::LeaveAlternateScreen)?; stdout.flush()?; Ok(response) } fn write_kitty_local_query(image_id: u32, path: String, tmux: bool) -> io::Result<()> { let options = &[ ControlOption::Format(ImageFormat::Rgba), ControlOption::Action(Action::Query), ControlOption::Medium(TransmissionMedium::LocalFile), ControlOption::ImageId(image_id), ControlOption::Width(1), ControlOption::Height(1), ]; let command = ControlCommand { options, payload: path, tmux }; write!(io::stdout(), "{command}") } fn write_kitty_remote_query(image_id: u32, image: &[u8], tmux: bool) -> io::Result<()> { let payload = STANDARD.encode(image); let options = &[ ControlOption::Format(ImageFormat::Rgba), ControlOption::Action(Action::Query), ControlOption::Medium(TransmissionMedium::Direct), ControlOption::ImageId(image_id), ControlOption::Width(1), ControlOption::Height(1), ]; // The image is small enough to fit in a single request so we don't need to bother with // chunks here. let command = ControlCommand { options, payload, tmux }; write!(io::stdout(), "{command}") } fn parse_response(mut term: T, ids: KittyImageIds) -> io::Result { let mut buffer = [0_u8; 128]; let mut state = QueryParseState::default(); let mut capabilities = TerminalCapabilities::default(); loop { let bytes_read = term.read(&mut buffer)?; if bytes_read == 0 { return Ok(capabilities); } for next in &buffer[0..bytes_read] { let next = char::from(*next); let Some(output) = state.update(next) else { continue; }; match output { Response::KittySupported { image_id } => { if image_id == ids.local { capabilities.kitty_local = true; } else if image_id == ids.remote { capabilities.kitty_remote = true; } } Response::Capabilities { sixel } => { capabilities.sixel = sixel; return Ok(capabilities); } Response::StatusReport => { return Ok(capabilities); } } } } } fn launch_timeout_trigger(running: Arc) { // Spawn a thread that will wait a second and if we still are running, will request the // device status report straight from whoever is on top of us (tmux or terminal if no // tmux), which will cause it to answer and wake up our main thread that's reading on // stdin. thread::spawn(move || { thread::sleep(Duration::from_secs(1)); if !running.load(Ordering::Relaxed) { return; } let _ = write!(io::stdout(), "\x1b[5n"); let _ = io::stdout().flush(); }); } } struct RawModeGuard; impl RawModeGuard { fn new() -> io::Result { terminal::enable_raw_mode()?; Ok(Self) } } impl Drop for RawModeGuard { fn drop(&mut self) { let _ = terminal::disable_raw_mode(); } } #[derive(Default)] struct QueryParseState { data: String, current: ResponseType, } impl QueryParseState { fn update(&mut self, next: char) -> Option { match &self.current { ResponseType::Unknown => { match (self.data.as_str(), next) { (_, '\x1b') => { *self = Default::default(); return None; } ("[", '?') => { self.current = ResponseType::Capabilities; } ("[", '0') => { self.current = ResponseType::StatusReport; } ("_Gi", '=') => { self.current = ResponseType::Kitty; } _ => (), }; self.data.push(next); } ResponseType::Kitty => match next { '\\' => { let response = self.build_kitty_response(); *self = Default::default(); return response; } _ => { self.data.push(next); } }, ResponseType::Capabilities => match next { 'c' => { let mut caps = self.data[2..].split(';'); let sixel = caps.any(|cap| cap == "4"); *self = Default::default(); return Some(Response::Capabilities { sixel }); } _ => self.data.push(next), }, ResponseType::StatusReport => match next { 'n' => { *self = Default::default(); return Some(Response::StatusReport); } _ => self.data.push(next), }, }; None } fn build_kitty_response(&self) -> Option { if !self.data.ends_with(";OK\x1b") { return None; } let (_, rest) = self.data.split_once("_Gi=").expect("no kitty prefix"); let (image_id, _) = rest.split_once(';')?; let image_id = image_id.parse::().ok()?; Some(Response::KittySupported { image_id }) } } #[derive(Default)] enum ResponseType { #[default] Unknown, Kitty, Capabilities, StatusReport, } enum Response { KittySupported { image_id: u32 }, Capabilities { sixel: bool }, StatusReport, } struct KittyImageIds { local: u32, remote: u32, } #[cfg(test)] mod tests { use super::*; use io::Cursor; use rstest::rstest; #[rstest] #[case::kitty_local("\x1b_Gi=42;OK\x1b\\\x1b[?c", true, false, false)] #[case::kitty_remote("\x1b_Gi=43;OK\x1b\\\x1b[?c", false, true, false)] #[case::kitty_both("\x1b_Gi=42;OK\x1b\\\x1b_Gi=43;OK\x1b\\\x1b[?c", true, true, false)] #[case::kitty_flipped("\x1b_Gi=43;OK\x1b\\\x1b_Gi=42;OK\x1b\\\x1b[?c", true, true, false)] #[case::all("\x1b_Gi=42;OK\x1b\\\x1b_Gi=43;OK\x1b\\\x1b[?4c", true, true, true)] #[case::none("\x1b[?c", false, false, false)] #[case::sixel_single("\x1b[?4c", false, false, true)] #[case::sixel_first("\x1b[?4;42c", false, false, true)] #[case::sixel_middle("\x1b[?1337;4;42c", false, false, true)] fn detection(#[case] input: &str, #[case] kitty_local: bool, #[case] kitty_remote: bool, #[case] sixel: bool) { let input = Cursor::new(input); let ids = KittyImageIds { local: 42, remote: 43 }; let capabilities = TerminalCapabilities::parse_response(input, ids).expect("reading failed"); assert_eq!(capabilities.kitty_local, kitty_local); assert_eq!(capabilities.kitty_remote, kitty_remote); assert_eq!(capabilities.sixel, sixel); } } presenterm-0.15.1/src/terminal/emulator.rs000064400000000000000000000111631046102023000166760ustar 00000000000000use super::{GraphicsMode, capabilities::TerminalCapabilities, image::protocols::kitty::KittyMode}; use std::{env, sync::OnceLock}; use strum::IntoEnumIterator; static CAPABILITIES: OnceLock = OnceLock::new(); #[derive(Debug, strum::EnumIter)] pub enum TerminalEmulator { Iterm2, WezTerm, Ghostty, Mintty, Kitty, Konsole, Foot, Yaft, Mlterm, St, Xterm, Unknown, } impl TerminalEmulator { pub fn detect() -> Self { let term = env::var("TERM").unwrap_or_default(); let term_program = env::var("TERM_PROGRAM").unwrap_or_default(); for emulator in Self::iter() { if emulator.is_detected(&term, &term_program) { return emulator; } } TerminalEmulator::Unknown } pub(crate) fn capabilities() -> TerminalCapabilities { CAPABILITIES.get_or_init(|| TerminalCapabilities::query().unwrap_or_default()).clone() } pub(crate) fn disable_capability_detection() { CAPABILITIES.get_or_init(TerminalCapabilities::default); } pub fn preferred_protocol(&self) -> GraphicsMode { let capabilities = Self::capabilities(); // Note: the order here is very important. In particular: // // * We prioritize checking for iterm2 support as the default for terminals that support // it. // * Kitty local is checked before remote since remote should also work when local is // supported but local is more efficient. // * Sixel is not great so we use it as a last resort. // * ASCII blocks is supported by all terminals so it must come last. let modes = [ GraphicsMode::Iterm2, GraphicsMode::Iterm2Multipart, GraphicsMode::Kitty { mode: KittyMode::Local }, GraphicsMode::Kitty { mode: KittyMode::Remote }, #[cfg(feature = "sixel")] GraphicsMode::Sixel, GraphicsMode::AsciiBlocks, ]; for mode in modes { if self.supports_graphics_mode(&mode, &capabilities) { return mode; } } unreachable!("ascii blocks is always supported") } fn is_detected(&self, term: &str, term_program: &str) -> bool { match self { TerminalEmulator::Iterm2 => { term_program.contains("iTerm") || env::var("LC_TERMINAL").is_ok_and(|c| c.contains("iTerm")) } TerminalEmulator::WezTerm => term_program.contains("WezTerm") || env::var("WEZTERM_EXECUTABLE").is_ok(), TerminalEmulator::Mintty => term_program.contains("mintty"), TerminalEmulator::Ghostty => term_program.contains("ghostty"), TerminalEmulator::Kitty => term.contains("kitty"), TerminalEmulator::Konsole => env::var("KONSOLE_VERSION").is_ok(), TerminalEmulator::Foot => ["foot", "foot-extra"].contains(&term), TerminalEmulator::Yaft => term == "yaft-256color", TerminalEmulator::Mlterm => term == "mlterm", TerminalEmulator::St => term == "st-256color", TerminalEmulator::Xterm => ["xterm", "xterm-256color"].contains(&term), TerminalEmulator::Unknown => true, } } fn supports_graphics_mode(&self, mode: &GraphicsMode, capabilities: &TerminalCapabilities) -> bool { match (mode, self) { // Use the kitty protocol in any terminal that supports the kitty graphics protocol. // // Note that this could potentially break for terminals that don't support the unicode // placeholder part of the spec which is required for this to work under tmux, but it's // not our fault terminals half implement the protocol. (GraphicsMode::Kitty { mode, .. }, _) => match mode { KittyMode::Local => capabilities.kitty_local, KittyMode::Remote => capabilities.kitty_remote, }, // All of these support the iterm2 protocol (GraphicsMode::Iterm2, Self::Iterm2 | Self::WezTerm | Self::Mintty | Self::Konsole) => true, // Only iterm2 supports the iterm2 protocol in multipart form. (GraphicsMode::Iterm2Multipart, Self::Iterm2) => true, // All terminals support ascii protocol (GraphicsMode::AsciiBlocks, _) => true, #[cfg(feature = "sixel")] (GraphicsMode::Sixel, Self::Foot | Self::Yaft | Self::Mlterm) => true, #[cfg(feature = "sixel")] (GraphicsMode::Sixel, Self::St | Self::Xterm | Self::Unknown) => capabilities.sixel, _ => false, } } } presenterm-0.15.1/src/terminal/image/mod.rs000064400000000000000000000042421046102023000167070ustar 00000000000000use self::printer::{ImageProperties, TerminalImage}; use image::DynamicImage; use protocols::ascii::AsciiImage; use std::{ fmt::Debug, ops::Deref, path::PathBuf, sync::{Arc, Mutex}, }; pub(crate) mod printer; pub(crate) mod protocols; pub(crate) mod scale; struct Inner { image: TerminalImage, ascii_image: Mutex>, } /// An image. /// /// This stores the image in an [std::sync::Arc] so it's cheap to clone. #[derive(Clone)] pub(crate) struct Image { inner: Arc, pub(crate) source: ImageSource, } impl Image { /// Constructs a new image. pub(crate) fn new(image: TerminalImage, source: ImageSource) -> Self { let inner = Inner { image, ascii_image: Default::default() }; Self { inner: Arc::new(inner), source } } pub(crate) fn to_ascii(&self) -> AsciiImage { let mut ascii_image = self.inner.ascii_image.lock().unwrap(); match ascii_image.deref() { Some(image) => image.clone(), None => { let image = match &self.inner.image { TerminalImage::Ascii(image) => image.clone(), TerminalImage::Kitty(image) => DynamicImage::from(image.as_rgba8()).into(), TerminalImage::Iterm(image) => DynamicImage::from(image.as_rgba8()).into(), TerminalImage::Raw(_) => unreachable!("raw is only used for exports"), #[cfg(feature = "sixel")] TerminalImage::Sixel(image) => DynamicImage::from(image.as_rgba8()).into(), }; *ascii_image = Some(image.clone()); image } } } pub(crate) fn image(&self) -> &TerminalImage { &self.inner.image } } impl PartialEq for Image { fn eq(&self, other: &Self) -> bool { self.source == other.source } } impl Debug for Image { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let (width, height) = self.inner.image.dimensions(); write!(f, "Image<{width}x{height}>") } } #[derive(Clone, Debug, PartialEq)] pub(crate) enum ImageSource { Filesystem(PathBuf), Generated, } presenterm-0.15.1/src/terminal/image/printer.rs000064400000000000000000000171231046102023000176150ustar 00000000000000use super::{ Image, ImageSource, protocols::{ ascii::{AsciiImage, AsciiPrinter}, iterm::{ItermImage, ItermPrinter}, kitty::{KittyImage, KittyPrinter}, raw::{RawImage, RawPrinter}, }, }; use crate::{ markdown::text_style::{Color, PaletteColorError}, terminal::{ GraphicsMode, emulator::TerminalEmulator, image::protocols::iterm::ItermMode, printer::{TerminalError, TerminalIo}, }, }; use image::{DynamicImage, ImageError}; use std::{ borrow::Cow, collections::HashMap, fmt, io, path::PathBuf, sync::{Arc, Mutex}, }; pub(crate) trait PrintImage { type Image: ImageProperties; /// Register an image. fn register(&self, spec: ImageSpec) -> Result; fn print(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError> where T: TerminalIo; } pub(crate) trait ImageProperties { fn dimensions(&self) -> (u32, u32); } #[derive(Clone, Debug, PartialEq)] pub(crate) struct PrintOptions { pub(crate) columns: u16, pub(crate) rows: u16, pub(crate) z_index: i32, pub(crate) background_color: Option, // Width/height in pixels. #[allow(dead_code)] pub(crate) column_width: u16, #[allow(dead_code)] pub(crate) row_height: u16, } pub(crate) enum TerminalImage { Kitty(KittyImage), Iterm(ItermImage), Ascii(AsciiImage), Raw(RawImage), #[cfg(feature = "sixel")] Sixel(super::protocols::sixel::SixelImage), } impl ImageProperties for TerminalImage { fn dimensions(&self) -> (u32, u32) { match self { Self::Kitty(image) => image.dimensions(), Self::Iterm(image) => image.dimensions(), Self::Ascii(image) => image.dimensions(), Self::Raw(image) => image.dimensions(), #[cfg(feature = "sixel")] Self::Sixel(image) => image.dimensions(), } } } pub enum ImagePrinter { Kitty(KittyPrinter), Iterm(ItermPrinter), Ascii(AsciiPrinter), Raw(RawPrinter), Null, #[cfg(feature = "sixel")] Sixel(super::protocols::sixel::SixelPrinter), } impl Default for ImagePrinter { fn default() -> Self { Self::Ascii(AsciiPrinter) } } impl ImagePrinter { pub fn new(mode: GraphicsMode) -> Result { let capabilities = TerminalEmulator::capabilities(); let printer = match mode { GraphicsMode::Kitty { mode } => Self::Kitty(KittyPrinter::new(mode, capabilities.tmux)?), GraphicsMode::Iterm2 => Self::Iterm(ItermPrinter::new(ItermMode::Single, capabilities.tmux)), GraphicsMode::Iterm2Multipart => Self::Iterm(ItermPrinter::new(ItermMode::Multipart, capabilities.tmux)), GraphicsMode::AsciiBlocks => Self::Ascii(AsciiPrinter), GraphicsMode::Raw => Self::Raw(RawPrinter), #[cfg(feature = "sixel")] GraphicsMode::Sixel => Self::Sixel(super::protocols::sixel::SixelPrinter::new()?), }; Ok(printer) } } impl PrintImage for ImagePrinter { type Image = TerminalImage; fn register(&self, spec: ImageSpec) -> Result { let image = match self { Self::Kitty(printer) => TerminalImage::Kitty(printer.register(spec)?), Self::Iterm(printer) => TerminalImage::Iterm(printer.register(spec)?), Self::Ascii(printer) => TerminalImage::Ascii(printer.register(spec)?), Self::Null => return Err(RegisterImageError::Unsupported), Self::Raw(printer) => TerminalImage::Raw(printer.register(spec)?), #[cfg(feature = "sixel")] Self::Sixel(printer) => TerminalImage::Sixel(printer.register(spec)?), }; Ok(image) } fn print(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError> where T: TerminalIo, { match (self, image) { (Self::Kitty(printer), TerminalImage::Kitty(image)) => printer.print(image, options, terminal), (Self::Iterm(printer), TerminalImage::Iterm(image)) => printer.print(image, options, terminal), (Self::Ascii(printer), TerminalImage::Ascii(image)) => printer.print(image, options, terminal), (Self::Null, _) => Ok(()), (Self::Raw(printer), TerminalImage::Raw(image)) => printer.print(image, options, terminal), #[cfg(feature = "sixel")] (Self::Sixel(printer), TerminalImage::Sixel(image)) => printer.print(image, options, terminal), _ => Err(PrintImageError::Unsupported), } } } #[derive(Clone, Default)] pub(crate) struct ImageRegistry { printer: Arc, images: Arc>>, } impl ImageRegistry { pub fn new(printer: Arc) -> Self { Self { printer, images: Default::default() } } } impl fmt::Debug for ImageRegistry { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let inner = match self.printer.as_ref() { ImagePrinter::Kitty(_) => "Kitty", ImagePrinter::Iterm(_) => "Iterm", ImagePrinter::Ascii(_) => "Ascii", ImagePrinter::Null => "Null", ImagePrinter::Raw(_) => "Raw", #[cfg(feature = "sixel")] ImagePrinter::Sixel(_) => "Sixel", }; write!(f, "ImageRegistry<{inner}>") } } impl ImageRegistry { pub(crate) fn register(&self, spec: ImageSpec) -> Result { let mut images = self.images.lock().unwrap(); let (source, cache_key) = match &spec { ImageSpec::Generated(_) => (ImageSource::Generated, None), ImageSpec::Filesystem(path) => { // Return if already cached if let Some(image) = images.get(path) { return Ok(image.clone()); } (ImageSource::Filesystem(path.clone()), Some(path.clone())) } }; let resource = self.printer.register(spec)?; let image = Image::new(resource, source); if let Some(key) = cache_key { images.insert(key.clone(), image.clone()); } Ok(image) } pub(crate) fn clear(&self) { self.images.lock().unwrap().clear(); } } pub(crate) enum ImageSpec { Generated(DynamicImage), Filesystem(PathBuf), } #[derive(Debug, thiserror::Error)] pub(crate) enum CreatePrinterError { #[error("io: {0}")] Io(#[from] io::Error), } #[derive(Debug, thiserror::Error)] pub(crate) enum PrintImageError { #[error(transparent)] Io(#[from] io::Error), #[error("unsupported image type")] Unsupported, #[error("image decoding: {0}")] Image(#[from] ImageError), #[error("{0}")] Other(Cow<'static, str>), } impl From for PrintImageError { fn from(e: PaletteColorError) -> Self { Self::Other(e.to_string().into()) } } impl From for PrintImageError { fn from(e: TerminalError) -> Self { match e { TerminalError::Io(e) => Self::Io(e), TerminalError::Image(e) => e, } } } #[derive(Debug, thiserror::Error)] pub(crate) enum RegisterImageError { #[error(transparent)] Io(#[from] io::Error), #[error("image decoding: {0}")] Image(#[from] ImageError), #[error("printer can't register images")] Unsupported, } impl PrintImageError { pub(crate) fn other(message: S) -> Self where S: Into>, { Self::Other(message.into()) } } presenterm-0.15.1/src/terminal/image/protocols/ascii.rs000064400000000000000000000130171046102023000212440ustar 00000000000000use crate::{ markdown::text_style::{Color, Colors, TextStyle}, terminal::{ image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError}, printer::{TerminalCommand, TerminalIo}, }, }; use image::{DynamicImage, GenericImageView, Pixel, Rgba, RgbaImage, imageops::FilterType}; use itertools::Itertools; use std::{ collections::HashMap, fs, sync::{Arc, Mutex}, }; const TOP_CHAR: &str = "▀"; const BOTTOM_CHAR: &str = "▄"; struct Inner { image: DynamicImage, cached_sizes: Mutex>, } #[derive(Clone)] pub(crate) struct AsciiImage { inner: Arc, } impl AsciiImage { pub(crate) fn cache_scaling(&self, columns: u16, rows: u16) { let mut cached_sizes = self.inner.cached_sizes.lock().unwrap(); // lookup on cache/resize the image and store it in cache let cache_key = (columns, rows); if cached_sizes.get(&cache_key).is_none() { let image = self.inner.image.resize_exact(columns as u32, rows as u32, FilterType::Triangle); cached_sizes.insert(cache_key, image.into_rgba8()); } } } impl ImageProperties for AsciiImage { fn dimensions(&self) -> (u32, u32) { self.inner.image.dimensions() } } impl From for AsciiImage { fn from(image: DynamicImage) -> Self { let image = image.into_rgba8(); let inner = Inner { image: image.into(), cached_sizes: Default::default() }; Self { inner: Arc::new(inner) } } } #[derive(Default)] pub struct AsciiPrinter; impl AsciiPrinter { fn pixel_color(pixel: &Rgba, background: Option) -> Option { let [r, g, b, alpha] = pixel.0; if alpha == 0 { None } else if alpha < 255 { // For alpha > 0 && < 255, we blend it with the background color (if any). This helps // smooth the image's borders. let mut pixel = *pixel; match background { Some(Color::Rgb { r, g, b }) => { pixel.blend(&Rgba([r, g, b, 255 - alpha])); Some(Color::Rgb { r: pixel[0], g: pixel[1], b: pixel[2] }) } // For transparent backgrounds, we can't really know whether we should blend it // towards light or dark. None | Some(_) => Some(Color::Rgb { r, g, b }), } } else { Some(Color::Rgb { r, g, b }) } } } impl PrintImage for AsciiPrinter { type Image = AsciiImage; fn register(&self, spec: ImageSpec) -> Result { let image = match spec { ImageSpec::Generated(image) => image, ImageSpec::Filesystem(path) => { let contents = fs::read(path)?; image::load_from_memory(&contents)? } }; Ok(AsciiImage::from(image)) } fn print(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError> where T: TerminalIo, { let columns = options.columns; let rows = options.rows * 2; // Scale it first image.cache_scaling(columns, rows); // lookup on cache/resize the image and store it in cache let cache_key = (columns, rows); let cached_sizes = image.inner.cached_sizes.lock().unwrap(); let image = cached_sizes.get(&cache_key).expect("scaled image no longer there"); let default_background = options.background_color; // Iterate pixel rows in pairs to be able to merge both pixels in a single iteration. // Note that may not have a second row if there's an odd number of them. for mut rows in &image.rows().chunks(2) { let top_row = rows.next().unwrap(); let mut bottom_row = rows.next(); for top_pixel in top_row { let bottom_pixel = bottom_row.as_mut().and_then(|pixels| pixels.next()); // Get pixel colors for both of these. At this point the special case for the odd // number of rows disappears as we treat a transparent pixel and a non-existent // one the same: they're simply transparent. let background = default_background; let top = Self::pixel_color(top_pixel, background); let bottom = bottom_pixel.and_then(|c| Self::pixel_color(c, background)); let command = match (top, bottom) { (Some(top), Some(bottom)) => TerminalCommand::PrintText { content: TOP_CHAR, style: TextStyle::default().fg_color(top).bg_color(bottom), }, (Some(top), None) => TerminalCommand::PrintText { content: TOP_CHAR, style: TextStyle::colored(Colors { foreground: Some(top), background: default_background }), }, (None, Some(bottom)) => TerminalCommand::PrintText { content: BOTTOM_CHAR, style: TextStyle::colored(Colors { foreground: Some(bottom), background: default_background }), }, (None, None) => TerminalCommand::MoveRight(1), }; terminal.execute(&command)?; } terminal.execute(&TerminalCommand::MoveDown(1))?; terminal.execute(&TerminalCommand::MoveLeft(options.columns))?; } Ok(()) } } presenterm-0.15.1/src/terminal/image/protocols/iterm.rs000064400000000000000000000075141046102023000213010ustar 00000000000000use crate::terminal::{ image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError}, printer::{TerminalCommand, TerminalIo}, }; use base64::{Engine, engine::general_purpose::STANDARD}; use image::{GenericImageView, ImageEncoder, RgbaImage, codecs::png::PngEncoder}; use std::{fs, str}; const CHUNK_SIZE: usize = 32 * 1024; pub(crate) struct ItermImage { dimensions: (u32, u32), raw_length: usize, base64_contents: String, } impl ItermImage { pub(crate) fn as_rgba8(&self) -> RgbaImage { let contents = STANDARD.decode(&self.base64_contents).expect("base64 must be valid"); let image = image::load_from_memory(&contents).expect("image must have been originally valid"); image.to_rgba8() } } impl ImageProperties for ItermImage { fn dimensions(&self) -> (u32, u32) { self.dimensions } } pub enum ItermMode { Single, Multipart, } pub struct ItermPrinter { mode: ItermMode, tmux: bool, } impl ItermPrinter { pub(crate) fn new(mode: ItermMode, tmux: bool) -> Self { Self { mode, tmux } } } impl PrintImage for ItermPrinter { type Image = ItermImage; fn register(&self, spec: ImageSpec) -> Result { let (contents, dimensions) = match spec { ImageSpec::Generated(image) => { let dimensions = image.dimensions(); let mut contents = Vec::new(); let encoder = PngEncoder::new(&mut contents); encoder.write_image(image.as_bytes(), dimensions.0, dimensions.1, image.color().into())?; (contents, dimensions) } ImageSpec::Filesystem(path) => { let contents = fs::read(path)?; let image = image::load_from_memory(&contents)?; (contents, image.dimensions()) } }; let raw_length = contents.len(); let contents = STANDARD.encode(&contents); Ok(ItermImage { dimensions, raw_length, base64_contents: contents }) } fn print(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError> where T: TerminalIo, { let size = image.raw_length; let columns = options.columns; let rows = options.rows; let (start, end) = match self.tmux { true => ("\x1bPtmux;\x1b\x1b]1337;", "\x07\x1b\\"), false => ("\x1b]1337;", "\x07"), }; let base64 = &image.base64_contents; match &self.mode { ItermMode::Single => { let content = &format!( "{start}File=size={size};width={columns};height={rows};inline=1;preserveAspectRatio=1:{base64}{end}" ); terminal.execute(&TerminalCommand::PrintText { content, style: Default::default() })?; } ItermMode::Multipart => { let content = &format!( "{start}MultipartFile=size={size};width={columns};height={rows};inline=1;preserveAspectRatio=1{end}" ); terminal.execute(&TerminalCommand::PrintText { content, style: Default::default() })?; for chunk in base64.as_bytes().chunks(CHUNK_SIZE) { // SAFETY: this is base64 so it must be utf8 let chunk = str::from_utf8(chunk).expect("not utf8"); let content = &format!("{start}FilePart={chunk}{end}"); terminal.execute(&TerminalCommand::PrintText { content, style: Default::default() })?; } terminal.execute(&TerminalCommand::PrintText { content: &format!("{start}FileEnd{end}"), style: Default::default(), })?; } }; Ok(()) } } presenterm-0.15.1/src/terminal/image/protocols/kitty.rs000064400000000000000000000466561046102023000213370ustar 00000000000000use crate::{ markdown::text_style::{Color, TextStyle}, terminal::{ image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError}, printer::{TerminalCommand, TerminalIo}, }, }; use base64::{Engine, engine::general_purpose::STANDARD}; use image::{AnimationDecoder, Delay, EncodableLayout, ImageReader, RgbaImage, codecs::gif::GifDecoder}; use std::{ fmt, fs::{self, File}, io::{self, BufReader}, path::{Path, PathBuf}, sync::atomic::{AtomicU32, Ordering}, }; use tempfile::{TempDir, tempdir}; const IMAGE_PLACEHOLDER: &str = "\u{10EEEE}"; const DIACRITICS: &[u32] = &[ 0x305, 0x30d, 0x30e, 0x310, 0x312, 0x33d, 0x33e, 0x33f, 0x346, 0x34a, 0x34b, 0x34c, 0x350, 0x351, 0x352, 0x357, 0x35b, 0x363, 0x364, 0x365, 0x366, 0x367, 0x368, 0x369, 0x36a, 0x36b, 0x36c, 0x36d, 0x36e, 0x36f, 0x483, 0x484, 0x485, 0x486, 0x487, 0x592, 0x593, 0x594, 0x595, 0x597, 0x598, 0x599, 0x59c, 0x59d, 0x59e, 0x59f, 0x5a0, 0x5a1, 0x5a8, 0x5a9, 0x5ab, 0x5ac, 0x5af, 0x5c4, 0x610, 0x611, 0x612, 0x613, 0x614, 0x615, 0x616, 0x617, 0x657, 0x658, 0x659, 0x65a, 0x65b, 0x65d, 0x65e, 0x6d6, 0x6d7, 0x6d8, 0x6d9, 0x6da, 0x6db, 0x6dc, 0x6df, 0x6e0, 0x6e1, 0x6e2, 0x6e4, 0x6e7, 0x6e8, 0x6eb, 0x6ec, 0x730, 0x732, 0x733, 0x735, 0x736, 0x73a, 0x73d, 0x73f, 0x740, 0x741, 0x743, 0x745, 0x747, 0x749, 0x74a, 0x7eb, 0x7ec, 0x7ed, 0x7ee, 0x7ef, 0x7f0, 0x7f1, 0x7f3, 0x816, 0x817, 0x818, 0x819, 0x81b, 0x81c, 0x81d, 0x81e, 0x81f, 0x820, 0x821, 0x822, 0x823, 0x825, 0x826, 0x827, 0x829, 0x82a, 0x82b, 0x82c, 0x82d, 0x951, 0x953, 0x954, 0xf82, 0xf83, 0xf86, 0xf87, 0x135d, 0x135e, 0x135f, 0x17dd, 0x193a, 0x1a17, 0x1a75, 0x1a76, 0x1a77, 0x1a78, 0x1a79, 0x1a7a, 0x1a7b, 0x1a7c, 0x1b6b, 0x1b6d, 0x1b6e, 0x1b6f, 0x1b70, 0x1b71, 0x1b72, 0x1b73, 0x1cd0, 0x1cd1, 0x1cd2, 0x1cda, 0x1cdb, 0x1ce0, 0x1dc0, 0x1dc1, 0x1dc3, 0x1dc4, 0x1dc5, 0x1dc6, 0x1dc7, 0x1dc8, 0x1dc9, 0x1dcb, 0x1dcc, 0x1dd1, 0x1dd2, 0x1dd3, 0x1dd4, 0x1dd5, 0x1dd6, 0x1dd7, 0x1dd8, 0x1dd9, 0x1dda, 0x1ddb, 0x1ddc, 0x1ddd, 0x1dde, 0x1ddf, 0x1de0, 0x1de1, 0x1de2, 0x1de3, 0x1de4, 0x1de5, 0x1de6, 0x1dfe, 0x20d0, 0x20d1, 0x20d4, 0x20d5, 0x20d6, 0x20d7, 0x20db, 0x20dc, 0x20e1, 0x20e7, 0x20e9, 0x20f0, 0x2cef, 0x2cf0, 0x2cf1, 0x2de0, 0x2de1, 0x2de2, 0x2de3, 0x2de4, 0x2de5, 0x2de6, 0x2de7, 0x2de8, 0x2de9, 0x2dea, 0x2deb, 0x2dec, 0x2ded, 0x2dee, 0x2def, 0x2df0, 0x2df1, 0x2df2, 0x2df3, 0x2df4, 0x2df5, 0x2df6, 0x2df7, 0x2df8, 0x2df9, 0x2dfa, 0x2dfb, 0x2dfc, 0x2dfd, 0x2dfe, 0x2dff, 0xa66f, 0xa67c, 0xa67d, 0xa6f0, 0xa6f1, 0xa8e0, 0xa8e1, 0xa8e2, 0xa8e3, 0xa8e4, 0xa8e5, 0xa8e6, 0xa8e7, 0xa8e8, 0xa8e9, 0xa8ea, 0xa8eb, 0xa8ec, 0xa8ed, 0xa8ee, 0xa8ef, 0xa8f0, 0xa8f1, 0xaab0, 0xaab2, 0xaab3, 0xaab7, 0xaab8, 0xaabe, 0xaabf, 0xaac1, 0xfe20, 0xfe21, 0xfe22, 0xfe23, 0xfe24, 0xfe25, 0xfe26, 0x10a0f, 0x10a38, 0x1d185, 0x1d186, 0x1d187, 0x1d188, 0x1d189, 0x1d1aa, 0x1d1ab, 0x1d1ac, 0x1d1ad, 0x1d242, 0x1d243, 0x1d244, ]; enum GenericResource { Image(B), Gif(Vec>), } type RawResource = GenericResource; impl RawResource { fn into_memory_resource(self) -> KittyImage { match self { Self::Image(image) => KittyImage { dimensions: image.dimensions(), resource: GenericResource::Image(KittyBuffer::Memory(image.into_raw())), }, Self::Gif(frames) => { let dimensions = frames[0].buffer.dimensions(); let frames = frames .into_iter() .map(|frame| GifFrame { delay: frame.delay, buffer: KittyBuffer::Memory(frame.buffer.into_raw()) }) .collect(); let resource = GenericResource::Gif(frames); KittyImage { dimensions, resource } } } } } pub(crate) struct KittyImage { dimensions: (u32, u32), resource: GenericResource, } impl KittyImage { pub(crate) fn as_rgba8(&self) -> RgbaImage { let first_frame = match &self.resource { GenericResource::Image(buffer) => buffer, GenericResource::Gif(gif_frames) => &gif_frames[0].buffer, }; let buffer = match first_frame { KittyBuffer::Filesystem(path) => { let Ok(contents) = fs::read(path) else { return RgbaImage::default(); }; contents } KittyBuffer::Memory(buffer) => buffer.clone(), }; RgbaImage::from_raw(self.dimensions.0, self.dimensions.1, buffer).unwrap_or_default() } } impl ImageProperties for KittyImage { fn dimensions(&self) -> (u32, u32) { self.dimensions } } enum KittyBuffer { Filesystem(PathBuf), Memory(Vec), } impl Drop for KittyBuffer { fn drop(&mut self) { if let Self::Filesystem(path) = self { let _ = fs::remove_file(path); } } } struct GifFrame { delay: Delay, buffer: T, } pub struct KittyPrinter { mode: KittyMode, tmux: bool, base_directory: TempDir, next: AtomicU32, } impl KittyPrinter { pub(crate) fn new(mode: KittyMode, tmux: bool) -> io::Result { let base_directory = tempdir()?; Ok(Self { mode, tmux, base_directory, next: Default::default() }) } fn allocate_tempfile(&self) -> PathBuf { let file_number = self.next.fetch_add(1, Ordering::AcqRel); self.base_directory.path().join(file_number.to_string()) } fn persist_image(&self, image: RgbaImage) -> io::Result { let path = self.allocate_tempfile(); fs::write(&path, image.as_bytes())?; let buffer = KittyBuffer::Filesystem(path); let resource = KittyImage { dimensions: image.dimensions(), resource: GenericResource::Image(buffer) }; Ok(resource) } fn persist_gif(&self, frames: Vec>) -> io::Result { let mut persisted_frames = Vec::new(); let mut dimensions = (0, 0); for frame in frames { let path = self.allocate_tempfile(); fs::write(&path, frame.buffer.as_bytes())?; dimensions = frame.buffer.dimensions(); let frame = GifFrame { delay: frame.delay, buffer: KittyBuffer::Filesystem(path) }; persisted_frames.push(frame); } Ok(KittyImage { dimensions, resource: GenericResource::Gif(persisted_frames) }) } fn persist_resource(&self, resource: RawResource) -> io::Result { match resource { RawResource::Image(image) => self.persist_image(image), RawResource::Gif(frames) => self.persist_gif(frames), } } fn generate_image_id() -> u32 { fastrand::u32(1..u32::MAX) } fn print_image( &self, dimensions: (u32, u32), buffer: &KittyBuffer, terminal: &mut T, print_options: &PrintOptions, ) -> Result<(), PrintImageError> where T: TerminalIo, { let mut options = vec![ ControlOption::Format(ImageFormat::Rgba), ControlOption::Action(Action::TransmitAndDisplay), ControlOption::Width(dimensions.0), ControlOption::Height(dimensions.1), ControlOption::Columns(print_options.columns), ControlOption::Rows(print_options.rows), ControlOption::ZIndex(print_options.z_index), ControlOption::Quiet(2), ]; let mut image_id = 0; if self.tmux { image_id = Self::generate_image_id(); options.extend([ControlOption::UnicodePlaceholder, ControlOption::ImageId(image_id)]); } match &buffer { KittyBuffer::Filesystem(path) => self.print_local(options, path, terminal)?, KittyBuffer::Memory(buffer) => self.print_remote(options, buffer, terminal, false)?, }; if self.tmux { self.print_unicode_placeholders(terminal, print_options, image_id)?; } Ok(()) } fn print_gif( &self, dimensions: (u32, u32), frames: &[GifFrame], terminal: &mut T, print_options: &PrintOptions, ) -> Result<(), PrintImageError> where T: TerminalIo, { let image_id = Self::generate_image_id(); for (frame_id, frame) in frames.iter().enumerate() { let (num, denom) = frame.delay.numer_denom_ms(); // default to 100ms in case somehow the denominator is 0 let delay = num.checked_div(denom).unwrap_or(100); let mut options = vec![ ControlOption::Format(ImageFormat::Rgba), ControlOption::ImageId(image_id), ControlOption::Width(dimensions.0), ControlOption::Height(dimensions.1), ControlOption::ZIndex(print_options.z_index), ControlOption::Quiet(2), ]; if frame_id == 0 { options.extend([ ControlOption::Action(Action::TransmitAndDisplay), ControlOption::Columns(print_options.columns), ControlOption::Rows(print_options.rows), ]); if self.tmux { options.push(ControlOption::UnicodePlaceholder); } } else { options.extend([ControlOption::Action(Action::TransmitFrame), ControlOption::Delay(delay)]); } let is_frame = frame_id > 0; match &frame.buffer { KittyBuffer::Filesystem(path) => self.print_local(options, path, terminal)?, KittyBuffer::Memory(buffer) => self.print_remote(options, buffer, terminal, is_frame)?, }; if frame_id == 0 { let options = &[ ControlOption::Action(Action::Animate), ControlOption::ImageId(image_id), ControlOption::FrameId(1), ControlOption::Loops(1), ]; let command = self.make_command(options, "").to_string(); terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?; } else if frame_id == 1 { let options = &[ ControlOption::Action(Action::Animate), ControlOption::ImageId(image_id), ControlOption::FrameId(1), ControlOption::AnimationState(2), ]; let command = self.make_command(options, "").to_string(); terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?; } } if self.tmux { self.print_unicode_placeholders(terminal, print_options, image_id)?; } let options = &[ ControlOption::Action(Action::Animate), ControlOption::ImageId(image_id), ControlOption::FrameId(1), ControlOption::AnimationState(3), ControlOption::Loops(1), ControlOption::Quiet(2), ]; let command = self.make_command(options, "").to_string(); terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?; Ok(()) } fn make_command<'a, P>(&self, options: &'a [ControlOption], payload: P) -> ControlCommand<'a, P> { ControlCommand { options, payload, tmux: self.tmux } } fn print_local( &self, mut options: Vec, path: &Path, terminal: &mut T, ) -> Result<(), PrintImageError> where T: TerminalIo, { let Some(path) = path.to_str() else { return Err(PrintImageError::other("path is not valid utf8")); }; let encoded_path = STANDARD.encode(path); options.push(ControlOption::Medium(TransmissionMedium::LocalFile)); let command = self.make_command(&options, &encoded_path).to_string(); terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?; Ok(()) } fn print_remote( &self, mut options: Vec, frame: &[u8], terminal: &mut T, is_frame: bool, ) -> Result<(), PrintImageError> where T: TerminalIo, { options.push(ControlOption::Medium(TransmissionMedium::Direct)); let payload = STANDARD.encode(frame); let chunk_size = 4096; let mut index = 0; while index < payload.len() { let start = index; let end = payload.len().min(start + chunk_size); index = end; let more = end != payload.len(); options.push(ControlOption::MoreData(more)); let payload = &payload[start..end]; let command = self.make_command(&options, payload).to_string(); terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?; options.clear(); if is_frame { options.push(ControlOption::Action(Action::TransmitFrame)); } } Ok(()) } fn print_unicode_placeholders( &self, terminal: &mut T, options: &PrintOptions, image_id: u32, ) -> Result<(), PrintImageError> where T: TerminalIo, { let color = Color::new((image_id >> 16) as u8, (image_id >> 8) as u8, image_id as u8); let style = TextStyle::default().fg_color(color); if options.rows.max(options.columns) >= DIACRITICS.len() as u16 { return Err(PrintImageError::other("image is too large to fit in tmux")); } let last_byte = char::from_u32(DIACRITICS[(image_id >> 24) as usize]).unwrap(); for row in 0..options.rows { let row_diacritic = char::from_u32(DIACRITICS[row as usize]).unwrap(); for column in 0..options.columns { let column_diacritic = char::from_u32(DIACRITICS[column as usize]).unwrap(); let content = format!("{IMAGE_PLACEHOLDER}{row_diacritic}{column_diacritic}{last_byte}"); terminal.execute(&TerminalCommand::PrintText { content: &content, style })?; } if row != options.rows - 1 { terminal.execute(&TerminalCommand::MoveDown(1))?; } terminal.execute(&TerminalCommand::MoveLeft(options.columns))?; } Ok(()) } fn load_raw_resource(path: &Path) -> Result { let file = File::open(path)?; if path.extension().unwrap_or_default() == "gif" { let decoder = GifDecoder::new(BufReader::new(file))?; let mut frames = Vec::new(); for frame in decoder.into_frames() { let frame = frame?; let frame = GifFrame { delay: frame.delay(), buffer: frame.into_buffer() }; frames.push(frame); } Ok(RawResource::Gif(frames)) } else { let reader = ImageReader::new(BufReader::new(file)).with_guessed_format()?; let image = reader.decode()?; Ok(RawResource::Image(image.into_rgba8())) } } } impl PrintImage for KittyPrinter { type Image = KittyImage; fn register(&self, spec: ImageSpec) -> Result { let image = match spec { ImageSpec::Generated(image) => RawResource::Image(image.into_rgba8()), ImageSpec::Filesystem(path) => Self::load_raw_resource(&path)?, }; let resource = match &self.mode { KittyMode::Local => self.persist_resource(image)?, KittyMode::Remote => image.into_memory_resource(), }; Ok(resource) } fn print(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError> where T: TerminalIo, { match &image.resource { GenericResource::Image(resource) => self.print_image(image.dimensions, resource, terminal, options)?, GenericResource::Gif(frames) => self.print_gif(image.dimensions, frames, terminal, options)?, }; Ok(()) } } #[derive(Clone, Debug)] pub enum KittyMode { Local, Remote, } pub(crate) struct ControlCommand<'a, D> { pub(crate) options: &'a [ControlOption], pub(crate) payload: D, pub(crate) tmux: bool, } impl fmt::Display for ControlCommand<'_, D> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if self.tmux { write!(f, "\x1bPtmux;\x1b")?; } write!(f, "\x1b_G")?; for (index, option) in self.options.iter().enumerate() { if index > 0 { write!(f, ",")?; } write!(f, "{option}")?; } write!(f, ";{}", &self.payload)?; if self.tmux { write!(f, "\x1b\x1b\\\x1b\\")?; } else { write!(f, "\x1b\\")?; } Ok(()) } } #[derive(Debug, Clone)] pub(crate) enum ControlOption { Action(Action), Format(ImageFormat), Medium(TransmissionMedium), Width(u32), Height(u32), Columns(u16), Rows(u16), MoreData(bool), ImageId(u32), FrameId(u32), Delay(u32), AnimationState(u32), Loops(u32), Quiet(u32), ZIndex(i32), UnicodePlaceholder, } impl fmt::Display for ControlOption { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use ControlOption::*; match self { Action(action) => write!(f, "a={action}"), Format(format) => write!(f, "f={format}"), Medium(medium) => write!(f, "t={medium}"), Width(width) => write!(f, "s={width}"), Height(height) => write!(f, "v={height}"), Columns(columns) => write!(f, "c={columns}"), Rows(rows) => write!(f, "r={rows}"), MoreData(true) => write!(f, "m=1"), MoreData(false) => write!(f, "m=0"), ImageId(id) => write!(f, "i={id}"), FrameId(id) => write!(f, "r={id}"), Delay(delay) => write!(f, "z={delay}"), AnimationState(state) => write!(f, "s={state}"), Loops(count) => write!(f, "v={count}"), Quiet(option) => write!(f, "q={option}"), ZIndex(index) => write!(f, "z={index}"), UnicodePlaceholder => write!(f, "U=1"), } } } #[derive(Debug, Clone)] pub(crate) enum ImageFormat { Rgba, } impl fmt::Display for ImageFormat { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use ImageFormat::*; let value = match self { Rgba => 32, }; write!(f, "{value}") } } #[derive(Debug, Clone)] pub(crate) enum TransmissionMedium { Direct, LocalFile, } impl fmt::Display for TransmissionMedium { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use TransmissionMedium::*; let value = match self { Direct => 'd', LocalFile => 'f', }; write!(f, "{value}") } } #[derive(Debug, Clone)] pub(crate) enum Action { Animate, TransmitAndDisplay, TransmitFrame, Query, } impl fmt::Display for Action { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use Action::*; let value = match self { Animate => 'a', TransmitAndDisplay => 'T', TransmitFrame => 'f', Query => 'q', }; write!(f, "{value}") } } presenterm-0.15.1/src/terminal/image/protocols/mod.rs000064400000000000000000000002061046102023000207270ustar 00000000000000pub(crate) mod ascii; pub(crate) mod iterm; pub(crate) mod kitty; pub(crate) mod raw; #[cfg(feature = "sixel")] pub(crate) mod sixel; presenterm-0.15.1/src/terminal/image/protocols/raw.rs000064400000000000000000000037701046102023000207520ustar 00000000000000use crate::terminal::{ image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError}, printer::TerminalIo, }; use base64::{Engine, engine::general_purpose::STANDARD}; use image::{GenericImageView, ImageEncoder, ImageFormat, codecs::png::PngEncoder}; use std::fs; pub(crate) struct RawImage { contents: Vec, format: ImageFormat, width: u32, height: u32, } impl RawImage { pub(crate) fn to_inline_html(&self) -> String { let mime_type = self.format.to_mime_type(); let data = STANDARD.encode(&self.contents); format!("data:{mime_type};base64,{data}") } } impl ImageProperties for RawImage { fn dimensions(&self) -> (u32, u32) { (self.width, self.height) } } pub(crate) struct RawPrinter; impl PrintImage for RawPrinter { type Image = RawImage; fn register(&self, spec: ImageSpec) -> Result { let image = match spec { ImageSpec::Generated(image) => { let mut contents = Vec::new(); let encoder = PngEncoder::new(&mut contents); let (width, height) = image.dimensions(); encoder.write_image(image.as_bytes(), width, height, image.color().into())?; RawImage { contents, format: ImageFormat::Png, width, height } } ImageSpec::Filesystem(path) => { let contents = fs::read(path)?; let format = image::guess_format(&contents)?; let image = image::load_from_memory_with_format(&contents, format)?; let (width, height) = image.dimensions(); RawImage { contents, format, width, height } } }; Ok(image) } fn print(&self, _image: &Self::Image, _options: &PrintOptions, _terminal: &mut T) -> Result<(), PrintImageError> where T: TerminalIo, { Err(PrintImageError::Other("raw images can't be printed".into())) } } presenterm-0.15.1/src/terminal/image/protocols/sixel.rs000064400000000000000000000047731046102023000213110ustar 00000000000000use crate::terminal::{ image::printer::{ CreatePrinterError, ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError, }, printer::{TerminalCommand, TerminalIo}, }; use image::{DynamicImage, GenericImageView, RgbaImage, imageops::FilterType}; use sixel_rs::{ encoder::{Encoder, QuickFrameBuilder}, optflags::EncodePolicy, sys::PixelFormat, }; use std::fs; pub(crate) struct SixelImage(DynamicImage); impl SixelImage { pub(crate) fn as_rgba8(&self) -> RgbaImage { self.0.to_rgba8() } } impl ImageProperties for SixelImage { fn dimensions(&self) -> (u32, u32) { self.0.dimensions() } } #[derive(Default)] pub struct SixelPrinter; impl SixelPrinter { pub(crate) fn new() -> Result { Ok(Self) } } impl PrintImage for SixelPrinter { type Image = SixelImage; fn register(&self, spec: ImageSpec) -> Result { match spec { ImageSpec::Generated(image) => Ok(SixelImage(image)), ImageSpec::Filesystem(path) => { let contents = fs::read(path)?; let image = image::load_from_memory(&contents)?; Ok(SixelImage(image)) } } } fn print(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError> where T: TerminalIo, { // We're already positioned in the right place but we may not have flushed that yet. terminal.execute(&TerminalCommand::Flush)?; let encoder = Encoder::new().map_err(|e| PrintImageError::other(format!("creating sixel encoder: {e:?}")))?; encoder .set_encode_policy(EncodePolicy::Fast) .map_err(|e| PrintImageError::other(format!("setting encoder policy: {e:?}")))?; // This check was taken from viuer: it seems to be a bug in xterm let width = (options.column_width * options.columns).min(1000); let height = options.row_height * options.rows; let image = image.0.resize_exact(width as u32, height as u32, FilterType::Triangle); let bytes = image.into_rgba8().into_raw(); let frame = QuickFrameBuilder::new() .width(width as usize) .height(height as usize) .format(PixelFormat::RGBA8888) .pixels(bytes); encoder.encode_bytes(frame).map_err(|e| PrintImageError::other(format!("encoding sixel image: {e:?}")))?; Ok(()) } } presenterm-0.15.1/src/terminal/image/scale.rs000064400000000000000000000111731046102023000172200ustar 00000000000000use crate::render::properties::{CursorPosition, WindowSize}; pub(crate) trait ScaleImage { /// Scale an image to a specific size. fn scale_image( &self, scale_size: &WindowSize, window_dimensions: &WindowSize, image_width: u32, image_height: u32, position: &CursorPosition, ) -> TerminalRect; /// Shrink an image so it fits the dimensions of the layout it's being displayed in. fn fit_image_to_rect( &self, dimensions: &WindowSize, image_width: u32, image_height: u32, position: &CursorPosition, ) -> TerminalRect; } pub(crate) struct ImageScaler { horizontal_margin: f64, } impl ScaleImage for ImageScaler { fn scale_image( &self, scale_size: &WindowSize, window_dimensions: &WindowSize, image_width: u32, image_height: u32, position: &CursorPosition, ) -> TerminalRect { let aspect_ratio = image_height as f64 / image_width as f64; let column_in_pixels = scale_size.pixels_per_column(); let width_in_columns = scale_size.columns; let image_width = width_in_columns as f64 * column_in_pixels; let image_height = image_width * aspect_ratio; self.fit_image_to_rect(window_dimensions, image_width as u32, image_height as u32, position) } fn fit_image_to_rect( &self, dimensions: &WindowSize, image_width: u32, image_height: u32, position: &CursorPosition, ) -> TerminalRect { let aspect_ratio = image_height as f64 / image_width as f64; // Compute the image's width in columns by translating pixels -> columns. let column_in_pixels = dimensions.pixels_per_column(); let column_margin = (dimensions.columns as f64 * (1.0 - self.horizontal_margin)) as u32; let mut width_in_columns = (image_width as f64 / column_in_pixels) as u32; // Do the same for its height. let row_in_pixels = dimensions.pixels_per_row(); let height_in_rows = (image_height as f64 / row_in_pixels) as u32; // If the image doesn't fit vertically, shrink it. let available_height = dimensions.rows.saturating_sub(position.row) as u32; if height_in_rows > available_height { // Because we only use the width to draw, here we scale the width based on how much we // need to shrink the height. let shrink_ratio = available_height as f64 / height_in_rows as f64; width_in_columns = (width_in_columns as f64 * shrink_ratio).round() as u32; } // Don't go too far wide. let width_in_columns = width_in_columns.min(column_margin); // Now translate width -> height by using the original aspect ratio + translate based on // the window size's aspect ratio. let height_in_rows = (width_in_columns as f64 * aspect_ratio * dimensions.aspect_ratio()).round() as u16; let width_in_columns = width_in_columns.max(1); let height_in_rows = height_in_rows.max(1); TerminalRect { columns: width_in_columns as u16, rows: height_in_rows } } } impl Default for ImageScaler { fn default() -> Self { Self { horizontal_margin: 0.05 } } } #[derive(Debug, PartialEq)] pub(crate) struct TerminalRect { pub(crate) columns: u16, pub(crate) rows: u16, } #[cfg(test)] mod tests { use super::*; use rstest::rstest; const WINDOW: WindowSize = WindowSize { rows: 50, columns: 100, height: 200, width: 200 }; const SMALL_WINDOW: WindowSize = WindowSize { rows: 3, columns: 6, height: 10, width: 10 }; const OTHER_RATIO: WindowSize = WindowSize { rows: 10, columns: 10, height: 10, width: 10 }; #[rstest] #[case::squares(WINDOW, 100, 100, TerminalRect { columns: 50, rows: 25 })] #[case::squares_smaller(WINDOW, 50, 50, TerminalRect { columns: 25, rows: 13 })] #[case::square_too_large(WINDOW, 400, 400, TerminalRect { columns: 100, rows: 50 })] #[case::too_tall(WINDOW, 200, 400, TerminalRect { columns: 50, rows: 50 })] #[case::too_wide(WINDOW, 400, 200, TerminalRect { columns: 100, rows: 25 })] #[case::small(SMALL_WINDOW, 899, 872, TerminalRect { columns: 6, rows: 3 })] #[case::other_ratio(OTHER_RATIO, 100, 100, TerminalRect { columns: 10, rows: 10 })] fn image_fitting( #[case] window: WindowSize, #[case] width: u32, #[case] height: u32, #[case] expected: TerminalRect, ) { let cursor = CursorPosition::default(); let rect = ImageScaler { horizontal_margin: 0.0 }.fit_image_to_rect(&window, width, height, &cursor); assert_eq!(rect, expected); } } presenterm-0.15.1/src/terminal/mod.rs000064400000000000000000000006621046102023000156270ustar 00000000000000pub(crate) mod ansi; pub(crate) mod capabilities; pub(crate) mod emulator; pub(crate) mod image; pub(crate) mod printer; pub(crate) mod virt; pub(crate) use printer::{Terminal, TerminalWrite, should_hide_cursor}; #[derive(Clone, Debug)] pub enum GraphicsMode { Iterm2, Iterm2Multipart, Kitty { mode: image::protocols::kitty::KittyMode, }, AsciiBlocks, Raw, #[cfg(feature = "sixel")] Sixel, } presenterm-0.15.1/src/terminal/printer.rs000064400000000000000000000213271046102023000165340ustar 00000000000000use super::emulator::TerminalEmulator; use crate::{ markdown::text_style::{Color, Colors, TextStyle}, terminal::image::{ Image, printer::{ImagePrinter, PrintImage, PrintImageError, PrintOptions}, }, }; use crossterm::{ QueueableCommand, cursor, style, terminal::{self}, }; use std::{ io::{self, Write}, sync::Arc, }; #[derive(Debug, PartialEq)] pub(crate) enum TerminalCommand<'a> { BeginUpdate, EndUpdate, MoveTo { column: u16, row: u16 }, MoveToRow(u16), MoveToColumn(u16), MoveDown(u16), MoveRight(u16), MoveLeft(u16), MoveToNextLine, PrintText { content: &'a str, style: TextStyle }, ClearScreen, SetColors(Colors), SetBackgroundColor(Color), SetCursorBoundaries { rows: u16 }, Flush, PrintImage { image: Image, options: PrintOptions }, } pub(crate) trait TerminalIo { fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError>; fn cursor_row(&self) -> u16; } #[derive(Debug, thiserror::Error)] pub(crate) enum TerminalError { #[error("io: {0}")] Io(#[from] io::Error), #[error("image: {0}")] Image(#[from] PrintImageError), } /// A wrapper over the terminal write handle. pub(crate) struct Terminal { writer: I, image_printer: Arc, cursor_row: u16, current_row_height: u16, rows: u16, last_cleared_background_color: Option, background_color: Option, osc11_background: bool, } impl Terminal { pub(crate) fn new(mut writer: I, image_printer: Arc) -> io::Result { writer.init()?; Ok(Self { writer, image_printer, cursor_row: 0, current_row_height: 1, rows: u16::MAX, last_cleared_background_color: None, background_color: None, // Only use OSC11 when outside of tmux temporarily since it somehow breaks under kitty osc11_background: !TerminalEmulator::capabilities().tmux, }) } fn begin_update(&mut self) -> io::Result<()> { self.writer.queue(terminal::BeginSynchronizedUpdate)?; Ok(()) } fn end_update(&mut self) -> io::Result<()> { self.writer.queue(terminal::EndSynchronizedUpdate)?; Ok(()) } fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> { self.writer.queue(cursor::MoveTo(column, row))?; self.cursor_row = row; Ok(()) } fn move_to_row(&mut self, row: u16) -> io::Result<()> { self.writer.queue(cursor::MoveToRow(row))?; self.cursor_row = row; Ok(()) } fn move_to_column(&mut self, column: u16) -> io::Result<()> { self.writer.queue(cursor::MoveToColumn(column))?; Ok(()) } fn move_down(&mut self, amount: u16) -> io::Result<()> { self.writer.queue(cursor::MoveDown(amount))?; self.cursor_row += amount; Ok(()) } fn move_right(&mut self, amount: u16) -> io::Result<()> { self.writer.queue(cursor::MoveRight(amount))?; Ok(()) } fn move_left(&mut self, amount: u16) -> io::Result<()> { self.writer.queue(cursor::MoveLeft(amount))?; Ok(()) } fn move_to_next_line(&mut self) -> io::Result<()> { let amount = self.current_row_height; self.writer.queue(cursor::MoveToNextLine(amount))?; self.cursor_row += amount; self.current_row_height = 1; Ok(()) } fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> { // Don't print text if it overflows vertically. if self.cursor_row.saturating_add(style.size as u16) > self.rows { return Ok(()); } let capabilities = TerminalEmulator::capabilities(); let content = style.apply(content, &capabilities); self.writer.queue(style::PrintStyledContent(content))?; self.current_row_height = self.current_row_height.max(style.size as u16); Ok(()) } fn clear_screen(&mut self) -> io::Result<()> { if self.osc11_background { match (self.last_cleared_background_color, self.background_color) { (_, Some(Color::Rgb { r, g, b })) => { // Set background via OSC 11 if we have an RGB color write!(self.writer, "\x1b]11;#{r:02x}{g:02x}{b:02x}\x1b\\")?; } // If it was RGB and it no longer is, or we have no background now, clear it. (Some(Color::Rgb { .. }), Some(_)) | (_, None) => write!(self.writer, "\x1b]111\x1b\\")?, _ => (), }; } self.last_cleared_background_color = self.background_color; self.writer.queue(terminal::Clear(terminal::ClearType::All))?; self.cursor_row = 0; self.current_row_height = 1; Ok(()) } fn set_colors(&mut self, colors: Colors) -> io::Result<()> { // Save this for when the screen is cleared.. self.background_color = colors.background; let colors = colors.into(); self.writer.queue(style::ResetColor)?; self.writer.queue(style::SetColors(colors))?; Ok(()) } fn set_background_color(&mut self, color: Color) -> io::Result<()> { self.background_color = Some(color); let color = color.into(); self.writer.queue(style::SetBackgroundColor(color))?; Ok(()) } fn set_cursor_boundaries(&mut self, rows: u16) { self.rows = rows; } fn flush(&mut self) -> io::Result<()> { self.writer.flush()?; Ok(()) } fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> { let image_printer = self.image_printer.clone(); image_printer.print(image.image(), options, self)?; self.cursor_row += options.rows; Ok(()) } pub(crate) fn suspend(&mut self) { self.writer.deinit(); } pub(crate) fn resume(&mut self) { let _ = self.writer.init(); } } impl TerminalIo for Terminal { fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> { use TerminalCommand::*; match command { BeginUpdate => self.begin_update()?, EndUpdate => self.end_update()?, MoveTo { column, row } => self.move_to(*column, *row)?, MoveToRow(row) => self.move_to_row(*row)?, MoveToColumn(column) => self.move_to_column(*column)?, MoveDown(amount) => self.move_down(*amount)?, MoveRight(amount) => self.move_right(*amount)?, MoveLeft(amount) => self.move_left(*amount)?, MoveToNextLine => self.move_to_next_line()?, PrintText { content, style } => self.print_text(content, style)?, ClearScreen => self.clear_screen()?, SetColors(colors) => self.set_colors(*colors)?, SetBackgroundColor(color) => self.set_background_color(*color)?, SetCursorBoundaries { rows } => self.set_cursor_boundaries(*rows), Flush => self.flush()?, PrintImage { image, options } => self.print_image(image, options)?, }; Ok(()) } fn cursor_row(&self) -> u16 { self.cursor_row } } impl Drop for Terminal { fn drop(&mut self) { if self.osc11_background { if let Some(Color::Rgb { .. }) = self.background_color { let _ = write!(self.writer, "\x1b]111\x1b\\"); } } self.writer.deinit(); } } pub(crate) fn should_hide_cursor() -> bool { // WezTerm on Windows fails to display images if we've hidden the cursor so we **always** hide it // unless we're on WezTerm on Windows. let term = std::env::var("TERM_PROGRAM"); let is_wezterm = term.as_ref().map(|s| s.as_str()) == Ok("WezTerm"); !(is_windows_based_os() && is_wezterm) } fn is_windows_based_os() -> bool { let is_windows = std::env::consts::OS == "windows"; let is_wsl = std::env::var("WSL_DISTRO_NAME").is_ok(); is_windows || is_wsl } pub(crate) trait TerminalWrite: io::Write { fn init(&mut self) -> io::Result<()>; fn deinit(&mut self); } impl TerminalWrite for io::Stdout { fn init(&mut self) -> io::Result<()> { terminal::enable_raw_mode()?; if should_hide_cursor() { self.queue(cursor::Hide)?; } self.queue(terminal::EnterAlternateScreen)?; Ok(()) } fn deinit(&mut self) { let _ = self.queue(terminal::LeaveAlternateScreen); if should_hide_cursor() { let _ = self.queue(cursor::Show); } let _ = self.flush(); let _ = terminal::disable_raw_mode(); } } presenterm-0.15.1/src/terminal/virt.rs000064400000000000000000000230771046102023000160410ustar 00000000000000use super::{ image::{ Image, printer::{PrintImage, PrintImageError, PrintOptions}, protocols::ascii::AsciiPrinter, }, printer::{TerminalError, TerminalIo}, }; use crate::{ WindowSize, markdown::{ elements::Text, text_style::{Color, Colors, TextStyle}, }, terminal::printer::TerminalCommand, }; use std::{collections::HashMap, io}; #[derive(Clone, Debug, PartialEq)] pub(crate) struct PrintedImage { pub(crate) image: Image, pub(crate) width_columns: u16, } pub(crate) struct TerminalRowIterator<'a> { row: &'a [StyledChar], } impl<'a> TerminalRowIterator<'a> { pub(crate) fn new(row: &'a [StyledChar]) -> Self { Self { row } } } impl Iterator for TerminalRowIterator<'_> { type Item = Text; fn next(&mut self) -> Option { let style = self.row.first()?.style; let mut output = String::new(); while let Some(c) = self.row.first() { if c.style != style { break; } output.push(c.character); self.row = &self.row[1..]; } Some(Text::new(output, style)) } } #[derive(Clone, Debug, PartialEq)] pub(crate) struct TerminalGrid { pub(crate) rows: Vec>, pub(crate) background_color: Option, pub(crate) images: HashMap<(u16, u16), PrintedImage>, } pub(crate) struct VirtualTerminal { row: u16, column: u16, colors: Colors, rows: Vec>, background_color: Option, images: HashMap<(u16, u16), PrintedImage>, row_heights: Vec, image_behavior: ImageBehavior, } impl VirtualTerminal { pub(crate) fn new(dimensions: WindowSize, image_behavior: ImageBehavior) -> Self { let rows = vec![vec![StyledChar::default(); dimensions.columns as usize]; dimensions.rows as usize]; let row_heights = vec![1; dimensions.rows as usize]; Self { row: 0, column: 0, colors: Default::default(), rows, background_color: None, images: Default::default(), row_heights, image_behavior, } } pub(crate) fn into_contents(self) -> TerminalGrid { TerminalGrid { rows: self.rows, background_color: self.background_color, images: self.images } } fn current_cell_mut(&mut self) -> Option<&mut StyledChar> { self.rows.get_mut(self.row as usize).and_then(|row| row.get_mut(self.column as usize)) } fn set_current_row_height(&mut self, height: u16) { if let Some(current) = self.row_heights.get_mut(self.row as usize) { *current = height; } } fn current_row_height(&self) -> u16 { *self.row_heights.get(self.row as usize).unwrap_or(&1) } fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> { self.column = column; self.row = row; Ok(()) } fn move_to_row(&mut self, row: u16) -> io::Result<()> { self.row = row; self.set_current_row_height(1); Ok(()) } fn move_to_column(&mut self, column: u16) -> io::Result<()> { self.column = column; Ok(()) } fn move_down(&mut self, amount: u16) -> io::Result<()> { self.row += amount; Ok(()) } fn move_right(&mut self, amount: u16) -> io::Result<()> { self.column += amount; Ok(()) } fn move_left(&mut self, amount: u16) -> io::Result<()> { self.column = self.column.saturating_sub(amount); Ok(()) } fn move_to_next_line(&mut self) -> io::Result<()> { let amount = self.current_row_height(); self.row += amount; self.column = 0; self.set_current_row_height(1); Ok(()) } fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> { let style = style.merged(&TextStyle::default().colors(self.colors)); for c in content.chars() { let Some(cell) = self.current_cell_mut() else { continue; }; cell.character = c; cell.style = style; self.column += style.size as u16; } let height = self.current_row_height().max(style.size as u16); self.set_current_row_height(height); Ok(()) } fn clear_screen(&mut self) -> io::Result<()> { for row in &mut self.rows { for cell in row { cell.character = ' '; } } self.background_color = self.colors.background; Ok(()) } fn set_colors(&mut self, colors: crate::markdown::text_style::Colors) -> io::Result<()> { self.colors = colors; Ok(()) } fn set_background_color(&mut self, color: Color) -> io::Result<()> { self.colors.background = Some(color); Ok(()) } fn flush(&mut self) -> io::Result<()> { Ok(()) } fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> { match &self.image_behavior { ImageBehavior::Store => { let key = (self.row, self.column); let image = PrintedImage { image: image.clone(), width_columns: options.columns }; self.images.insert(key, image); } ImageBehavior::PrintAscii => { let image = image.to_ascii(); let image_printer = AsciiPrinter; image_printer.print(&image, options, self)? } }; Ok(()) } } impl TerminalIo for VirtualTerminal { fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> { use TerminalCommand::*; match command { BeginUpdate | EndUpdate => (), MoveTo { column, row } => self.move_to(*column, *row)?, MoveToRow(row) => self.move_to_row(*row)?, MoveToColumn(column) => self.move_to_column(*column)?, MoveDown(amount) => self.move_down(*amount)?, MoveRight(amount) => self.move_right(*amount)?, MoveLeft(amount) => self.move_left(*amount)?, MoveToNextLine => self.move_to_next_line()?, PrintText { content, style } => self.print_text(content, style)?, ClearScreen => self.clear_screen()?, SetColors(colors) => self.set_colors(*colors)?, SetBackgroundColor(color) => self.set_background_color(*color)?, SetCursorBoundaries { .. } => (), Flush => self.flush()?, PrintImage { image, options } => self.print_image(image, options)?, }; Ok(()) } fn cursor_row(&self) -> u16 { self.row } } #[derive(Clone, Debug, Default)] pub(crate) enum ImageBehavior { #[default] Store, PrintAscii, } #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct StyledChar { pub(crate) character: char, pub(crate) style: TextStyle, } impl StyledChar { #[cfg(test)] pub(crate) fn new(character: char, style: TextStyle) -> Self { Self { character, style } } } impl From for StyledChar { fn from(character: char) -> Self { Self { character, style: Default::default() } } } impl Default for StyledChar { fn default() -> Self { Self { character: ' ', style: Default::default() } } } #[cfg(test)] mod tests { use super::*; trait TerminalGridExt { fn assert_contents(&self, lines: &[&str]); } impl TerminalGridExt for TerminalGrid { fn assert_contents(&self, lines: &[&str]) { assert_eq!(self.rows.len(), lines.len()); for (line, expected) in self.rows.iter().zip(lines) { let line: String = line.iter().map(|c| c.character).collect(); assert_eq!(line, *expected); } } } #[test] fn text() { let dimensions = WindowSize { rows: 2, columns: 3, height: 0, width: 0 }; let mut term = VirtualTerminal::new(dimensions, Default::default()); for c in "abc".chars() { term.print_text(&c.to_string(), &Default::default()).expect("print failed"); } term.move_to_next_line().unwrap(); term.print_text("A", &Default::default()).expect("print failed"); let grid = term.into_contents(); grid.assert_contents(&["abc", "A "]); } #[test] fn movement() { let dimensions = WindowSize { rows: 2, columns: 3, height: 0, width: 0 }; let mut term = VirtualTerminal::new(dimensions, Default::default()); term.print_text("A", &Default::default()).unwrap(); term.move_down(1).unwrap(); term.print_text("B", &Default::default()).unwrap(); term.move_to(2, 0).unwrap(); term.print_text("C", &Default::default()).unwrap(); term.move_to_row(1).unwrap(); term.move_to_column(2).unwrap(); term.print_text("D", &Default::default()).unwrap(); let grid = term.into_contents(); grid.assert_contents(&["A C", " BD"]); } #[test] fn iterator() { let row = &[ StyledChar { character: ' ', style: TextStyle::default() }, StyledChar { character: 'A', style: TextStyle::default() }, StyledChar { character: 'B', style: TextStyle::default().bold() }, StyledChar { character: 'C', style: TextStyle::default().bold() }, StyledChar { character: 'D', style: TextStyle::default() }, ]; let texts: Vec<_> = TerminalRowIterator::new(row).collect(); assert_eq!(texts, &[Text::from(" A"), Text::new("BC", TextStyle::default().bold()), Text::from("D")]); } } presenterm-0.15.1/src/theme/clean.rs000064400000000000000000000617311046102023000154250ustar 00000000000000use super::{ AuthorPositioning, FooterTemplate, Margin, raw::{self, RawColor}, }; use crate::{ markdown::text_style::{Color, Colors, TextStyle, UndefinedPaletteColorError}, resource::Resources, terminal::image::{Image, printer::RegisterImageError}, }; use std::collections::BTreeMap; const DEFAULT_CODE_HIGHLIGHT_THEME: &str = "base16-eighties.dark"; const DEFAULT_BLOCK_QUOTE_PREFIX: &str = "▍ "; const DEFAULT_PROGRESS_BAR_CHAR: char = '█'; const DEFAULT_FOOTER_HEIGHT: u16 = 3; const DEFAULT_TYPST_HORIZONTAL_MARGIN: u16 = 5; const DEFAULT_TYPST_VERTICAL_MARGIN: u16 = 7; const DEFAULT_MERMAID_THEME: &str = "default"; const DEFAULT_MERMAID_BACKGROUND: &str = "transparent"; const DEFAULT_D2_THEME: u32 = 0; #[derive(Clone, Debug, Default)] pub(crate) struct ThemeOptions { pub(crate) font_size_supported: bool, } impl ThemeOptions { fn adjust_font_size(&self, font_size: Option) -> u8 { if !self.font_size_supported { 1 } else { font_size.unwrap_or(1).clamp(1, 7) } } } #[derive(Clone, Debug)] pub(crate) struct PresentationTheme { pub(crate) slide_title: SlideTitleStyle, pub(crate) code: CodeBlockStyle, pub(crate) execution_output: ExecutionOutputBlockStyle, pub(crate) inline_code: InlineCodeStyle, pub(crate) table: Alignment, pub(crate) block_quote: BlockQuoteStyle, pub(crate) alert: AlertStyle, pub(crate) default_style: DefaultStyle, pub(crate) headings: HeadingStyles, pub(crate) intro_slide: IntroSlideStyle, pub(crate) footer: FooterStyle, pub(crate) typst: TypstStyle, pub(crate) mermaid: MermaidStyle, pub(crate) d2: D2Style, pub(crate) modals: ModalStyle, pub(crate) palette: ColorPalette, } impl PresentationTheme { pub(crate) fn new( raw: &raw::PresentationTheme, resources: &Resources, options: &ThemeOptions, ) -> Result { let raw::PresentationTheme { slide_title, code, execution_output, inline_code, table, block_quote, alert, default_style, headings, intro_slide, footer, typst, mermaid, d2, modals, palette, extends: _, } = raw; let palette = ColorPalette::try_from(palette)?; let default_style = DefaultStyle::new(default_style, &palette)?; Ok(Self { slide_title: SlideTitleStyle::new(slide_title, &palette, options)?, code: CodeBlockStyle::new(code), execution_output: ExecutionOutputBlockStyle::new(execution_output, &palette)?, inline_code: InlineCodeStyle::new(inline_code, &palette)?, table: table.clone().unwrap_or_default().into(), block_quote: BlockQuoteStyle::new(block_quote, &palette)?, alert: AlertStyle::new(alert, &palette)?, default_style: default_style.clone(), headings: HeadingStyles::new(headings, &palette, options)?, intro_slide: IntroSlideStyle::new(intro_slide, &palette, options)?, footer: FooterStyle::new(&footer.clone().unwrap_or_default(), &palette, resources)?, typst: TypstStyle::new(typst, &palette)?, mermaid: MermaidStyle::new(mermaid), d2: D2Style::new(d2), modals: ModalStyle::new(modals, &default_style, &palette)?, palette, }) } pub(crate) fn alignment(&self, element: &ElementType) -> Alignment { use ElementType::*; match element { SlideTitle => self.slide_title.alignment, Heading1 => self.headings.h1.alignment, Heading2 => self.headings.h2.alignment, Heading3 => self.headings.h3.alignment, Heading4 => self.headings.h4.alignment, Heading5 => self.headings.h5.alignment, Heading6 => self.headings.h6.alignment, Paragraph => Default::default(), PresentationTitle => self.intro_slide.title.alignment, PresentationSubTitle => self.intro_slide.subtitle.alignment, PresentationEvent => self.intro_slide.event.alignment, PresentationLocation => self.intro_slide.location.alignment, PresentationDate => self.intro_slide.date.alignment, PresentationAuthor => self.intro_slide.author.alignment, Table => self.table, } } } #[derive(Debug, thiserror::Error)] pub(crate) enum ProcessingThemeError { #[error(transparent)] Palette(#[from] UndefinedPaletteColorError), #[error("palette cannot contain other palette colors")] PaletteColorInPalette, #[error("invalid footer image: {0}")] FooterImage(RegisterImageError), } #[derive(Clone, Debug)] pub(crate) struct SlideTitleStyle { pub(crate) alignment: Alignment, pub(crate) separator: bool, pub(crate) padding_top: u8, pub(crate) padding_bottom: u8, pub(crate) style: TextStyle, } impl SlideTitleStyle { fn new( raw: &raw::SlideTitleStyle, palette: &ColorPalette, options: &ThemeOptions, ) -> Result { let raw::SlideTitleStyle { alignment, separator, padding_top, padding_bottom, colors, bold, italics, underlined, font_size, } = raw; let colors = colors.resolve(palette)?; let mut style = TextStyle::colored(colors).size(options.adjust_font_size(*font_size)); if bold.unwrap_or_default() { style = style.bold(); } if italics.unwrap_or_default() { style = style.italics(); } if underlined.unwrap_or_default() { style = style.underlined(); } Ok(Self { alignment: alignment.clone().unwrap_or_default().into(), separator: *separator, padding_top: padding_top.unwrap_or_default(), padding_bottom: padding_bottom.unwrap_or_default(), style, }) } } #[derive(Clone, Debug)] pub(crate) struct HeadingStyles { pub(crate) h1: HeadingStyle, pub(crate) h2: HeadingStyle, pub(crate) h3: HeadingStyle, pub(crate) h4: HeadingStyle, pub(crate) h5: HeadingStyle, pub(crate) h6: HeadingStyle, } impl HeadingStyles { fn new( raw: &raw::HeadingStyles, palette: &ColorPalette, options: &ThemeOptions, ) -> Result { let raw::HeadingStyles { h1, h2, h3, h4, h5, h6 } = raw; Ok(Self { h1: HeadingStyle::new(h1, palette, options)?, h2: HeadingStyle::new(h2, palette, options)?, h3: HeadingStyle::new(h3, palette, options)?, h4: HeadingStyle::new(h4, palette, options)?, h5: HeadingStyle::new(h5, palette, options)?, h6: HeadingStyle::new(h6, palette, options)?, }) } } #[derive(Clone, Debug)] pub(crate) struct HeadingStyle { pub(crate) alignment: Alignment, pub(crate) prefix: Option, pub(crate) style: TextStyle, } impl HeadingStyle { fn new( raw: &raw::HeadingStyle, palette: &ColorPalette, options: &ThemeOptions, ) -> Result { let raw::HeadingStyle { alignment, prefix, colors, font_size } = raw; let alignment = alignment.clone().unwrap_or_default().into(); let style = TextStyle::colored(colors.resolve(palette)?).size(options.adjust_font_size(*font_size)); Ok(Self { alignment, prefix: prefix.clone(), style }) } } #[derive(Clone, Debug)] pub(crate) struct BlockQuoteStyle { pub(crate) alignment: Alignment, pub(crate) prefix: String, pub(crate) base_style: TextStyle, pub(crate) prefix_style: TextStyle, } impl BlockQuoteStyle { fn new(raw: &raw::BlockQuoteStyle, palette: &ColorPalette) -> Result { let raw::BlockQuoteStyle { alignment, prefix, colors } = raw; let alignment = alignment.clone().unwrap_or_default().into(); let prefix = prefix.as_deref().unwrap_or(DEFAULT_BLOCK_QUOTE_PREFIX).to_string(); let base_style = TextStyle::colored(colors.base.resolve(palette)?); let mut prefix_style = TextStyle::colored(colors.base.resolve(palette)?); if let Some(color) = &colors.prefix { prefix_style.colors.foreground = color.resolve(palette)?; } Ok(Self { alignment, prefix, base_style, prefix_style }) } } #[derive(Clone, Debug)] pub(crate) struct AlertStyle { pub(crate) alignment: Alignment, pub(crate) base_style: TextStyle, pub(crate) prefix: String, pub(crate) styles: AlertTypeStyles, } impl AlertStyle { fn new(raw: &raw::AlertStyle, palette: &ColorPalette) -> Result { let raw::AlertStyle { alignment, base_colors, prefix, styles } = raw; let alignment = alignment.clone().unwrap_or_default().into(); let base_style = TextStyle::colored(base_colors.resolve(palette)?); let prefix = prefix.as_deref().unwrap_or(DEFAULT_BLOCK_QUOTE_PREFIX).to_string(); let styles = AlertTypeStyles::new(styles, base_style, palette)?; Ok(Self { alignment, base_style, prefix, styles }) } } #[derive(Clone, Debug)] pub(crate) struct AlertTypeStyles { pub(crate) note: AlertTypeStyle, pub(crate) tip: AlertTypeStyle, pub(crate) important: AlertTypeStyle, pub(crate) warning: AlertTypeStyle, pub(crate) caution: AlertTypeStyle, } impl AlertTypeStyles { fn new( raw: &raw::AlertTypeStyles, base_style: TextStyle, palette: &ColorPalette, ) -> Result { let raw::AlertTypeStyles { note, tip, important, warning, caution } = raw; Ok(Self { note: AlertTypeStyle::new( note, &AlertTypeDefaults { title: "Note", icon: "󰋽", color: Color::Blue }, base_style, palette, )?, tip: AlertTypeStyle::new( tip, &AlertTypeDefaults { title: "Tip", icon: "", color: Color::Green }, base_style, palette, )?, important: AlertTypeStyle::new( important, &AlertTypeDefaults { title: "Important", icon: "", color: Color::Cyan }, base_style, palette, )?, warning: AlertTypeStyle::new( warning, &AlertTypeDefaults { title: "Warning", icon: "", color: Color::Yellow }, base_style, palette, )?, caution: AlertTypeStyle::new( caution, &AlertTypeDefaults { title: "Caution", icon: "󰳦", color: Color::Red }, base_style, palette, )?, }) } } #[derive(Clone, Debug)] pub(crate) struct AlertTypeStyle { pub(crate) style: TextStyle, pub(crate) title: String, pub(crate) icon: String, } impl AlertTypeStyle { fn new( raw: &raw::AlertTypeStyle, defaults: &AlertTypeDefaults, base_style: TextStyle, palette: &ColorPalette, ) -> Result { let raw::AlertTypeStyle { color, title, icon, .. } = raw; let color = color.as_ref().map(|c| c.resolve(palette)).transpose()?.flatten().unwrap_or(defaults.color); let style = base_style.fg_color(color); let title = title.as_deref().unwrap_or(defaults.title).to_string(); let icon = icon.as_deref().unwrap_or(defaults.icon).to_string(); Ok(Self { style, title, icon }) } } struct AlertTypeDefaults { title: &'static str, icon: &'static str, color: Color, } #[derive(Clone, Debug)] pub(crate) struct IntroSlideStyle { pub(crate) title: IntroSlideTitleStyle, pub(crate) subtitle: IntroSlideLabelStyle, pub(crate) event: IntroSlideLabelStyle, pub(crate) location: IntroSlideLabelStyle, pub(crate) date: IntroSlideLabelStyle, pub(crate) author: AuthorStyle, pub(crate) footer: bool, } impl IntroSlideStyle { fn new( raw: &raw::IntroSlideStyle, palette: &ColorPalette, options: &ThemeOptions, ) -> Result { let raw::IntroSlideStyle { title, subtitle, event, location, date, author, footer } = raw; Ok(Self { title: IntroSlideTitleStyle::new(title, palette, options)?, subtitle: IntroSlideLabelStyle::new(subtitle, palette)?, event: IntroSlideLabelStyle::new(event, palette)?, location: IntroSlideLabelStyle::new(location, palette)?, date: IntroSlideLabelStyle::new(date, palette)?, author: AuthorStyle::new(author, palette)?, footer: footer.unwrap_or(false), }) } } #[derive(Clone, Debug, Default)] pub(crate) struct IntroSlideLabelStyle { pub(crate) alignment: Alignment, pub(crate) style: TextStyle, } impl IntroSlideLabelStyle { fn new(raw: &raw::BasicStyle, palette: &ColorPalette) -> Result { let raw::BasicStyle { alignment, colors } = raw; let style = TextStyle::colored(colors.resolve(palette)?); Ok(Self { alignment: alignment.clone().unwrap_or_default().into(), style }) } } #[derive(Clone, Debug, Default)] pub(crate) struct IntroSlideTitleStyle { pub(crate) alignment: Alignment, pub(crate) style: TextStyle, } impl IntroSlideTitleStyle { fn new( raw: &raw::IntroSlideTitleStyle, palette: &ColorPalette, options: &ThemeOptions, ) -> Result { let raw::IntroSlideTitleStyle { alignment, colors, font_size } = raw; let style = TextStyle::colored(colors.resolve(palette)?).size(options.adjust_font_size(*font_size)); Ok(Self { alignment: alignment.clone().unwrap_or_default().into(), style }) } } #[derive(Clone, Debug, Default)] pub(crate) struct AuthorStyle { pub(crate) alignment: Alignment, pub(crate) style: TextStyle, pub(crate) positioning: AuthorPositioning, } impl AuthorStyle { fn new(raw: &raw::AuthorStyle, palette: &ColorPalette) -> Result { let raw::AuthorStyle { alignment, colors, positioning } = raw; let style = TextStyle::colored(colors.resolve(palette)?); Ok(Self { alignment: alignment.clone().unwrap_or_default().into(), style, positioning: positioning.clone() }) } } #[derive(Clone, Debug, Default)] pub(crate) struct DefaultStyle { pub(crate) margin: Margin, pub(crate) style: TextStyle, } impl DefaultStyle { fn new(raw: &raw::DefaultStyle, palette: &ColorPalette) -> Result { let raw::DefaultStyle { margin, colors } = raw; let margin = margin.unwrap_or_default(); let style = TextStyle::colored(colors.resolve(palette)?); Ok(Self { margin, style }) } } #[derive(Copy, Clone, Debug, PartialEq)] pub(crate) enum Alignment { Left { margin: Margin }, Right { margin: Margin }, Center { minimum_margin: Margin, minimum_size: u16 }, } impl Alignment { pub(crate) fn adjust_size(&self, size: u16) -> u16 { match self { Self::Left { .. } | Self::Right { .. } => size, Self::Center { minimum_size, .. } => size.max(*minimum_size), } } } impl From for Alignment { fn from(alignment: raw::Alignment) -> Self { match alignment { raw::Alignment::Left { margin } => Self::Left { margin }, raw::Alignment::Right { margin } => Self::Right { margin }, raw::Alignment::Center { minimum_margin, minimum_size } => Self::Center { minimum_margin, minimum_size }, } } } impl Default for Alignment { fn default() -> Self { Self::Left { margin: Margin::Fixed(0) } } } #[derive(Clone, Debug, Default)] pub(crate) enum FooterStyle { Template { left: Option, center: Option, right: Option, style: TextStyle, height: u16, }, ProgressBar { character: char, style: TextStyle, }, #[default] Empty, } impl FooterStyle { fn new( raw: &raw::FooterStyle, palette: &ColorPalette, resources: &Resources, ) -> Result { match raw { raw::FooterStyle::Template { left, center, right, colors, height } => { let left = left.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?; let center = center.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?; let right = right.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?; let style = TextStyle::colored(colors.resolve(palette)?); let height = height.unwrap_or(DEFAULT_FOOTER_HEIGHT); Ok(Self::Template { left, center, right, style, height }) } raw::FooterStyle::ProgressBar { character, colors } => { let character = character.unwrap_or(DEFAULT_PROGRESS_BAR_CHAR); let style = TextStyle::colored(colors.resolve(palette)?); Ok(Self::ProgressBar { character, style }) } raw::FooterStyle::Empty => Ok(Self::Empty), } } pub(crate) fn height(&self) -> u16 { match self { Self::Template { height, .. } => *height, _ => DEFAULT_FOOTER_HEIGHT, } } } #[derive(Clone, Debug)] pub(crate) enum FooterContent { Template(FooterTemplate), Image(Image), } impl FooterContent { fn new(raw: &raw::FooterContent, resources: &Resources) -> Result { match raw { raw::FooterContent::Template(template) => Ok(Self::Template(template.clone())), raw::FooterContent::Image { path } => { let image = resources.theme_image(path).map_err(ProcessingThemeError::FooterImage)?; Ok(Self::Image(image)) } } } } #[derive(Clone, Debug, Default)] pub(crate) struct CodeBlockStyle { pub(crate) alignment: Alignment, pub(crate) padding: PaddingRect, pub(crate) theme_name: String, pub(crate) background: bool, } impl CodeBlockStyle { fn new(raw: &raw::CodeBlockStyle) -> Self { let raw::CodeBlockStyle { alignment, padding, theme_name, background } = raw; let padding = PaddingRect { horizontal: padding.horizontal.unwrap_or_default(), vertical: padding.vertical.unwrap_or_default(), }; Self { alignment: alignment.clone().unwrap_or_default().into(), padding, theme_name: theme_name.as_deref().unwrap_or(DEFAULT_CODE_HIGHLIGHT_THEME).to_string(), background: background.unwrap_or(true), } } } /// Vertical/horizontal padding. #[derive(Clone, Copy, Debug, Default)] pub(crate) struct PaddingRect { /// The number of columns to use as horizontal padding. pub(crate) horizontal: u8, /// The number of rows to use as vertical padding. pub(crate) vertical: u8, } #[derive(Clone, Debug, Default)] pub(crate) struct ExecutionOutputBlockStyle { pub(crate) style: TextStyle, pub(crate) status: ExecutionStatusBlockStyle, pub(crate) padding: PaddingRect, } impl ExecutionOutputBlockStyle { fn new(raw: &raw::ExecutionOutputBlockStyle, palette: &ColorPalette) -> Result { let raw::ExecutionOutputBlockStyle { colors, status, padding } = raw; let colors = colors.resolve(palette)?; let style = TextStyle::colored(colors); let padding = PaddingRect { horizontal: padding.horizontal.unwrap_or_default(), vertical: padding.vertical.unwrap_or_default(), }; Ok(Self { style, status: ExecutionStatusBlockStyle::new(status, palette)?, padding }) } } #[derive(Copy, Clone, Debug, Default)] pub(crate) struct ExecutionStatusBlockStyle { pub(crate) running_style: TextStyle, pub(crate) success_style: TextStyle, pub(crate) failure_style: TextStyle, pub(crate) not_started_style: TextStyle, } impl ExecutionStatusBlockStyle { fn new(raw: &raw::ExecutionStatusBlockStyle, palette: &ColorPalette) -> Result { let raw::ExecutionStatusBlockStyle { running, success, failure, not_started } = raw; let running_style = TextStyle::colored(running.resolve(palette)?); let success_style = TextStyle::colored(success.resolve(palette)?); let failure_style = TextStyle::colored(failure.resolve(palette)?); let not_started_style = TextStyle::colored(not_started.resolve(palette)?); Ok(Self { running_style, success_style, failure_style, not_started_style }) } } #[derive(Clone, Debug, Default)] pub(crate) struct InlineCodeStyle { pub(crate) style: TextStyle, } impl InlineCodeStyle { fn new(raw: &raw::InlineCodeStyle, palette: &ColorPalette) -> Result { let raw::InlineCodeStyle { colors } = raw; let style = TextStyle::colored(colors.resolve(palette)?); Ok(Self { style }) } } #[derive(Clone, Debug)] pub(crate) enum ElementType { SlideTitle, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Paragraph, PresentationTitle, PresentationSubTitle, PresentationEvent, PresentationLocation, PresentationDate, PresentationAuthor, Table, } #[derive(Clone, Debug)] pub(crate) struct TypstStyle { pub(crate) horizontal_margin: u16, pub(crate) vertical_margin: u16, pub(crate) style: TextStyle, } impl TypstStyle { fn new(raw: &raw::TypstStyle, palette: &ColorPalette) -> Result { let raw::TypstStyle { horizontal_margin, vertical_margin, colors } = raw; let horizontal_margin = horizontal_margin.unwrap_or(DEFAULT_TYPST_HORIZONTAL_MARGIN); let vertical_margin = vertical_margin.unwrap_or(DEFAULT_TYPST_VERTICAL_MARGIN); let style = TextStyle::colored(colors.resolve(palette)?); Ok(Self { horizontal_margin, vertical_margin, style }) } } #[derive(Clone, Debug)] pub(crate) struct MermaidStyle { pub(crate) theme: String, pub(crate) background: String, } impl MermaidStyle { fn new(raw: &raw::MermaidStyle) -> Self { let raw::MermaidStyle { theme, background } = raw; let theme = theme.as_deref().unwrap_or(DEFAULT_MERMAID_THEME).to_string(); let background = background.as_deref().unwrap_or(DEFAULT_MERMAID_BACKGROUND).to_string(); Self { theme, background } } } #[derive(Clone, Debug)] pub(crate) struct D2Style { pub(crate) theme: String, } impl D2Style { fn new(raw: &raw::D2Style) -> Self { let raw::D2Style { theme } = raw; let theme = theme.unwrap_or(DEFAULT_D2_THEME).to_string(); Self { theme } } } #[derive(Clone, Debug)] pub(crate) struct ModalStyle { pub(crate) style: TextStyle, pub(crate) selection_style: TextStyle, } impl ModalStyle { fn new( raw: &raw::ModalStyle, default_style: &DefaultStyle, palette: &ColorPalette, ) -> Result { let raw::ModalStyle { colors, selection_colors } = raw; let mut style = default_style.style; style.merge(&TextStyle::colored(colors.resolve(palette)?)); let mut selection_style = style.bold(); selection_style.merge(&TextStyle::colored(selection_colors.resolve(palette)?)); Ok(Self { style, selection_style }) } } /// The color palette. #[derive(Clone, Debug, Default)] pub(crate) struct ColorPalette { pub(crate) colors: BTreeMap, pub(crate) classes: BTreeMap, } impl TryFrom<&raw::ColorPalette> for ColorPalette { type Error = ProcessingThemeError; fn try_from(palette: &raw::ColorPalette) -> Result { let mut colors = BTreeMap::new(); let mut classes = BTreeMap::new(); for (name, color) in &palette.colors { let raw::RawColor::Color(color) = color else { return Err(ProcessingThemeError::PaletteColorInPalette); }; colors.insert(name.clone(), *color); } let resolve_local = |color: &RawColor| match color { raw::RawColor::Color(c) => Ok(*c), raw::RawColor::Palette(name) => colors .get(name) .copied() .ok_or_else(|| ProcessingThemeError::Palette(UndefinedPaletteColorError(name.clone()))), _ => Err(ProcessingThemeError::PaletteColorInPalette), }; for (name, colors) in &palette.classes { let foreground = colors.foreground.as_ref().map(resolve_local).transpose()?; let background = colors.background.as_ref().map(resolve_local).transpose()?; classes.insert(name.clone(), Colors { foreground, background }); } Ok(Self { colors, classes }) } } presenterm-0.15.1/src/theme/mod.rs000064400000000000000000000002631046102023000151130ustar 00000000000000pub(crate) mod clean; pub(crate) mod raw; pub(crate) mod registry; pub(crate) use clean::*; pub(crate) use raw::{AuthorPositioning, FooterTemplate, FooterTemplateChunk, Margin}; presenterm-0.15.1/src/theme/raw.rs000064400000000000000000000734051046102023000151350ustar 00000000000000use super::registry::LoadThemeError; use crate::markdown::text_style::{Color, Colors, UndefinedPaletteColorError}; use hex::{FromHex, FromHexError}; use serde::{Deserialize, Serialize, de::Visitor}; use std::{ collections::BTreeMap, fmt, fs, path::{Path, PathBuf}, str::FromStr, }; pub(crate) type RawColors = Colors; /// A presentation theme. #[derive(Default, Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct PresentationTheme { /// The theme this theme extends from. #[serde(default)] pub(crate) extends: Option, /// The style for a slide's title. #[serde(default)] pub(crate) slide_title: SlideTitleStyle, /// The style for a block of code. #[serde(default)] pub(crate) code: CodeBlockStyle, /// The style for the execution output of a piece of code. #[serde(default)] pub(crate) execution_output: ExecutionOutputBlockStyle, /// The style for inline code. #[serde(default)] pub(crate) inline_code: InlineCodeStyle, /// The style for a table. #[serde(default)] pub(crate) table: Option, /// The style for a block quote. #[serde(default)] pub(crate) block_quote: BlockQuoteStyle, /// The style for an alert. #[serde(default)] pub(crate) alert: AlertStyle, /// The default style. #[serde(rename = "default", default)] pub(crate) default_style: DefaultStyle, //// The style of all headings. #[serde(default)] pub(crate) headings: HeadingStyles, /// The style of the introduction slide. #[serde(default)] pub(crate) intro_slide: IntroSlideStyle, /// The style of the presentation footer. #[serde(default)] pub(crate) footer: Option, /// The style for typst auto-rendered code blocks. #[serde(default)] pub(crate) typst: TypstStyle, /// The style for mermaid auto-rendered code blocks. #[serde(default)] pub(crate) mermaid: MermaidStyle, /// The style for d2 auto-rendered code blocks. #[serde(default)] pub(crate) d2: D2Style, /// The style for modals. #[serde(default)] pub(crate) modals: ModalStyle, /// The color palette. #[serde(default)] pub(crate) palette: ColorPalette, } impl PresentationTheme { /// Construct a presentation from a path. pub(crate) fn from_path>(path: P) -> Result { let contents = fs::read_to_string(&path).map_err(|e| LoadThemeError::Reading(path.as_ref().into(), e))?; let theme = serde_yaml::from_str(&contents) .map_err(|e| LoadThemeError::Corrupted(path.as_ref().display().to_string(), e.into()))?; Ok(theme) } } /// The style of a slide title. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct SlideTitleStyle { /// The alignment. #[serde(flatten, default)] pub(crate) alignment: Option, /// Whether to use a separator line. #[serde(default)] pub(crate) separator: bool, /// The padding that should be added before the text. #[serde(default)] pub(crate) padding_top: Option, /// The padding that should be added after the text. #[serde(default)] pub(crate) padding_bottom: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, /// Whether to use bold font for slide titles. #[serde(default)] pub(crate) bold: Option, /// Whether to use italics font for slide titles. #[serde(default)] pub(crate) italics: Option, /// Whether to use underlined font for slide titles. #[serde(default)] pub(crate) underlined: Option, /// The font size to be used if the terminal supports it. #[serde(default)] pub(crate) font_size: Option, } /// The style for all headings. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct HeadingStyles { /// H1 style. #[serde(default)] pub(crate) h1: HeadingStyle, /// H2 style. #[serde(default)] pub(crate) h2: HeadingStyle, /// H3 style. #[serde(default)] pub(crate) h3: HeadingStyle, /// H4 style. #[serde(default)] pub(crate) h4: HeadingStyle, /// H5 style. #[serde(default)] pub(crate) h5: HeadingStyle, /// H6 style. #[serde(default)] pub(crate) h6: HeadingStyle, } /// The style for a heading. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct HeadingStyle { /// The alignment. #[serde(flatten, default)] pub(crate) alignment: Option, /// The prefix to be added to this heading. /// /// This allows adding text like "->" to every heading. #[serde(default)] pub(crate) prefix: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, /// The font size to be used if the terminal supports it. #[serde(default)] pub(crate) font_size: Option, } /// The style of a block quote. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct BlockQuoteStyle { /// The alignment. #[serde(flatten, default)] pub(crate) alignment: Option, /// The prefix to be added to this block quote. /// /// This allows adding something like a vertical bar before the text. #[serde(default)] pub(crate) prefix: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: BlockQuoteColors, } /// The colors of a block quote. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct BlockQuoteColors { /// The foreground/background colors. #[serde(flatten)] pub(crate) base: RawColors, /// The color of the vertical bar that prefixes each line in the quote. #[serde(default)] pub(crate) prefix: Option, } /// The style of an alert. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct AlertStyle { /// The alignment. #[serde(flatten, default)] pub(crate) alignment: Option, /// The base colors. #[serde(default)] pub(crate) base_colors: RawColors, /// The prefix to be added to this block quote. /// /// This allows adding something like a vertical bar before the text. #[serde(default)] pub(crate) prefix: Option, /// The style for each alert type. #[serde(default)] pub(crate) styles: AlertTypeStyles, } /// The style for each alert type. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct AlertTypeStyles { /// The style for note alert types. #[serde(default)] pub(crate) note: AlertTypeStyle, /// The style for tip alert types. #[serde(default)] pub(crate) tip: AlertTypeStyle, /// The style for important alert types. #[serde(default)] pub(crate) important: AlertTypeStyle, /// The style for warning alert types. #[serde(default)] pub(crate) warning: AlertTypeStyle, /// The style for caution alert types. #[serde(default)] pub(crate) caution: AlertTypeStyle, } /// The style for an alert type. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct AlertTypeStyle { /// The color to be used. #[serde(default)] pub(crate) color: Option, /// The title to be used. #[serde(default)] pub(crate) title: Option, /// The icon to be used. #[serde(default)] pub(crate) icon: Option, } /// The style for the presentation introduction slide. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct IntroSlideStyle { /// The style of the title line. #[serde(default)] pub(crate) title: IntroSlideTitleStyle, /// The style of the subtitle line. #[serde(default)] pub(crate) subtitle: BasicStyle, /// The style of the event line. #[serde(default)] pub(crate) event: BasicStyle, /// The style of the location line. #[serde(default)] pub(crate) location: BasicStyle, /// The style of the date line. #[serde(default)] pub(crate) date: BasicStyle, /// The style of the author line. #[serde(default)] pub(crate) author: AuthorStyle, /// Whether we want a footer in the intro slide. #[serde(default)] pub(crate) footer: Option, } /// A simple style. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct DefaultStyle { /// The margin on the left/right of the screen. #[serde(default, with = "serde_yaml::with::singleton_map")] pub(crate) margin: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, } /// A simple style. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct BasicStyle { /// The alignment. #[serde(flatten, default)] pub(crate) alignment: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, } /// The intro slide title's style. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct IntroSlideTitleStyle { /// The alignment. #[serde(flatten, default)] pub(crate) alignment: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, /// The font size to be used if the terminal supports it. #[serde(default)] pub(crate) font_size: Option, } /// Text alignment. /// /// This allows anchoring presentation elements to the left, center, or right of the screen. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(tag = "alignment", rename_all = "snake_case")] pub(crate) enum Alignment { /// Left alignment. Left { /// The margin before any text. #[serde(default)] margin: Margin, }, /// Right alignment. Right { /// The margin after any text. #[serde(default)] margin: Margin, }, /// Center alignment. Center { /// The minimum margin expected. #[serde(default)] minimum_margin: Margin, /// The minimum size of this element, in columns. #[serde(default)] minimum_size: u16, }, } impl Default for Alignment { fn default() -> Self { Self::Left { margin: Margin::Fixed(0) } } } /// The style for the author line in the presentation intro slide. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct AuthorStyle { /// The alignment. #[serde(flatten, default)] pub(crate) alignment: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, /// The positioning of the author's name. #[serde(default)] pub(crate) positioning: AuthorPositioning, } /// The style of the footer that's shown in every slide. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "style", rename_all = "snake_case")] pub(crate) enum FooterStyle { /// Use a template to generate the footer. Template { /// The content to be put on the left. left: Option, /// The content to be put on the center. center: Option, /// The content to be put on the right. right: Option, /// The colors to be used. #[serde(default)] colors: RawColors, /// The height of the footer area. height: Option, }, /// Use a progress bar. ProgressBar { /// The character that will be used for the progress bar. character: Option, /// The colors to be used. #[serde(default)] colors: RawColors, }, /// No footer. Empty, } impl Default for FooterStyle { fn default() -> Self { Self::Template { left: None, center: None, right: None, colors: RawColors::default(), height: None } } } #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] pub(crate) enum FooterTemplateChunk { Literal(String), OpenBrace, ClosedBrace, CurrentSlide, TotalSlides, Author, Title, SubTitle, Event, Location, Date, } #[derive(Clone, Debug, Serialize)] #[serde(untagged)] pub(crate) enum FooterContent { Template(FooterTemplate), Image { #[serde(rename = "image")] path: PathBuf, }, } struct FooterContentVisitor; impl<'de> Visitor<'de> for FooterContentVisitor { type Value = FooterContent; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("a valid footer") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { let template = FooterTemplate::from_str(v).map_err(|e| E::custom(e.to_string()))?; Ok(FooterContent::Template(template)) } fn visit_map
(self, mut map: A) -> Result where A: serde::de::MapAccess<'de>, { let Some((key, value)): Option<(String, PathBuf)> = map.next_entry()? else { return Err(serde::de::Error::custom("invalid footer")); }; match key.as_str() { "image" => Ok(FooterContent::Image { path: value }), _ => Err(serde::de::Error::invalid_value(serde::de::Unexpected::Str(&key), &self)), } } } impl<'de> Deserialize<'de> for FooterContent { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { deserializer.deserialize_any(FooterContentVisitor) } } #[derive(Clone, Debug)] pub(crate) struct FooterTemplate(pub(crate) Vec); crate::utils::impl_deserialize_from_str!(FooterTemplate); crate::utils::impl_serialize_from_display!(FooterTemplate); impl FromStr for FooterTemplate { type Err = ParseFooterTemplateError; fn from_str(s: &str) -> Result { let mut chunks = Vec::new(); let mut chunk_start = 0; let mut in_variable = false; let mut iter = s.char_indices().peekable(); while let Some((index, c)) = iter.next() { if c == '{' { if in_variable { return Err(ParseFooterTemplateError::NestedOpenBrace); } let double_brace = iter.peek() == Some(&(index + 1, '{')); if double_brace { iter.next(); if chunk_start != index { chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string())); } chunks.push(FooterTemplateChunk::OpenBrace); chunk_start = index + 2; } else { in_variable = true; if chunk_start != index { chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string())); } chunk_start = index + 1; } } else if c == '}' { if !in_variable { let double_brace = iter.peek() == Some(&(index + 1, '}')); if double_brace { iter.next(); chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string())); chunks.push(FooterTemplateChunk::ClosedBrace); in_variable = false; chunk_start = index + 2; continue; } return Err(ParseFooterTemplateError::ClosedBraceWithoutOpen); } let variable = &s[chunk_start..index]; let chunk = match variable { "current_slide" => FooterTemplateChunk::CurrentSlide, "total_slides" => FooterTemplateChunk::TotalSlides, "author" => FooterTemplateChunk::Author, "title" => FooterTemplateChunk::Title, "sub_title" => FooterTemplateChunk::SubTitle, "event" => FooterTemplateChunk::Event, "location" => FooterTemplateChunk::Location, "date" => FooterTemplateChunk::Date, _ => return Err(ParseFooterTemplateError::UnsupportedVariable(variable.to_string())), }; chunks.push(chunk); in_variable = false; chunk_start = index + 1; } } if in_variable { return Err(ParseFooterTemplateError::TrailingBrace); } else if chunk_start != s.len() { chunks.push(FooterTemplateChunk::Literal(s[chunk_start..].to_string())); } Ok(Self(chunks)) } } impl fmt::Display for FooterTemplate { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use FooterTemplateChunk::*; for c in &self.0 { match c { Literal(l) => write!(f, "{l}"), OpenBrace => write!(f, "{{{{"), ClosedBrace => write!(f, "}}}}"), CurrentSlide => write!(f, "{{current_slide}}"), TotalSlides => write!(f, "{{total_slides}}"), Author => write!(f, "{{author}}"), Title => write!(f, "{{title}}"), SubTitle => write!(f, "{{sub_title}}"), Event => write!(f, "{{event}}"), Location => write!(f, "{{location}}"), Date => write!(f, "{{date}}"), }?; } Ok(()) } } #[derive(Debug, thiserror::Error)] pub(crate) enum ParseFooterTemplateError { #[error("found '{{' while already inside '{{' scope")] NestedOpenBrace, #[error("open '{{' was not closed")] TrailingBrace, #[error("found '}}' but no '{{' was found")] ClosedBraceWithoutOpen, #[error("unsupported variable: '{0}'")] UnsupportedVariable(String), } /// The style for a piece of code. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct CodeBlockStyle { /// The alignment. #[serde(flatten)] pub(crate) alignment: Option, /// The padding. #[serde(default)] pub(crate) padding: PaddingRect, /// The syntect theme name to use. #[serde(default)] pub(crate) theme_name: Option, /// Whether to use the theme's background color. pub(crate) background: Option, } /// The style for the output of a code execution block. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct ExecutionOutputBlockStyle { /// The colors to be used for the output pane. #[serde(default)] pub(crate) colors: RawColors, /// The colors to be used for the text that represents the status of the execution block. #[serde(default)] pub(crate) status: ExecutionStatusBlockStyle, /// The padding. #[serde(default)] pub(crate) padding: PaddingRect, } /// The style for the status of a code execution block. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct ExecutionStatusBlockStyle { /// The colors for the "running" status. #[serde(default)] pub(crate) running: RawColors, /// The colors for the "finished" status. #[serde(default)] pub(crate) success: RawColors, /// The colors for the "finished with error" status. #[serde(default)] pub(crate) failure: RawColors, /// The colors for the "not started" status. #[serde(default)] pub(crate) not_started: RawColors, } /// The style for inline code. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct InlineCodeStyle { /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, } /// Vertical/horizontal padding. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct PaddingRect { /// The number of columns to use as horizontal padding. #[serde(default)] pub(crate) horizontal: Option, /// The number of rows to use as vertical padding. #[serde(default)] pub(crate) vertical: Option, } /// A margin. #[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "snake_case")] pub(crate) enum Margin { /// A fixed number of characters. Fixed(u16), /// A percent of the screen size. Percent(u16), } impl Margin { pub(crate) fn as_characters(&self, screen_size: u16) -> u16 { match *self { Self::Fixed(value) => value, Self::Percent(percent) => { let ratio = percent as f64 / 100.0; (screen_size as f64 * ratio).ceil() as u16 } } } pub(crate) fn is_empty(&self) -> bool { matches!(self, Self::Fixed(0) | Self::Percent(0)) } } impl Default for Margin { fn default() -> Self { Self::Fixed(0) } } /// An element type. #[derive(Clone, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum ElementType { SlideTitle, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Paragraph, List, Code, PresentationTitle, PresentationSubTitle, PresentationEvent, PresentationLocation, PresentationDate, PresentationAuthor, Table, BlockQuote, } /// Where to position the author's name in the intro slide. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum AuthorPositioning { /// Right below the title. BelowTitle, /// At the bottom of the page. #[default] PageBottom, } /// Typst styles. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct TypstStyle { /// The horizontal margin on the generated images. pub(crate) horizontal_margin: Option, /// The vertical margin on the generated images. pub(crate) vertical_margin: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, } /// Mermaid styles. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct MermaidStyle { /// The mermaidjs theme to use. pub(crate) theme: Option, /// The background color to use. pub(crate) background: Option, } /// D2 styles. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct D2Style { /// The d2 theme id to use. pub(crate) theme: Option, } /// Modals style. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct ModalStyle { /// The default colors to use for everything in the modal. #[serde(default)] pub(crate) colors: RawColors, /// The colors to use for selected lines. #[serde(default)] pub(crate) selection_colors: RawColors, } /// The color palette. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct ColorPalette { #[serde(default)] pub(crate) colors: BTreeMap, #[serde(default)] pub(crate) classes: BTreeMap, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum RawColor { Color(Color), Palette(String), ForegroundClass(String), BackgroundClass(String), } crate::utils::impl_deserialize_from_str!(RawColor); crate::utils::impl_serialize_from_display!(RawColor); impl RawColor { fn new_palette(name: &str) -> Result { if name.is_empty() { Err(ParseColorError::PaletteColorEmpty) } else { Ok(Self::Palette(name.into())) } } pub(crate) fn resolve( &self, palette: &crate::theme::clean::ColorPalette, ) -> Result, UndefinedPaletteColorError> { let color = match self { Self::Color(c) => Some(*c), Self::Palette(name) => { Some(palette.colors.get(name).copied().ok_or(UndefinedPaletteColorError(name.clone()))?) } Self::ForegroundClass(name) => { palette.classes.get(name).ok_or(UndefinedPaletteColorError(name.clone()))?.foreground } Self::BackgroundClass(name) => { palette.classes.get(name).ok_or(UndefinedPaletteColorError(name.clone()))?.background } }; Ok(color) } } impl From for RawColor { fn from(color: Color) -> Self { Self::Color(color) } } impl FromStr for RawColor { type Err = ParseColorError; fn from_str(input: &str) -> Result { let output = match input { "black" => Color::Black.into(), "white" => Color::White.into(), "grey" => Color::Grey.into(), "dark_grey" => Color::DarkGrey.into(), "red" => Color::Red.into(), "dark_red" => Color::DarkRed.into(), "green" => Color::Green.into(), "dark_green" => Color::DarkGreen.into(), "blue" => Color::Blue.into(), "dark_blue" => Color::DarkBlue.into(), "yellow" => Color::Yellow.into(), "dark_yellow" => Color::DarkYellow.into(), "magenta" => Color::Magenta.into(), "dark_magenta" => Color::DarkMagenta.into(), "cyan" => Color::Cyan.into(), "dark_cyan" => Color::DarkCyan.into(), other if other.starts_with("palette:") => Self::new_palette(other.trim_start_matches("palette:"))?, other if other.starts_with("p:") => Self::new_palette(other.trim_start_matches("p:"))?, // Fallback to hex-encoded rgb _ => { let hex = match input.len() { 6 => input.to_string(), 3 => input.chars().flat_map(|c| [c, c]).collect::(), len => return Err(ParseColorError::InvalidHexLength(len)), }; let values = <[u8; 3]>::from_hex(hex)?; Color::Rgb { r: values[0], g: values[1], b: values[2] }.into() } }; Ok(output) } } impl fmt::Display for RawColor { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use Color::*; match self { Self::Color(Rgb { r, g, b }) => write!(f, "{}", hex::encode([*r, *g, *b])), Self::Color(Black) => write!(f, "black"), Self::Color(White) => write!(f, "white"), Self::Color(Grey) => write!(f, "grey"), Self::Color(DarkGrey) => write!(f, "dark_grey"), Self::Color(Red) => write!(f, "red"), Self::Color(DarkRed) => write!(f, "dark_red"), Self::Color(Green) => write!(f, "green"), Self::Color(DarkGreen) => write!(f, "dark_green"), Self::Color(Blue) => write!(f, "blue"), Self::Color(DarkBlue) => write!(f, "dark_blue"), Self::Color(Yellow) => write!(f, "yellow"), Self::Color(DarkYellow) => write!(f, "dark_yellow"), Self::Color(Magenta) => write!(f, "magenta"), Self::Color(DarkMagenta) => write!(f, "dark_magenta"), Self::Color(Cyan) => write!(f, "cyan"), Self::Color(DarkCyan) => write!(f, "dark_cyan"), Self::Palette(name) => write!(f, "palette:{name}"), Self::ForegroundClass(_) => Err(fmt::Error), Self::BackgroundClass(_) => Err(fmt::Error), } } } #[derive(thiserror::Error, Debug)] pub(crate) enum ParseColorError { #[error("invalid hex color: {0}")] Hex(#[from] FromHexError), #[error("hex color should only be 3 or 6 long, got hex string of length {0}")] InvalidHexLength(usize), #[error("palette color name is empty")] PaletteColorEmpty, } #[cfg(test)] mod test { use super::*; use rstest::rstest; #[test] fn parse_all_footer_template_variables() { use FooterTemplateChunk::*; let raw = "hi {current_slide} {total_slides} {author} {title} {sub_title} {event} {location} {event}"; let t: FooterTemplate = raw.parse().expect("invalid input"); let expected = vec![ Literal("hi ".into()), CurrentSlide, Literal(" ".into()), TotalSlides, Literal(" ".into()), Author, Literal(" ".into()), Title, Literal(" ".into()), SubTitle, Literal(" ".into()), Event, Literal(" ".into()), Location, Literal(" ".into()), Event, ]; assert_eq!(t.0, expected); assert_eq!(t.to_string(), raw); } #[test] fn parse_double_braces() { use FooterTemplateChunk::*; let raw = "hi {{beep}} {{author}} {{{{}}}}"; let t: FooterTemplate = raw.parse().expect("invalid input"); let merged: String = t.0.into_iter() .map(|l| match l { Literal(s) => s, OpenBrace => "{".to_string(), ClosedBrace => "}".to_string(), _ => panic!("not a literal"), }) .collect(); assert_eq!(merged, "hi {beep} {author} {{}}"); } #[rstest] #[case::trailing("{author")] #[case::close_without_open2("author}")] fn invalid_footer_templates(#[case] input: &str) { FooterTemplate::from_str(input).expect_err("parse succeeded"); } #[test] fn color_serde() { let color: RawColor = "beef42".parse().unwrap(); assert_eq!(color.to_string(), "beef42"); let short_color: RawColor = "ded".parse().unwrap(); assert_eq!(short_color.to_string(), "ddeedd"); } #[rstest] #[case::empty1("p:")] #[case::empty2("palette:")] fn invalid_palette_color_names(#[case] input: &str) { RawColor::from_str(input).expect_err("not an error"); } #[rstest] #[case::short("p:hi", "hi")] #[case::long("palette:bye", "bye")] fn valid_palette_color_names(#[case] input: &str, #[case] expected: &str) { let color = RawColor::from_str(input).expect("failed to parse"); let RawColor::Palette(name) = color else { panic!("not a palette color") }; assert_eq!(name, expected); } } presenterm-0.15.1/src/theme/registry.rs000064400000000000000000000225101046102023000162030ustar 00000000000000use super::raw::PresentationTheme; use std::{ collections::BTreeMap, fs, io, path::{Path, PathBuf}, }; include!(concat!(env!("OUT_DIR"), "/themes.rs")); #[derive(Default)] pub struct PresentationThemeRegistry { custom_themes: BTreeMap, } impl PresentationThemeRegistry { /// Loads a theme from its name. pub fn load_by_name(&self, name: &str) -> Option { match THEMES.get(name) { Some(contents) => { // This is going to be caught by the test down here. let theme = serde_yaml::from_slice(contents).expect("corrupted theme"); Some(theme) } None => self.custom_themes.get(name).cloned(), } } /// Register all the themes in the given directory. pub fn register_from_directory>(&mut self, path: P) -> Result<(), LoadThemeError> { let handle = match fs::read_dir(&path) { Ok(handle) => handle, Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()), Err(e) => return Err(e.into()), }; let mut dependencies = BTreeMap::new(); for entry in handle { let entry = entry?; let Some(file_name) = entry.file_name().to_str().map(ToOwned::to_owned) else { continue; }; if file_name.ends_with(".yaml") { let theme_name = file_name.trim_end_matches(".yaml"); if THEMES.contains_key(theme_name) { return Err(LoadThemeError::Duplicate(theme_name.into())); } let theme = PresentationTheme::from_path(entry.path())?; let base = theme.extends.clone(); self.custom_themes.insert(theme_name.into(), theme); dependencies.insert(theme_name.to_string(), base); } } let mut graph = ThemeGraph::new(dependencies); for theme_name in graph.dependents.keys() { let theme_name = theme_name.as_str(); if !THEMES.contains_key(theme_name) && !self.custom_themes.contains_key(theme_name) { return Err(LoadThemeError::ExtendedThemeNotFound(theme_name.into())); } } while let Some(theme_name) = graph.pop() { self.extend_theme(&theme_name)?; } if !graph.dependents.is_empty() { return Err(LoadThemeError::ExtensionLoop(graph.dependents.into_keys().collect())); } Ok(()) } fn extend_theme(&mut self, theme_name: &str) -> Result<(), LoadThemeError> { let Some(base_name) = self.custom_themes.get(theme_name).expect("theme not found").extends.clone() else { return Ok(()); }; let Some(base_theme) = self.load_by_name(&base_name) else { return Err(LoadThemeError::ExtendedThemeNotFound(base_name.clone())); }; let theme = self.custom_themes.get_mut(theme_name).expect("theme not found"); *theme = merge_struct::merge(&base_theme, theme) .map_err(|e| LoadThemeError::Corrupted(base_name.to_string(), e.into()))?; Ok(()) } /// Get all the registered theme names. pub fn theme_names(&self) -> Vec { let builtin_themes = THEMES.keys().map(|name| name.to_string()); let themes = self.custom_themes.keys().cloned().chain(builtin_themes).collect(); themes } } struct ThemeGraph { dependents: BTreeMap>, ready: Vec, } impl ThemeGraph { fn new(dependencies: I) -> Self where I: IntoIterator)>, { let mut dependents: BTreeMap<_, Vec<_>> = BTreeMap::new(); let mut ready = Vec::new(); for (name, extends) in dependencies { dependents.entry(name.clone()).or_default(); match extends { // If we extend from a non built in theme, make ourselves their dependent Some(base) if !THEMES.contains_key(base.as_str()) => { dependents.entry(base).or_default().push(name); } // Otherwise this theme is ready to be processed _ => ready.push(name), } } Self { dependents, ready } } fn pop(&mut self) -> Option { let theme = self.ready.pop()?; if let Some(dependents) = self.dependents.remove(&theme) { self.ready.extend(dependents); } Some(theme) } } /// An error loading a presentation theme. #[derive(thiserror::Error, Debug)] pub enum LoadThemeError { #[error(transparent)] Io(#[from] io::Error), #[error("failed to read custom theme {0:?}: {1}")] Reading(PathBuf, io::Error), #[error("theme '{0}' is corrupted: {1}")] Corrupted(String, Box), #[error("duplicate custom theme '{0}'")] Duplicate(String), #[error("extended theme does not exist: {0}")] ExtendedThemeNotFound(String), #[error("theme has an extension loop involving: {0:?}")] ExtensionLoop(Vec), } #[cfg(test)] mod test { use crate::resource::Resources; use super::*; use tempfile::{TempDir, tempdir}; fn write_theme(name: &str, theme: PresentationTheme, directory: &TempDir) { let theme = serde_yaml::to_string(&theme).unwrap(); let file_name = format!("{name}.yaml"); fs::write(directory.path().join(file_name), theme).expect("writing theme"); } #[test] fn validate_themes() { let themes = PresentationThemeRegistry::default(); for theme_name in THEMES.keys() { let Some(theme) = themes.load_by_name(theme_name).clone() else { panic!("theme '{theme_name}' is corrupted"); }; // Built-in themes can't use this because... I don't feel like supporting this now. assert!(theme.extends.is_none(), "theme '{theme_name}' uses extends"); let merged = merge_struct::merge(&PresentationTheme::default(), &theme); assert!(merged.is_ok(), "theme '{theme_name}' can't be merged: {}", merged.unwrap_err()); let resources = Resources::new("/tmp/foo", "/tmp/foo", Default::default()); crate::theme::PresentationTheme::new(&theme, &resources, &Default::default()).expect("malformed theme"); } } #[test] fn load_custom() { let directory = tempdir().expect("creating tempdir"); write_theme( "potato", PresentationTheme { extends: Some("dark".to_string()), ..Default::default() }, &directory, ); let mut themes = PresentationThemeRegistry::default(); themes.register_from_directory(directory.path()).expect("loading themes"); let mut theme = themes.load_by_name("potato").expect("theme not found"); // Since we extend the dark theme they must match after we remove the "extends" field. let dark = themes.load_by_name("dark"); theme.extends.take().expect("no extends"); assert_eq!(serde_yaml::to_string(&theme).unwrap(), serde_yaml::to_string(&dark).unwrap()); } #[test] fn load_derive_chain() { let directory = tempdir().expect("creating tempdir"); write_theme("A", PresentationTheme { extends: Some("dark".to_string()), ..Default::default() }, &directory); write_theme("B", PresentationTheme { extends: Some("C".to_string()), ..Default::default() }, &directory); write_theme("C", PresentationTheme { extends: Some("A".to_string()), ..Default::default() }, &directory); write_theme("D", PresentationTheme::default(), &directory); let mut themes = PresentationThemeRegistry::default(); themes.register_from_directory(directory.path()).expect("loading themes"); themes.load_by_name("A").expect("A not found"); themes.load_by_name("B").expect("B not found"); themes.load_by_name("C").expect("C not found"); themes.load_by_name("D").expect("D not found"); } #[test] fn invalid_derives() { let directory = tempdir().expect("creating tempdir"); write_theme( "A", PresentationTheme { extends: Some("non-existent-theme".to_string()), ..Default::default() }, &directory, ); let mut themes = PresentationThemeRegistry::default(); themes.register_from_directory(directory.path()).expect_err("loading themes succeeded"); } #[test] fn load_derive_chain_loop() { let directory = tempdir().expect("creating tempdir"); write_theme("A", PresentationTheme { extends: Some("B".to_string()), ..Default::default() }, &directory); write_theme("B", PresentationTheme { extends: Some("A".to_string()), ..Default::default() }, &directory); let mut themes = PresentationThemeRegistry::default(); let err = themes.register_from_directory(directory.path()).expect_err("loading themes succeeded"); let LoadThemeError::ExtensionLoop(names) = err else { panic!("not an extension loop error") }; assert_eq!(names, &["A", "B"]); } #[test] fn register_from_missing_directory() { let mut themes = PresentationThemeRegistry::default(); let result = themes.register_from_directory("/tmp/presenterm/8ee2027983915ec78acc45027d874316"); result.expect("loading failed"); } } presenterm-0.15.1/src/third_party.rs000064400000000000000000000332141046102023000155650ustar 00000000000000use crate::{ ImageRegistry, config::{default_mermaid_scale, default_snippet_render_threads, default_typst_ppi}, markdown::{ elements::{Line, Percent, Text}, text_style::{Color, TextStyle}, }, render::{ operation::{ AsRenderOperations, ImageRenderProperties, ImageSize, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy, RenderOperation, }, properties::WindowSize, }, terminal::image::{ Image, printer::{ImageSpec, RegisterImageError}, }, theme::{Alignment, D2Style, MermaidStyle, PresentationTheme, TypstStyle, raw::RawColor}, tools::{ExecutionError, ThirdPartyTools}, }; use std::{ collections::{HashMap, VecDeque}, fs, io, mem, path::Path, rc::Rc, sync::{Arc, Condvar, Mutex}, thread, }; pub struct ThirdPartyConfigs { pub typst_ppi: String, pub mermaid_scale: String, pub d2_scale: String, pub threads: usize, } pub struct ThirdPartyRender { render_pool: RenderPool, } impl ThirdPartyRender { pub fn new(config: ThirdPartyConfigs, image_registry: ImageRegistry, root_dir: &Path) -> Self { // typst complains about empty paths so we give it a "." if we don't have one. let root_dir = match root_dir.to_string_lossy().to_string() { path if path.is_empty() => ".".into(), path => path, }; let render_pool = RenderPool::new(config, root_dir, image_registry); Self { render_pool } } pub(crate) fn render( &self, request: ThirdPartyRenderRequest, theme: &PresentationTheme, width: Option, ) -> Result { let result = self.render_pool.render(request); let operation = Rc::new(RenderThirdParty::new(result, theme.default_style.style, width)); Ok(RenderOperation::RenderAsync(operation)) } } impl Default for ThirdPartyRender { fn default() -> Self { let config = ThirdPartyConfigs { typst_ppi: default_typst_ppi().to_string(), mermaid_scale: default_mermaid_scale().to_string(), d2_scale: "-1".to_string(), threads: default_snippet_render_threads(), }; Self::new(config, Default::default(), Path::new(".")) } } #[derive(Debug)] pub(crate) enum ThirdPartyRenderRequest { Typst(String, TypstStyle), Latex(String, TypstStyle), Mermaid(String, MermaidStyle), D2(String, D2Style), } #[derive(Debug, Default)] enum RenderResult { Success(Image), Failure(String), #[default] Pending, } struct RenderPoolState { requests: VecDeque<(ThirdPartyRenderRequest, Arc>)>, image_registry: ImageRegistry, cache: HashMap, } struct Shared { config: ThirdPartyConfigs, root_dir: String, signal: Condvar, } struct RenderPool { state: Arc>, shared: Arc, } impl RenderPool { fn new(config: ThirdPartyConfigs, root_dir: String, image_registry: ImageRegistry) -> Self { let threads = config.threads; let shared = Shared { config, root_dir, signal: Default::default() }; let state = RenderPoolState { requests: Default::default(), image_registry, cache: Default::default() }; let this = Self { state: Arc::new(Mutex::new(state)), shared: Arc::new(shared) }; for _ in 0..threads { let worker = Worker { state: this.state.clone(), shared: this.shared.clone() }; thread::spawn(move || worker.run()); } this } fn render(&self, request: ThirdPartyRenderRequest) -> Arc> { let result: Arc> = Default::default(); let mut state = self.state.lock().expect("lock poisoned"); state.requests.push_back((request, result.clone())); self.shared.signal.notify_one(); result } } struct Worker { state: Arc>, shared: Arc, } impl Worker { fn run(self) { loop { let mut state = self.state.lock().unwrap(); let (request, result) = loop { let Some((request, result)) = state.requests.pop_front() else { state = self.shared.signal.wait(state).unwrap(); continue; }; break (request, result); }; drop(state); self.render(request, result); } } fn render(&self, request: ThirdPartyRenderRequest, result: Arc>) { let output = match request { ThirdPartyRenderRequest::Typst(input, style) => self.render_typst(input, &style), ThirdPartyRenderRequest::Latex(input, style) => self.render_latex(input, &style), ThirdPartyRenderRequest::Mermaid(input, style) => self.render_mermaid(input, &style), ThirdPartyRenderRequest::D2(input, style) => self.render_d2(input, &style), }; let mut result = result.lock().unwrap(); match output { Ok(image) => *result = RenderResult::Success(image), Err(error) => *result = RenderResult::Failure(error.to_string()), }; } pub(crate) fn render_typst(&self, input: String, style: &TypstStyle) -> Result { let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::Typst }; if let Some(image) = self.state.lock().unwrap().cache.get(&snippet).cloned() { return Ok(image); } self.do_render_typst(snippet, &input, style) } pub(crate) fn render_latex(&self, input: String, style: &TypstStyle) -> Result { let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::Latex }; if let Some(image) = self.state.lock().unwrap().cache.get(&snippet).cloned() { return Ok(image); } let output = ThirdPartyTools::pandoc(&["--from", "latex", "--to", "typst"]) .stdin(input.as_bytes().into()) .run_and_capture_stdout()?; let input = String::from_utf8_lossy(&output); self.do_render_typst(snippet, &input, style) } pub(crate) fn render_mermaid(&self, input: String, style: &MermaidStyle) -> Result { let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::Mermaid }; if let Some(image) = self.state.lock().unwrap().cache.get(&snippet).cloned() { return Ok(image); } let workdir = tempfile::Builder::default().prefix(".presenterm").tempdir()?; let output_path = workdir.path().join("output.png"); let input_path = workdir.path().join("input.mmd"); fs::write(&input_path, input)?; ThirdPartyTools::mermaid(&[ "-i", &input_path.to_string_lossy(), "-o", &output_path.to_string_lossy(), "-s", &self.shared.config.mermaid_scale, "-t", &style.theme, "-b", &style.background, ]) .run()?; self.load_image(snippet, &output_path) } pub(crate) fn render_d2(&self, input: String, style: &D2Style) -> Result { let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::D2 }; let workdir = tempfile::Builder::default().prefix(".presenterm").tempdir()?; let output_path = workdir.path().join("output.png"); let input_path = workdir.path().join("input.d2"); fs::write(&input_path, input)?; ThirdPartyTools::d2(&[ &input_path.to_string_lossy(), &output_path.to_string_lossy(), "--pad", "0", "--scale", &self.shared.config.d2_scale, "--theme", &style.theme, ]) .run()?; self.load_image(snippet, &output_path) } fn do_render_typst( &self, snippet: ImageSnippet, input: &str, style: &TypstStyle, ) -> Result { let workdir = tempfile::Builder::default().prefix(".presenterm").tempdir_in(&self.shared.root_dir)?; let mut typst_input = Self::generate_page_header(style)?; typst_input.push_str(input); let input_path = workdir.path().join("input.typst"); fs::write(&input_path, &typst_input)?; let output_path = workdir.path().join("output.png"); ThirdPartyTools::typst(&[ "compile", "--format", "png", "--root", &self.shared.root_dir, "--ppi", &self.shared.config.typst_ppi, &input_path.to_string_lossy(), &output_path.to_string_lossy(), ]) .run()?; self.load_image(snippet, &output_path) } fn generate_page_header(style: &TypstStyle) -> Result { let x_margin = style.horizontal_margin; let y_margin = style.vertical_margin; let background = style .style .colors .background .as_ref() .map(Self::as_typst_color) .unwrap_or_else(|| Ok(String::from("none")))?; let mut header = format!( "#set page(width: auto, height: auto, margin: (x: {x_margin}pt, y: {y_margin}pt), fill: {background})\n" ); if let Some(color) = &style.style.colors.foreground { let color = Self::as_typst_color(color)?; header.push_str(&format!("#set text(fill: {color})\n")); } Ok(header) } fn as_typst_color(color: &Color) -> Result { match color.as_rgb() { Some((r, g, b)) => Ok(format!("rgb(\"#{r:02x}{g:02x}{b:02x}\")")), None => Err(ThirdPartyRenderError::UnsupportedColor(RawColor::from(*color).to_string())), } } fn load_image(&self, snippet: ImageSnippet, path: &Path) -> Result { let contents = fs::read(path)?; let image = image::load_from_memory(&contents)?; let image = self.state.lock().unwrap().image_registry.register(ImageSpec::Generated(image))?; self.state.lock().unwrap().cache.insert(snippet, image.clone()); Ok(image) } } #[derive(Debug, thiserror::Error)] pub enum ThirdPartyRenderError { #[error(transparent)] Execution(#[from] ExecutionError), #[error("io: {0}")] Io(#[from] io::Error), #[error("invalid image: {0}")] InvalidImage(#[from] image::ImageError), #[error("invalid image: {0}")] RegisterImage(#[from] RegisterImageError), #[error("unsupported color '{0}', only RGB is supported")] UnsupportedColor(String), } #[derive(Hash, PartialEq, Eq)] enum SnippetSource { Typst, Latex, Mermaid, D2, } #[derive(Hash, PartialEq, Eq)] struct ImageSnippet { snippet: String, source: SnippetSource, } #[derive(Debug)] pub(crate) struct RenderThirdParty { contents: Arc>>, pending_result: Arc>, default_style: TextStyle, width: Option, } impl RenderThirdParty { fn new(pending_result: Arc>, default_style: TextStyle, width: Option) -> Self { Self { contents: Default::default(), pending_result, default_style, width } } } impl RenderAsync for RenderThirdParty { fn pollable(&self) -> Box { Box::new(OperationPollable { contents: self.contents.clone(), pending_result: self.pending_result.clone() }) } fn start_policy(&self) -> RenderAsyncStartPolicy { RenderAsyncStartPolicy::Automatic } } impl AsRenderOperations for RenderThirdParty { fn as_render_operations(&self, _: &WindowSize) -> Vec { match &*self.contents.lock().unwrap() { Some(Output::Image(image)) => { let size = match &self.width { Some(percent) => ImageSize::WidthScaled { ratio: percent.as_ratio() }, None => Default::default(), }; let properties = ImageRenderProperties { size, background_color: self.default_style.colors.background, ..Default::default() }; vec![RenderOperation::RenderImage(image.clone(), properties)] } Some(Output::Error) => Vec::new(), None => { let text = Line::from(Text::new("Loading...", TextStyle::default().bold())); vec![RenderOperation::RenderText { line: text.into(), alignment: Alignment::Center { minimum_margin: Default::default(), minimum_size: 0 }, }] } } } } #[derive(Debug)] enum Output { Image(Image), Error, } #[derive(Clone)] struct OperationPollable { contents: Arc>>, pending_result: Arc>, } impl Pollable for OperationPollable { fn poll(&mut self) -> PollableState { let mut contents = self.contents.lock().unwrap(); if contents.is_some() { return PollableState::Done; } match mem::take(&mut *self.pending_result.lock().unwrap()) { RenderResult::Success(image) => { *contents = Some(Output::Image(image)); PollableState::Done } RenderResult::Failure(error) => { *contents = Some(Output::Error); PollableState::Failed { error } } RenderResult::Pending => PollableState::Unmodified, } } } presenterm-0.15.1/src/tools.rs000064400000000000000000000072541046102023000144010ustar 00000000000000use itertools::Itertools; use std::{ io::{self, Write}, process::{Command, Output, Stdio}, }; const DEFAULT_MAX_ERROR_LINES: usize = 10; pub(crate) struct ThirdPartyTools; impl ThirdPartyTools { pub(crate) fn pandoc(args: &[&str]) -> Tool { Tool::new("pandoc", args) } pub(crate) fn typst(args: &[&str]) -> Tool { Tool::new("typst", args) } pub(crate) fn mermaid(args: &[&str]) -> Tool { let mmdc = if cfg!(windows) { "mmdc.cmd" } else { "mmdc" }; Tool::new(mmdc, args) } pub(crate) fn d2(args: &[&str]) -> Tool { Tool::new("d2", args) } pub(crate) fn weasyprint(args: &[&str]) -> Tool { Tool::new("weasyprint", args).inherit_stdout().max_error_lines(100) } } pub(crate) struct Tool { command_name: &'static str, command: Command, stdin: Option>, max_error_lines: usize, } impl Tool { fn new(command_name: &'static str, args: &[&str]) -> Self { let mut command = Command::new(command_name); command.args(args).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped()); Self { command_name, command, stdin: None, max_error_lines: DEFAULT_MAX_ERROR_LINES } } pub(crate) fn stdin(mut self, stdin: Vec) -> Self { self.stdin = Some(stdin); self } pub(crate) fn inherit_stdout(mut self) -> Self { self.command.stdout(Stdio::inherit()); self } pub(crate) fn max_error_lines(mut self, value: usize) -> Self { self.max_error_lines = value; self } pub(crate) fn run(self) -> Result<(), ExecutionError> { self.spawn()?; Ok(()) } pub(crate) fn run_and_capture_stdout(mut self) -> Result, ExecutionError> { self.command.stdout(Stdio::piped()); let output = self.spawn()?; Ok(output.stdout) } fn spawn(mut self) -> Result { use ExecutionError::*; if self.stdin.is_some() { self.command.stdin(Stdio::piped()); } let mut child = match self.command.spawn() { Ok(child) => child, Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(SpawnNotFound { command: self.command_name }), Err(error) => return Err(Spawn { command: self.command_name, error }), }; if let Some(data) = &self.stdin { let mut stdin = child.stdin.take().expect("no stdin"); stdin .write_all(data) .and_then(|_| stdin.flush()) .map_err(|error| Communication { command: self.command_name, error })?; } let output = child.wait_with_output().map_err(|error| Communication { command: self.command_name, error })?; self.validate_output(&output)?; Ok(output) } fn validate_output(self, output: &Output) -> Result<(), ExecutionError> { if output.status.success() { Ok(()) } else { let stderr = String::from_utf8_lossy(&output.stderr).lines().take(self.max_error_lines).join("\n"); Err(ExecutionError::Execution { command: self.command_name, stderr }) } } } #[derive(Debug, thiserror::Error)] pub enum ExecutionError { #[error("spawning '{command}' failed: {error}")] Spawn { command: &'static str, error: io::Error }, #[error("spawning '{command}' failed (is '{command}' installed?)")] SpawnNotFound { command: &'static str }, #[error("communicating with '{command}' failed: {error}")] Communication { command: &'static str, error: io::Error }, #[error("'{command}' execution failed: \n{stderr}")] Execution { command: &'static str, stderr: String }, } presenterm-0.15.1/src/transitions/collapse_horizontal.rs000064400000000000000000000043111046102023000216600ustar 00000000000000use super::{AnimateTransition, LinesFrame, TransitionDirection}; use crate::terminal::virt::TerminalGrid; pub(crate) struct CollapseHorizontalAnimation { from: TerminalGrid, to: TerminalGrid, } impl CollapseHorizontalAnimation { pub(crate) fn new(left: TerminalGrid, right: TerminalGrid, direction: TransitionDirection) -> Self { let (from, to) = match direction { TransitionDirection::Next => (left, right), TransitionDirection::Previous => (right, left), }; Self { from, to } } } impl AnimateTransition for CollapseHorizontalAnimation { type Frame = LinesFrame; fn build_frame(&self, frame: usize, _previous_frame: usize) -> Self::Frame { let mut rows = Vec::new(); for (from, to) in self.from.rows.iter().zip(&self.to.rows) { // Take the first and last `frame` cells let to_prefix = to.iter().take(frame); let to_suffix = to.iter().rev().take(frame).rev(); let total_rows_from = from.len() - frame * 2; let from = from.iter().skip(frame).take(total_rows_from); let row = to_prefix.chain(from).chain(to_suffix).copied().collect(); rows.push(row) } let grid = TerminalGrid { rows, background_color: self.from.background_color, images: Default::default() }; LinesFrame::from(&grid) } fn total_frames(&self) -> usize { self.from.rows[0].len() / 2 } } #[cfg(test)] mod tests { use super::*; use crate::{markdown::elements::Line, transitions::utils::build_grid}; use rstest::rstest; fn as_text(line: Line) -> String { line.0.into_iter().map(|l| l.content).collect() } #[rstest] #[case(0, &["ABCDEF"])] #[case(1, &["1BCDE6"])] #[case(2, &["12CD56"])] #[case(3, &["123456"])] fn transition(#[case] frame: usize, #[case] expected: &[&str]) { let left = build_grid(&["ABCDEF"]); let right = build_grid(&["123456"]); let transition = CollapseHorizontalAnimation::new(left, right, TransitionDirection::Next); let lines: Vec<_> = transition.build_frame(frame, 0).lines.into_iter().map(as_text).collect(); assert_eq!(lines, expected); } } presenterm-0.15.1/src/transitions/fade.rs000064400000000000000000000112761046102023000165140ustar 00000000000000use super::{AnimateTransition, AnimationFrame, TransitionDirection}; use crate::{ markdown::text_style::TextStyle, terminal::{ printer::TerminalCommand, virt::{StyledChar, TerminalGrid}, }, }; use std::str; pub(crate) struct FadeAnimation { changes: Vec, } impl FadeAnimation { pub(crate) fn new(left: TerminalGrid, right: TerminalGrid, direction: TransitionDirection) -> Self { let mut changes = Vec::new(); let background = left.background_color; for (row, (left, right)) in left.rows.into_iter().zip(right.rows).enumerate() { for (column, (left, right)) in left.into_iter().zip(right).enumerate() { let character = match &direction { TransitionDirection::Next => right, TransitionDirection::Previous => left, }; if left != right { let StyledChar { character, mut style } = character; // If we don't have an explicit background color fall back to the default style.colors.background = style.colors.background.or(background); let mut char_buffer = [0; 4]; let char_buffer_len = character.encode_utf8(&mut char_buffer).len() as u8; changes.push(Change { row: row as u16, column: column as u16, char_buffer, char_buffer_len, style, }); } } } fastrand::shuffle(&mut changes); Self { changes } } } impl AnimateTransition for FadeAnimation { type Frame = FadeCellsFrame; fn build_frame(&self, frame: usize, previous_frame: usize) -> Self::Frame { let last_frame = self.changes.len().saturating_sub(1); let previous_frame = previous_frame.min(last_frame); let frame_index = frame.min(self.changes.len()); let changes = self.changes[previous_frame..frame_index].to_vec(); FadeCellsFrame { changes } } fn total_frames(&self) -> usize { self.changes.len() } } #[derive(Debug)] pub(crate) struct FadeCellsFrame { changes: Vec, } impl AnimationFrame for FadeCellsFrame { fn build_commands(&self) -> Vec { let mut commands = Vec::new(); for change in &self.changes { let Change { row, column, char_buffer, char_buffer_len, style } = change; let char_buffer_len = *char_buffer_len as usize; // SAFETY: this is an utf8 encoded char so it must be valid let content = str::from_utf8(&char_buffer[..char_buffer_len]).expect("invalid utf8"); commands.push(TerminalCommand::MoveTo { row: *row, column: *column }); commands.push(TerminalCommand::PrintText { content, style: *style }); } commands } } #[derive(Clone, Debug)] struct Change { row: u16, column: u16, char_buffer: [u8; 4], char_buffer_len: u8, style: TextStyle, } #[cfg(test)] mod tests { use super::*; use crate::{ WindowSize, terminal::{printer::TerminalIo, virt::VirtualTerminal}, }; use rstest::rstest; #[rstest] #[case::next(TransitionDirection::Next)] #[case::previous(TransitionDirection::Previous)] fn transition(#[case] direction: TransitionDirection) { let left = TerminalGrid { rows: vec![ vec!['X'.into(), ' '.into(), 'B'.into()], vec!['C'.into(), StyledChar::new('X', TextStyle::default().size(2)), 'D'.into()], ], background_color: None, images: Default::default(), }; let right = TerminalGrid { rows: vec![ vec![' '.into(), 'A'.into(), StyledChar::new('B', TextStyle::default().bold())], vec![StyledChar::new('C', TextStyle::default().size(2)), ' '.into(), '🚀'.into()], ], background_color: None, images: Default::default(), }; let expected = match direction { TransitionDirection::Next => right.clone(), TransitionDirection::Previous => left.clone(), }; let dimensions = WindowSize { rows: 2, columns: 3, height: 0, width: 0 }; let mut virt = VirtualTerminal::new(dimensions, Default::default()); let animation = FadeAnimation::new(left, right, direction); for command in animation.build_frame(animation.total_frames(), 0).build_commands() { virt.execute(&command).expect("failed to run") } let output = virt.into_contents(); assert_eq!(output, expected); } } presenterm-0.15.1/src/transitions/mod.rs000064400000000000000000000117331046102023000163720ustar 00000000000000use crate::{ markdown::{elements::Line, text_style::Color}, terminal::{ printer::TerminalCommand, virt::{TerminalGrid, TerminalRowIterator}, }, }; use std::fmt::Debug; use unicode_width::UnicodeWidthStr; pub(crate) mod collapse_horizontal; pub(crate) mod fade; pub(crate) mod slide_horizontal; #[derive(Clone, Debug)] pub(crate) enum TransitionDirection { Next, Previous, } pub(crate) trait AnimateTransition { type Frame: AnimationFrame + Debug; fn build_frame(&self, frame: usize, previous_frame: usize) -> Self::Frame; fn total_frames(&self) -> usize; } pub(crate) trait AnimationFrame { fn build_commands(&self) -> Vec; } #[derive(Debug)] pub(crate) struct LinesFrame { pub(crate) lines: Vec, pub(crate) background_color: Option, } impl LinesFrame { fn skip_whitespace(mut text: &str) -> (&str, usize, usize) { let mut trimmed_before = 0; while let Some(' ') = text.chars().next() { text = &text[1..]; trimmed_before += 1; } let mut trimmed_after = 0; let mut rev = text.chars().rev(); while let Some(' ') = rev.next() { text = &text[..text.len() - 1]; trimmed_after += 1; } (text, trimmed_before, trimmed_after) } } impl From<&TerminalGrid> for LinesFrame { fn from(grid: &TerminalGrid) -> Self { let mut lines = Vec::new(); for row in &grid.rows { let line = TerminalRowIterator::new(row).collect(); lines.push(Line(line)); } Self { lines, background_color: grid.background_color } } } impl AnimationFrame for LinesFrame { fn build_commands(&self) -> Vec { use TerminalCommand::*; let mut commands = vec![]; if let Some(color) = self.background_color { commands.push(SetBackgroundColor(color)); } commands.push(ClearScreen); for (row, line) in self.lines.iter().enumerate() { let mut column = 0; let mut is_in_column = false; let mut is_in_row = false; for chunk in &line.0 { let (text, white_before, white_after) = match chunk.style.colors.background { Some(_) => (chunk.content.as_str(), 0, 0), None => Self::skip_whitespace(&chunk.content), }; // If this is an empty line just skip it if text.is_empty() { column += chunk.content.width(); is_in_column = false; continue; } if !is_in_row { commands.push(MoveToRow(row as u16)); is_in_row = true; } if white_before > 0 { column += white_before; is_in_column = false; } if !is_in_column { commands.push(MoveToColumn(column as u16)); is_in_column = true; } commands.push(PrintText { content: text, style: chunk.style }); column += text.width(); if white_after > 0 { column += white_after; is_in_column = false; } } } commands } } #[cfg(test)] mod utils { use crate::terminal::virt::{StyledChar, TerminalGrid}; pub(crate) fn build_grid(rows: &[&str]) -> TerminalGrid { let rows = rows .iter() .map(|r| r.chars().map(|c| StyledChar { character: c, style: Default::default() }).collect()) .collect(); TerminalGrid { rows, background_color: None, images: Default::default() } } } #[cfg(test)] mod tests { use super::*; use crate::markdown::elements::Text; #[test] fn commands() { let animation = LinesFrame { lines: vec![ Line(vec![Text::from(" hi "), Text::from("bye"), Text::from("s")]), Line(vec![Text::from("hello"), Text::from(" wor"), Text::from("s")]), ], background_color: Some(Color::Red), }; let commands = animation.build_commands(); use TerminalCommand::*; let expected = &[ SetBackgroundColor(Color::Red), ClearScreen, MoveToRow(0), MoveToColumn(2), PrintText { content: "hi", style: Default::default() }, MoveToColumn(6), PrintText { content: "bye", style: Default::default() }, PrintText { content: "s", style: Default::default() }, MoveToRow(1), MoveToColumn(0), PrintText { content: "hello", style: Default::default() }, MoveToColumn(6), PrintText { content: "wor", style: Default::default() }, PrintText { content: "s", style: Default::default() }, ]; assert_eq!(commands, expected); } } presenterm-0.15.1/src/transitions/slide_horizontal.rs000064400000000000000000000072521046102023000211650ustar 00000000000000use super::{AnimateTransition, LinesFrame, TransitionDirection}; use crate::{ WindowSize, markdown::elements::Line, terminal::virt::{TerminalGrid, TerminalRowIterator}, }; pub(crate) struct SlideHorizontalAnimation { grid: TerminalGrid, dimensions: WindowSize, direction: TransitionDirection, } impl SlideHorizontalAnimation { pub(crate) fn new( left: TerminalGrid, right: TerminalGrid, dimensions: WindowSize, direction: TransitionDirection, ) -> Self { let mut rows = Vec::new(); for (mut row, right) in left.rows.into_iter().zip(right.rows) { row.extend(right); rows.push(row); } let grid = TerminalGrid { rows, background_color: left.background_color, images: Default::default() }; Self { grid, dimensions, direction } } } impl AnimateTransition for SlideHorizontalAnimation { type Frame = LinesFrame; fn build_frame(&self, frame: usize, _previous_frame: usize) -> Self::Frame { let total = self.total_frames(); let frame = frame.min(total); let index = match &self.direction { TransitionDirection::Next => frame, TransitionDirection::Previous => total.saturating_sub(frame), }; let mut lines = Vec::new(); for row in &self.grid.rows { let row = &row[index..index + self.dimensions.columns as usize]; let mut line = Vec::new(); let max_width = self.dimensions.columns as usize; let mut width = 0; for mut text in TerminalRowIterator::new(row) { let text_width = text.width() * text.style.size as usize; if width + text_width > max_width { let capped_width = max_width.saturating_sub(width) / text.style.size as usize; if capped_width == 0 { continue; } text.content = text.content.chars().take(capped_width).collect(); } width += text_width; line.push(text); } lines.push(Line(line)); } LinesFrame { lines, background_color: self.grid.background_color } } fn total_frames(&self) -> usize { self.grid.rows[0].len().saturating_sub(self.dimensions.columns as usize) } } #[cfg(test)] mod tests { use super::*; use rstest::rstest; fn as_text(line: Line) -> String { line.0.into_iter().map(|l| l.content).collect() } #[rstest] #[case::next_frame0(0, TransitionDirection::Next, &["AB", "CD"])] #[case::next_frame1(1, TransitionDirection::Next, &["BE", "DG"])] #[case::next_frame2(2, TransitionDirection::Next, &["EF", "GH"])] #[case::next_way_past(100, TransitionDirection::Next, &["EF", "GH"])] #[case::previous_frame0(0, TransitionDirection::Previous, &["EF", "GH"])] #[case::previous_frame1(1, TransitionDirection::Previous, &["BE", "DG"])] #[case::previous_frame2(2, TransitionDirection::Previous, &["AB", "CD"])] #[case::previous_way_past(100, TransitionDirection::Previous, &["AB", "CD"])] fn build_frame(#[case] frame: usize, #[case] direction: TransitionDirection, #[case] expected: &[&str]) { use crate::transitions::utils::build_grid; let left = build_grid(&["AB", "CD"]); let right = build_grid(&["EF", "GH"]); let dimensions = WindowSize { rows: 2, columns: 2, height: 0, width: 0 }; let transition = SlideHorizontalAnimation::new(left, right, dimensions, direction); let lines: Vec<_> = transition.build_frame(frame, 0).lines.into_iter().map(as_text).collect(); assert_eq!(lines, expected); } } presenterm-0.15.1/src/ui/execution/acquire_terminal.rs000064400000000000000000000111051046102023000211730ustar 00000000000000use crate::{ code::{execute::LanguageSnippetExecutor, snippet::Snippet}, markdown::elements::{Line, Text}, render::{ operation::{AsRenderOperations, Pollable, PollableState, RenderAsync, RenderOperation}, properties::WindowSize, }, terminal::should_hide_cursor, theme::{Alignment, ExecutionStatusBlockStyle, Margin}, ui::separator::{RenderSeparator, SeparatorWidth}, }; use crossterm::{ ExecutableCommand, cursor, terminal::{self, disable_raw_mode, enable_raw_mode}, }; use std::{ io::{self}, ops::Deref, rc::Rc, sync::{Arc, Mutex}, }; const MINIMUM_SEPARATOR_WIDTH: u16 = 32; #[derive(Debug)] pub(crate) struct RunAcquireTerminalSnippet { snippet: Snippet, block_length: u16, executor: LanguageSnippetExecutor, colors: ExecutionStatusBlockStyle, state: Arc>, font_size: u8, } impl RunAcquireTerminalSnippet { pub(crate) fn new( snippet: Snippet, executor: LanguageSnippetExecutor, colors: ExecutionStatusBlockStyle, block_length: u16, font_size: u8, ) -> Self { Self { snippet, block_length, executor, colors, state: Default::default(), font_size } } fn invoke(&self) -> Result<(), String> { let mut stdout = io::stdout(); stdout .execute(terminal::LeaveAlternateScreen) .and_then(|_| disable_raw_mode()) .map_err(|e| format!("failed to deinit terminal: {e}"))?; // save result for later, but first reinit the terminal let result = self.executor.execute_sync(&self.snippet).map_err(|e| format!("failed to run snippet: {e}")); stdout .execute(terminal::EnterAlternateScreen) .and_then(|_| enable_raw_mode()) .map_err(|e| format!("failed to reinit terminal: {e}"))?; if should_hide_cursor() { stdout.execute(cursor::Hide).map_err(|e| e.to_string())?; } result } } impl AsRenderOperations for RunAcquireTerminalSnippet { fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { let state = self.state.lock().unwrap(); let separator_text = match state.deref() { State::NotStarted => Text::new("not started", self.colors.not_started_style), State::Success => Text::new("finished", self.colors.success_style), State::Failure(_) => Text::new("finished with error", self.colors.failure_style), }; let heading = Line(vec![" [".into(), separator_text, "] ".into()]); let separator_width = SeparatorWidth::Fixed(self.block_length.max(MINIMUM_SEPARATOR_WIDTH)); let separator = RenderSeparator::new(heading, separator_width, self.font_size); let mut ops = vec![ RenderOperation::RenderLineBreak, RenderOperation::RenderDynamic(Rc::new(separator)), RenderOperation::RenderLineBreak, ]; if let State::Failure(lines) = state.deref() { ops.push(RenderOperation::RenderLineBreak); for line in lines { ops.extend([ RenderOperation::RenderText { line: vec![Text::new(line, self.colors.failure_style)].into(), alignment: Alignment::Left { margin: Margin::Percent(25) }, }, RenderOperation::RenderLineBreak, ]); } } ops } } impl RenderAsync for RunAcquireTerminalSnippet { fn pollable(&self) -> Box { // Run within this method because we need to release/acquire the raw terminal in the main // thread. let mut state = self.state.lock().unwrap(); if matches!(*state, State::NotStarted) { if let Err(e) = self.invoke() { let lines = e.lines().map(ToString::to_string).collect(); *state = State::Failure(lines); } else { *state = State::Success; } } Box::new(OperationPollable) } } #[derive(Default, Clone)] enum State { #[default] NotStarted, Success, Failure(Vec), } impl std::fmt::Debug for State { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::NotStarted => write!(f, "NotStarted"), Self::Success => write!(f, "Success"), Self::Failure(_) => write!(f, "Failure"), } } } struct OperationPollable; impl Pollable for OperationPollable { fn poll(&mut self) -> PollableState { PollableState::Done } } presenterm-0.15.1/src/ui/execution/disabled.rs000064400000000000000000000036231046102023000174240ustar 00000000000000use crate::{ markdown::{elements::Text, text_style::TextStyle}, render::{ operation::{AsRenderOperations, Pollable, RenderAsync, RenderAsyncStartPolicy, RenderOperation, ToggleState}, properties::WindowSize, }, theme::Alignment, }; use std::sync::{Arc, Mutex}; #[derive(Clone, Debug)] pub(crate) struct SnippetExecutionDisabledOperation { text: Text, alignment: Alignment, policy: RenderAsyncStartPolicy, toggled: Arc>, } impl SnippetExecutionDisabledOperation { pub(crate) fn new( style: TextStyle, alignment: Alignment, policy: RenderAsyncStartPolicy, exec_type: ExecutionType, ) -> Self { let (attribute, cli_parameter) = match exec_type { ExecutionType::Execute => ("+exec", "-x"), ExecutionType::ExecReplace => ("+exec_replace", "-X"), ExecutionType::Image => ("+image", "-X"), }; let text = Text::new(format!("snippet {attribute} is disabled, run with {cli_parameter} to enable"), style); Self { text, alignment, policy, toggled: Default::default() } } } impl AsRenderOperations for SnippetExecutionDisabledOperation { fn as_render_operations(&self, _: &WindowSize) -> Vec { if !*self.toggled.lock().unwrap() { return Vec::new(); } vec![ RenderOperation::RenderLineBreak, RenderOperation::RenderText { line: vec![self.text.clone()].into(), alignment: self.alignment }, RenderOperation::RenderLineBreak, ] } } impl RenderAsync for SnippetExecutionDisabledOperation { fn pollable(&self) -> Box { Box::new(ToggleState::new(self.toggled.clone())) } fn start_policy(&self) -> RenderAsyncStartPolicy { self.policy } } #[derive(Debug)] pub(crate) enum ExecutionType { Execute, ExecReplace, Image, } presenterm-0.15.1/src/ui/execution/image.rs000064400000000000000000000116311046102023000167350ustar 00000000000000use crate::{ code::{ execute::{ExecutionHandle, LanguageSnippetExecutor, ProcessStatus}, snippet::Snippet, }, markdown::elements::Text, render::{ operation::{ AsRenderOperations, ImageRenderProperties, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy, RenderOperation, }, properties::WindowSize, }, terminal::image::{ Image, printer::{ImageRegistry, ImageSpec}, }, theme::{Alignment, ExecutionStatusBlockStyle, Margin}, }; use std::{ io::BufRead, mem, ops::Deref, sync::{Arc, Mutex}, }; #[derive(Debug)] pub(crate) struct RunImageSnippet { snippet: Snippet, state: Arc>, image_registry: ImageRegistry, colors: ExecutionStatusBlockStyle, } impl RunImageSnippet { pub(crate) fn new( snippet: Snippet, executor: LanguageSnippetExecutor, image_registry: ImageRegistry, colors: ExecutionStatusBlockStyle, ) -> Self { let state = Arc::new(Mutex::new(State::NotStarted(executor))); Self { snippet, image_registry, colors, state } } } impl RenderAsync for RunImageSnippet { fn pollable(&self) -> Box { Box::new(OperationPollable { state: self.state.clone(), snippet: self.snippet.clone(), image_registry: self.image_registry.clone(), }) } fn start_policy(&self) -> RenderAsyncStartPolicy { RenderAsyncStartPolicy::Automatic } } impl AsRenderOperations for RunImageSnippet { fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { let state = self.state.lock().unwrap(); match state.deref() { State::NotStarted(_) | State::Running(_) => vec![], State::Success(image) => { vec![RenderOperation::RenderImage(image.clone(), ImageRenderProperties::default())] } State::Failure(lines) => { let mut output = Vec::new(); for line in lines { output.extend([RenderOperation::RenderText { line: vec![Text::new(line, self.colors.failure_style)].into(), alignment: Alignment::Left { margin: Margin::Percent(25) }, }]); } output } } } } struct OperationPollable { state: Arc>, snippet: Snippet, image_registry: ImageRegistry, } impl OperationPollable { fn load_image(&self, data: &[u8]) -> Result { let image = match image::load_from_memory(data) { Ok(image) => image, Err(e) => { return Err(e.to_string()); } }; self.image_registry.register(ImageSpec::Generated(image)).map_err(|e| e.to_string()) } } impl Pollable for OperationPollable { fn poll(&mut self) -> PollableState { let mut state = self.state.lock().unwrap(); match state.deref() { State::NotStarted(executor) => match executor.execute_async(&self.snippet) { Ok(handle) => { *state = State::Running(handle); PollableState::Unmodified } Err(e) => { *state = State::Failure(e.to_string().lines().map(ToString::to_string).collect()); PollableState::Done } }, State::Running(handle) => { let mut inner = handle.state.lock().unwrap(); match inner.status { ProcessStatus::Running => PollableState::Unmodified, ProcessStatus::Success => { let data = mem::take(&mut inner.output); drop(inner); match self.load_image(&data) { Ok(image) => { *state = State::Success(image); } Err(e) => { *state = State::Failure(vec![e.to_string()]); } }; PollableState::Done } ProcessStatus::Failure => { let mut lines = Vec::new(); for line in inner.output.lines() { lines.push(line.unwrap_or_else(|_| String::new())); } drop(inner); *state = State::Failure(lines); PollableState::Done } } } State::Success(_) | State::Failure(_) => PollableState::Done, } } } #[derive(Debug)] enum State { NotStarted(LanguageSnippetExecutor), Running(ExecutionHandle), Success(Image), Failure(Vec), } presenterm-0.15.1/src/ui/execution/mod.rs000064400000000000000000000005201046102023000164250ustar 00000000000000pub(crate) mod acquire_terminal; pub(crate) mod disabled; pub(crate) mod image; pub(crate) mod output; pub(crate) mod validator; pub(crate) use acquire_terminal::RunAcquireTerminalSnippet; pub(crate) use disabled::SnippetExecutionDisabledOperation; pub(crate) use image::RunImageSnippet; pub(crate) use output::SnippetOutputOperation; presenterm-0.15.1/src/ui/execution/output.rs000064400000000000000000000270111046102023000172120ustar 00000000000000use crate::{ code::{ execute::{ExecutionHandle, ExecutionState, LanguageSnippetExecutor, ProcessStatus}, snippet::Snippet, }, markdown::{ elements::{Line, Text}, text_style::{Colors, TextStyle}, }, render::{ operation::{ AsRenderOperations, BlockLine, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy, RenderOperation, }, properties::WindowSize, }, terminal::ansi::AnsiParser, theme::{Alignment, ExecutionOutputBlockStyle, ExecutionStatusBlockStyle}, ui::separator::{RenderSeparator, SeparatorWidth}, }; use std::{ io::BufRead, iter, rc::Rc, sync::{Arc, Mutex}, }; const MINIMUM_SEPARATOR_WIDTH: u16 = 32; #[derive(Default, Debug)] enum State { #[default] Initial, Running(ExecutionHandle), Done, } #[derive(Debug)] struct Inner { snippet: Snippet, executor: LanguageSnippetExecutor, output_lines: Vec, max_line_length: u16, process_status: Option, state: State, policy: RenderAsyncStartPolicy, } #[derive(Debug)] pub(crate) struct SnippetOutputOperation { default_colors: Colors, style: ExecutionOutputBlockStyle, block_length: u16, alignment: Alignment, handle: SnippetHandle, font_size: u8, } impl SnippetOutputOperation { #[allow(clippy::too_many_arguments)] pub(crate) fn new( handle: SnippetHandle, default_colors: Colors, style: ExecutionOutputBlockStyle, block_length: u16, alignment: Alignment, font_size: u8, ) -> Self { let block_length = alignment.adjust_size(block_length); Self { default_colors, style, block_length, alignment, handle, font_size } } } impl AsRenderOperations for SnippetOutputOperation { fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { let inner = self.handle.0.lock().unwrap(); if let State::Initial = inner.state { return Vec::new(); } let mut operations = vec![]; let block_colors = self.style.style.colors; if block_colors.background.is_some() { operations.push(RenderOperation::SetColors(block_colors)); } if !inner.output_lines.is_empty() { let has_margin = match &self.alignment { Alignment::Left { margin } => !margin.is_empty(), Alignment::Right { margin } => !margin.is_empty(), Alignment::Center { minimum_margin, minimum_size } => !minimum_margin.is_empty() || minimum_size != &0, }; let padding = self.style.padding; let block_length = if has_margin { self.block_length.max(inner.max_line_length) } else { inner.max_line_length }; let vertical_padding = iter::repeat_n(" ", padding.vertical as usize).map(Line::from); let lines = vertical_padding.clone().chain(inner.output_lines.iter().cloned()).chain(vertical_padding); let style = TextStyle::default().size(self.font_size); for mut line in lines { line.apply_style(&style); let prefix = Text::new(" ".repeat(padding.horizontal as usize), style).into(); operations.push(RenderOperation::RenderBlockLine(BlockLine { prefix, right_padding_length: padding.horizontal as u16, repeat_prefix_on_wrap: false, text: line.into(), block_length, alignment: self.alignment, block_color: block_colors.background, })); operations.push(RenderOperation::RenderLineBreak); } } operations.extend([RenderOperation::SetColors(self.default_colors)]); operations } } struct OperationPollable { inner: Arc>, last_length: usize, } impl OperationPollable { fn try_start(&self, inner: &mut Inner) { // Don't run twice. if !matches!(inner.state, State::Initial) { return; } inner.state = match inner.executor.execute_async(&inner.snippet) { Ok(handle) => State::Running(handle), Err(e) => { inner.output_lines = vec![e.to_string().into()]; State::Done } } } } impl Pollable for OperationPollable { fn poll(&mut self) -> PollableState { let mut inner = self.inner.lock().unwrap(); self.try_start(&mut inner); // At this point if we don't have a handle it's because we're done. let State::Running(handle) = &mut inner.state else { return PollableState::Done; }; // Pull data out of the process' output and drop the handle state. let mut state = handle.state.lock().unwrap(); let ExecutionState { output, status } = &mut *state; let status = status.clone(); let modified = output.len() != self.last_length; let mut lines = Vec::new(); for line in output.lines() { let mut line = line.expect("invalid utf8"); if line.contains('\t') { line = line.replace('\t', " "); } lines.push(line); } drop(state); let mut max_line_length = 0; let (lines, _) = AnsiParser::new(Default::default()).parse_lines(&lines); for line in &lines { let width = u16::try_from(line.width()).unwrap_or(u16::MAX); max_line_length = max_line_length.max(width); } let is_finished = status.is_finished(); inner.process_status = Some(status); inner.output_lines = lines; inner.max_line_length = inner.max_line_length.max(max_line_length); if is_finished { inner.state = State::Done; PollableState::Done } else { match modified { true => PollableState::Modified, false => PollableState::Unmodified, } } } } #[derive(Debug, Clone)] pub(crate) struct SnippetHandle(Arc>); impl SnippetHandle { pub(crate) fn new(code: Snippet, executor: LanguageSnippetExecutor, policy: RenderAsyncStartPolicy) -> Self { let inner = Inner { snippet: code, executor, process_status: Default::default(), output_lines: Default::default(), max_line_length: Default::default(), state: Default::default(), policy, }; Self(Arc::new(Mutex::new(inner))) } pub(crate) fn executor(&self) -> LanguageSnippetExecutor { self.0.lock().unwrap().executor.clone() } pub(crate) fn snippet(&self) -> Snippet { self.0.lock().unwrap().snippet.clone() } } #[derive(Debug)] pub(crate) struct RunSnippetTrigger(Arc>); impl RunSnippetTrigger { pub(crate) fn new(handle: SnippetHandle) -> Self { Self(handle.0) } } impl AsRenderOperations for RunSnippetTrigger { fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { vec![] } } impl RenderAsync for RunSnippetTrigger { fn pollable(&self) -> Box { Box::new(OperationPollable { inner: self.0.clone(), last_length: 0 }) } fn start_policy(&self) -> RenderAsyncStartPolicy { self.0.lock().unwrap().policy } } #[derive(Debug)] pub(crate) struct ExecIndicatorStyle { pub(crate) theme: ExecutionStatusBlockStyle, pub(crate) block_length: u16, pub(crate) font_size: u8, pub(crate) alignment: Alignment, } #[derive(Debug)] pub(crate) struct ExecIndicator { handle: SnippetHandle, separator_width: SeparatorWidth, theme: ExecutionStatusBlockStyle, font_size: u8, } impl ExecIndicator { pub(crate) fn new(handle: SnippetHandle, style: ExecIndicatorStyle) -> Self { let ExecIndicatorStyle { theme, block_length, font_size, alignment } = style; let block_length = alignment.adjust_size(block_length); let separator_width = match &alignment { Alignment::Left { .. } | Alignment::Right { .. } => SeparatorWidth::FitToWindow, // We need a minimum here otherwise if the code/block length is too narrow, the separator is // word-wrapped and looks bad. Alignment::Center { .. } => { SeparatorWidth::Fixed(block_length.max(MINIMUM_SEPARATOR_WIDTH * font_size as u16)) } }; Self { handle, separator_width, theme, font_size } } } impl AsRenderOperations for ExecIndicator { fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { let inner = self.handle.0.lock().unwrap(); let status = &inner.process_status; let description = match status { Some(ProcessStatus::Running) => Text::new("running", self.theme.running_style), Some(ProcessStatus::Success) => Text::new("finished", self.theme.success_style), Some(ProcessStatus::Failure) => Text::new("finished with error", self.theme.failure_style), None => Text::new("not started", self.theme.not_started_style), }; let heading = Line(vec![" [".into(), description.clone(), "] ".into()]); let separator = RenderSeparator::new(heading, self.separator_width, self.font_size); vec![ RenderOperation::RenderLineBreak, RenderOperation::RenderDynamic(Rc::new(separator)), RenderOperation::RenderLineBreak, ] } } #[cfg(all(target_os = "linux", test))] mod tests { use super::*; use crate::{ code::{ execute::SnippetExecutor, snippet::{SnippetAttributes, SnippetExec, SnippetLanguage}, }, markdown::{ elements::{Line, Text}, text_style::Color, }, }; fn make_run_shell(code: &str) -> RunSnippetTrigger { let snippet = Snippet { contents: code.into(), language: SnippetLanguage::Bash, attributes: SnippetAttributes { execution: SnippetExec::Exec(Default::default()), ..Default::default() }, }; let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); let policy = RenderAsyncStartPolicy::OnDemand; let handle = SnippetHandle::new(snippet, executor, policy); RunSnippetTrigger::new(handle) } #[test] fn run_command() { let handle = make_run_shell("echo -e '\\033[1;31mhi mom'"); let mut pollable = handle.pollable(); // Run until done while let PollableState::Modified | PollableState::Unmodified = pollable.poll() {} // Expect to see the output lines let inner = handle.0.lock().unwrap(); let line = Line::from(Text::new("hi mom", TextStyle::default().fg_color(Color::Red).bold())); assert_eq!(inner.output_lines, vec![line]); } #[test] fn multiple_pollables() { let handle = make_run_shell("echo -e '\\033[1;31mhi mom'"); let mut main_pollable = handle.pollable(); let mut pollable2 = handle.pollable(); // Run until done while let PollableState::Modified | PollableState::Unmodified = main_pollable.poll() {} // Polling a pollable created early should return `Done` immediately assert_eq!(pollable2.poll(), PollableState::Done); // A new pollable should claim `Done` immediately let mut pollable3 = handle.pollable(); assert_eq!(pollable3.poll(), PollableState::Done); } } presenterm-0.15.1/src/ui/execution/validator.rs000064400000000000000000000145321046102023000176430ustar 00000000000000use crate::{ code::{ execute::{ExecutionHandle, LanguageSnippetExecutor, ProcessStatus}, snippet::{ExpectedSnippetExecutionResult, Snippet}, }, render::operation::{ AsRenderOperations, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy, RenderOperation, }, }; use std::{ mem, ops::DerefMut, sync::{Arc, Mutex}, }; #[derive(Debug)] pub(crate) struct ValidateSnippetOperation { snippet: Snippet, executor: LanguageSnippetExecutor, state: Arc>, } impl ValidateSnippetOperation { pub(crate) fn new(snippet: Snippet, executor: LanguageSnippetExecutor) -> Self { Self { snippet, executor, state: Default::default() } } } impl AsRenderOperations for ValidateSnippetOperation { fn as_render_operations(&self, _dimensions: &crate::WindowSize) -> Vec { vec![] } } impl RenderAsync for ValidateSnippetOperation { fn pollable(&self) -> Box { Box::new(OperationPollable { snippet: self.snippet.clone(), executor: self.executor.clone(), state: self.state.clone(), }) } fn start_policy(&self) -> RenderAsyncStartPolicy { RenderAsyncStartPolicy::Automatic } } #[derive(Debug, Default)] enum State { #[default] Initial, Running(ExecutionHandle), Done(PollableState), } struct OperationPollable { snippet: Snippet, executor: LanguageSnippetExecutor, state: Arc>, } impl OperationPollable { fn success_to_pollable_state(&self) -> PollableState { match self.snippet.attributes.expected_execution_result { ExpectedSnippetExecutionResult::Success => PollableState::Done, ExpectedSnippetExecutionResult::Failure => { PollableState::Failed { error: "expected snippet to fail but it succeeded".into() } } } } fn error_to_pollable_state>(&self, error: S) -> PollableState { match self.snippet.attributes.expected_execution_result { ExpectedSnippetExecutionResult::Success => PollableState::Failed { error: error.into() }, ExpectedSnippetExecutionResult::Failure => PollableState::Done, } } } impl Pollable for OperationPollable { fn poll(&mut self) -> PollableState { let mut state = self.state.lock().expect("lock poisoned"); let next_state = match mem::take(state.deref_mut()) { State::Initial => match self.executor.execute_async(&self.snippet) { Ok(handle) => State::Running(handle), Err(e) => State::Done(self.error_to_pollable_state(e.to_string())), }, State::Running(handle) => { let state = handle.state.lock().expect("lock poisoned"); match state.status { ProcessStatus::Running => { drop(state); State::Running(handle) } ProcessStatus::Success => State::Done(self.success_to_pollable_state()), ProcessStatus::Failure => { State::Done(self.error_to_pollable_state(String::from_utf8_lossy(&state.output))) } } } State::Done(output) => State::Done(output), }; *state = next_state; match &*state { State::Initial | State::Running(_) => PollableState::Unmodified, State::Done(output) => output.clone(), } } } #[cfg(test)] mod tests { use super::*; use crate::code::{ execute::SnippetExecutor, snippet::{SnippetAttributes, SnippetLanguage}, }; use rstest::rstest; #[rstest] #[case::success("fn main() { println!(\"hi\"); }", ExpectedSnippetExecutionResult::Success)] #[case::failure("fn main() ", ExpectedSnippetExecutionResult::Failure)] fn expectation_matches(#[case] contents: &str, #[case] expected_execution_result: ExpectedSnippetExecutionResult) { let snippet = Snippet { contents: contents.into(), language: SnippetLanguage::Rust, attributes: SnippetAttributes { expected_execution_result, ..Default::default() }, }; let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); let state = Arc::new(Mutex::new(State::default())); let mut pollable = OperationPollable { snippet: snippet.clone(), executor: executor.clone(), state: state.clone() }; loop { match pollable.poll() { PollableState::Unmodified | PollableState::Modified => continue, PollableState::Done => break, PollableState::Failed { error } => panic!("finished with error: {error}"), } } let mut pollable = OperationPollable { snippet, executor, state: state.clone() }; assert!(matches!(pollable.poll(), PollableState::Done), "different pollable returned different"); } #[rstest] #[case::success("fn main() { println!(\"hi\"); }", ExpectedSnippetExecutionResult::Failure)] #[case::failure("fn main() ", ExpectedSnippetExecutionResult::Success)] fn expect_does_not_match( #[case] contents: &str, #[case] expected_execution_result: ExpectedSnippetExecutionResult, ) { let snippet = Snippet { contents: contents.into(), language: SnippetLanguage::Rust, attributes: SnippetAttributes { expected_execution_result, ..Default::default() }, }; let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); let state = Arc::new(Mutex::new(State::default())); let mut pollable = OperationPollable { snippet: snippet.clone(), executor: executor.clone(), state: state.clone() }; loop { match pollable.poll() { PollableState::Unmodified | PollableState::Modified => continue, PollableState::Done => panic!("finished successfully"), PollableState::Failed { .. } => break, } } let mut pollable = OperationPollable { snippet, executor, state: state.clone() }; assert!(matches!(pollable.poll(), PollableState::Failed { .. }), "different pollable returned different"); } } presenterm-0.15.1/src/ui/footer.rs000064400000000000000000000324151046102023000151510ustar 00000000000000use crate::{ markdown::{ elements::{Line, Text}, parse::{MarkdownParser, ParseInlinesError}, text_style::{TextStyle, UndefinedPaletteColorError}, }, render::{ operation::{AsRenderOperations, ImagePosition, ImageRenderProperties, MarginProperties, RenderOperation}, properties::WindowSize, }, terminal::image::Image, theme::{Alignment, ColorPalette, FooterContent, FooterStyle, FooterTemplate, FooterTemplateChunk, Margin}, }; use comrak::Arena; use std::borrow::Cow; use unicode_width::UnicodeWidthStr; #[derive(Debug, Default)] pub(crate) struct FooterVariables { pub(crate) current_slide: usize, pub(crate) total_slides: usize, pub(crate) author: Option, pub(crate) title: Option, pub(crate) sub_title: Option, pub(crate) event: Option, pub(crate) location: Option, pub(crate) date: Option, } #[derive(Debug)] pub(crate) struct FooterGenerator { current_slide: usize, total_slides: u64, style: RenderedFooterStyle, } impl FooterGenerator { pub(crate) fn new( style: FooterStyle, vars: &FooterVariables, palette: &ColorPalette, ) -> Result { let style = RenderedFooterStyle::new(style, vars, palette)?; let current_slide = vars.current_slide; let total_slides = vars.total_slides as u64; Ok(Self { current_slide, total_slides, style }) } fn render_line(line: &FooterLine, alignment: Alignment, height: u16, operations: &mut Vec) { operations.extend([ RenderOperation::JumpToBottomRow { index: height / 2 }, RenderOperation::RenderText { line: line.0.clone().into(), alignment }, ]); } fn push_image(&self, image: &Image, alignment: Alignment, operations: &mut Vec) { let mut properties = ImageRenderProperties::default(); operations.push(RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(0), top: 0, bottom: 1, })); match alignment { Alignment::Left { .. } => { operations.push(RenderOperation::JumpToColumn { index: 0 }); properties.position = ImagePosition::Cursor; } Alignment::Right { .. } => { properties.position = ImagePosition::Right; } Alignment::Center { .. } => properties.position = ImagePosition::Center, }; operations.extend([ // Start printing the image at the top of the footer rect RenderOperation::JumpToRow { index: 0 }, RenderOperation::RenderImage(image.clone(), properties), RenderOperation::PopMargin, ]); } } impl AsRenderOperations for FooterGenerator { fn as_render_operations(&self, dimensions: &WindowSize) -> Vec { use RenderedFooterStyle::*; match &self.style { Template { left, center, right, height } => { // Crate a margin for ourselves so we can jump to top without stepping over slide // text. let mut operations = vec![RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: dimensions.rows.saturating_sub(*height), bottom: 0, })]; // We print this one row below the bottom so there's one row of padding. let alignments = [ Alignment::Left { margin: Default::default() }, Alignment::Center { minimum_size: 0, minimum_margin: Default::default() }, Alignment::Right { margin: Default::default() }, ]; for (content, alignment) in [left, center, right].iter().zip(alignments) { if let Some(content) = content { match content { RenderedFooterContent::Line(line) => { Self::render_line(line, alignment, *height, &mut operations); } RenderedFooterContent::Image(image) => { self.push_image(image, alignment, &mut operations); } }; } } operations.push(RenderOperation::PopMargin); operations } ProgressBar { character, style } => { let character = character.to_string(); let total_columns = dimensions.columns as usize / character.width(); let progress_ratio = (self.current_slide + 1) as f64 / self.total_slides as f64; let columns_ratio = (total_columns as f64 * progress_ratio).ceil(); let bar = character.repeat(columns_ratio as usize); let bar = Text::new(bar, *style); vec![ RenderOperation::JumpToBottomRow { index: 0 }, RenderOperation::RenderText { line: vec![bar].into(), alignment: Alignment::Left { margin: Margin::Fixed(0) }, }, ] } Empty => vec![], } } } #[derive(Debug)] enum RenderedFooterStyle { Template { left: Option, center: Option, right: Option, height: u16, }, ProgressBar { character: char, style: TextStyle, }, Empty, } impl RenderedFooterStyle { fn new( style: FooterStyle, vars: &FooterVariables, palette: &ColorPalette, ) -> Result { match style { FooterStyle::Template { left, center, right, style, height } => { let left = left.map(|c| RenderedFooterContent::new(c, &style, vars, palette)).transpose()?; let center = center.map(|c| RenderedFooterContent::new(c, &style, vars, palette)).transpose()?; let right = right.map(|c| RenderedFooterContent::new(c, &style, vars, palette)).transpose()?; Ok(Self::Template { left, center, right, height }) } FooterStyle::ProgressBar { character, style } => Ok(Self::ProgressBar { character, style }), FooterStyle::Empty => Ok(Self::Empty), } } } #[derive(Clone, Debug)] struct FooterLine(Line); impl FooterLine { fn new( template: FooterTemplate, style: &TextStyle, vars: &FooterVariables, palette: &ColorPalette, ) -> Result { use FooterTemplateChunk::*; let FooterVariables { current_slide, total_slides, author, title, sub_title, event, location, date } = vars; let arena = Arena::default(); let mut reassembled = String::new(); for chunk in template.0 { let raw_text = match chunk { CurrentSlide => Cow::Owned(current_slide.to_string()), OpenBrace => Cow::Borrowed("{"), ClosedBrace => Cow::Borrowed("}"), Literal(text) => Cow::Owned(text), TotalSlides => Cow::Owned(total_slides.to_string()), Author => Self::extract_variable("author", author)?, Title => Self::extract_variable("title", title)?, SubTitle => Self::extract_variable("sub_title", sub_title)?, Event => Self::extract_variable("event", event)?, Location => Self::extract_variable("location", location)?, Date => Self::extract_variable("date", date)?, }; if raw_text.lines().count() != 1 { return Err(InvalidFooterTemplateError::NoNewlines); } reassembled.push_str(&raw_text); } // Inline parsing loses leading/trailing whitespaces so re-add them ourselves let starting_length = reassembled.len(); let raw_text = reassembled.trim_start(); let left_whitespace = starting_length - raw_text.len(); let raw_text = raw_text.trim_end(); let right_whitespace = starting_length - raw_text.len() - left_whitespace; let parser = MarkdownParser::new(&arena); let inlines = parser.parse_inlines(&reassembled)?; let mut line = inlines.resolve(palette)?; if left_whitespace != 0 { line.0.insert(0, " ".repeat(left_whitespace).into()); } if right_whitespace != 0 { line.0.push(" ".repeat(right_whitespace).into()); } line.apply_style(style); Ok(Self(line)) } fn extract_variable<'a>( name: &'static str, variable: &'a Option, ) -> Result, InvalidFooterTemplateError> { variable.as_deref().map(Cow::Borrowed).ok_or(InvalidFooterTemplateError::VariableNotSet(name)) } } #[derive(Clone, Debug)] enum RenderedFooterContent { Line(FooterLine), Image(Image), } impl RenderedFooterContent { fn new( content: FooterContent, style: &TextStyle, vars: &FooterVariables, palette: &ColorPalette, ) -> Result { Ok(match content { FooterContent::Template(template) => Self::Line(FooterLine::new(template, style, vars, palette)?), FooterContent::Image(image) => Self::Image(image), }) } } #[derive(Debug, thiserror::Error)] pub(crate) enum InvalidFooterTemplateError { #[error("footer cannot contain multiple lines")] NoNewlines, #[error("invalid markdown: {0}")] Inlines(#[from] ParseInlinesError), #[error(transparent)] PaletteColor(#[from] UndefinedPaletteColorError), #[error("variable '{0}' not set")] VariableNotSet(&'static str), } #[cfg(test)] mod tests { use crate::markdown::text_style::Color; use super::*; use once_cell::sync::Lazy; use rstest::rstest; static VARIABLES: Lazy = Lazy::new(|| FooterVariables { current_slide: 1, total_slides: 5, author: Some("bob".into()), title: Some("hi".into()), sub_title: Some("bye".into()), event: Some("test".into()), location: Some("here".into()), date: Some("now".into()), }); static PALETTE: Lazy = Lazy::new(|| ColorPalette { colors: [("red".into(), Color::new(255, 0, 0))].into(), classes: Default::default(), }); #[rstest] #[case::literal(FooterTemplateChunk::Literal("hi".into()), &["hi".into()])] #[case::literal_whitespaced(FooterTemplateChunk::Literal(" hi ".into()), &[" ".into(), "hi".into(), " ".into()])] #[case::author(FooterTemplateChunk::Author, &["bob".into()])] #[case::title(FooterTemplateChunk::Title, &["hi".into()])] #[case::sub_title(FooterTemplateChunk::SubTitle, &["bye".into()])] #[case::event(FooterTemplateChunk::Event, &["test".into()])] #[case::location(FooterTemplateChunk::Location, &["here".into()])] #[case::date(FooterTemplateChunk::Date, &["now".into()])] #[case::bold( FooterTemplateChunk::Literal("**hi** mom".into()), &[Text::new("hi", TextStyle::default().bold()), " mom".into()] )] #[case::colored( FooterTemplateChunk::Literal("hi mom".into()), &[Text::new("hi", TextStyle::default().fg_color(Color::new(255, 0, 0))), " mom".into()] )] fn render_valid(#[case] chunk: FooterTemplateChunk, #[case] expected: &[Text]) { let template = FooterTemplate(vec![chunk]); let line = FooterLine::new(template, &Default::default(), &VARIABLES, &PALETTE).expect("render failed"); assert_eq!(line.0.0, expected); } #[rstest] #[case::non_paragraph( FooterTemplateChunk::Literal("* hi".into()), )] #[case::invalid_palette_color( FooterTemplateChunk::Literal("hi mom".into()), )] #[case::newlines(FooterTemplateChunk::Literal("hi\nmom".into()))] fn render_invalid(#[case] chunk: FooterTemplateChunk) { let template = FooterTemplate(vec![chunk]); FooterLine::new(template, &Default::default(), &VARIABLES, &PALETTE).expect_err("render succeeded"); } #[test] fn interleaved_spans() { let chunks = vec![ FooterTemplateChunk::Literal("".into()), FooterTemplateChunk::CurrentSlide, FooterTemplateChunk::Literal(" / ".into()), FooterTemplateChunk::TotalSlides, FooterTemplateChunk::Literal("".into()), FooterTemplateChunk::Literal("".into()), FooterTemplateChunk::Title, FooterTemplateChunk::Literal("".into()), ]; let template = FooterTemplate(chunks); let line = FooterLine::new(template, &Default::default(), &VARIABLES, &PALETTE).expect("render failed"); let expected = &[ Text::new("1 / 5", TextStyle::default().fg_color(Color::new(255, 0, 0))), Text::new("hi", TextStyle::default().fg_color(Color::Green)), ]; assert_eq!(line.0.0, expected); } } presenterm-0.15.1/src/ui/mod.rs000064400000000000000000000001421046102023000144220ustar 00000000000000pub(crate) mod execution; pub(crate) mod footer; pub(crate) mod modals; pub(crate) mod separator; presenterm-0.15.1/src/ui/modals.rs000064400000000000000000000263561046102023000151410ustar 00000000000000use crate::{ code::padding::NumberPadder, commands::keyboard::KeyBinding, config::KeyBindingsConfig, markdown::{ elements::{Line, Text}, text::WeightedLine, text_style::TextStyle, }, presentation::PresentationState, render::{ operation::{ AsRenderOperations, ImagePosition, ImageRenderProperties, ImageSize, MarginProperties, RenderOperation, }, properties::WindowSize, }, terminal::image::Image, theme::{Margin, PresentationTheme}, }; use std::{iter, rc::Rc}; use unicode_width::UnicodeWidthStr; static MODAL_Z_INDEX: i32 = -1; #[derive(Default)] pub(crate) struct IndexBuilder { titles: Vec, background: Option, } impl IndexBuilder { pub(crate) fn add_title(&mut self, title: Line) { self.titles.push(title); } pub(crate) fn set_background(&mut self, background: Image) { self.background = Some(background); } pub(crate) fn build(self, theme: &PresentationTheme, state: PresentationState) -> Vec { let mut builder = ModalBuilder::new("Slides"); let padder = NumberPadder::new(self.titles.len()); for (index, mut title) in self.titles.into_iter().enumerate() { let index = padder.pad_right(index + 1); title.0.insert(0, format!("{index}: ").into()); builder.content.push(title); } let base_style = theme.modals.style; let selection_style = theme.modals.selection_style; let ModalContent { prefix, content, suffix, content_width } = builder.build(base_style); let drawer = IndexDrawer { prefix, rows: content, suffix, state, content_width, selection_style, background: self.background, }; vec![RenderOperation::RenderDynamic(Rc::new(drawer))] } } #[derive(Debug)] struct IndexDrawer { prefix: Vec, rows: Vec, suffix: Vec, content_width: u16, state: PresentationState, selection_style: TextStyle, background: Option, } impl AsRenderOperations for IndexDrawer { fn as_render_operations(&self, dimensions: &WindowSize) -> Vec { let current_slide_index = self.state.current_slide_index(); let max_rows = (dimensions.rows as f64 * 0.8) as u16; let (skip, take) = match self.rows.len() as u16 > max_rows { true => { let start = (current_slide_index as u16).saturating_sub(max_rows / 2); let start = start.min(self.rows.len() as u16 - max_rows); (start as usize, max_rows as usize) } false => (0, self.rows.len()), }; let visible_rows = self.rows.iter().enumerate().skip(skip).take(take); let mut operations = vec![CenterModalContent::new(self.content_width, take, self.background.clone()).into()]; operations.extend(self.prefix.iter().cloned()); for (index, row) in visible_rows { let mut row = row.clone(); if index == current_slide_index { row = row.with_style(self.selection_style); } let operation = RenderOperation::RenderText { line: row.build(), alignment: Default::default() }; operations.extend([operation, RenderOperation::RenderLineBreak]); } operations.extend(self.suffix.iter().cloned()); operations } } #[derive(Default)] pub(crate) struct KeyBindingsModalBuilder { background: Option, } impl KeyBindingsModalBuilder { pub(crate) fn set_background(&mut self, background: Image) { self.background = Some(background); } pub(crate) fn build(self, theme: &PresentationTheme, config: &KeyBindingsConfig) -> Vec { let mut builder = ModalBuilder::new("Key bindings"); builder.content.extend([ Self::build_line("Next", &config.next), Self::build_line("Next (fast)", &config.next_fast), Self::build_line("Previous", &config.previous), Self::build_line("Previous (fast)", &config.previous_fast), Self::build_line("First slide", &config.first_slide), Self::build_line("Last slide", &config.last_slide), Self::build_line("Go to slide", &config.go_to_slide), Self::build_line("Execute code", &config.execute_code), Self::build_line("Reload", &config.reload), Self::build_line("Toggle slide index", &config.toggle_slide_index), Self::build_line("Close modal", &config.close_modal), Self::build_line("Exit", &config.exit), ]); let lines = builder.content.len(); let style = theme.modals.style; let content = builder.build(style); let content_width = content.content_width; let mut operations = content.into_operations(); operations.insert(0, CenterModalContent::new(content_width, lines, self.background).into()); operations } fn build_line(label: &str, bindings: &[KeyBinding]) -> Line { let mut text = vec![Text::new(label, TextStyle::default().bold()), ": ".into()]; for (index, binding) in bindings.iter().enumerate() { if index > 0 { text.push(", ".into()); } text.push(Text::new(binding.to_string(), TextStyle::default().italics())); } Line(text) } } struct ModalBuilder { heading: String, content: Vec, } impl ModalBuilder { fn new>(heading: S) -> Self { Self { heading: heading.into(), content: Vec::new() } } fn build(self, style: TextStyle) -> ModalContent { let longest_line = self.content.iter().map(Line::width).max().unwrap_or(0) as u16; let longest_line = longest_line.max(self.heading.len() as u16); // Ensure we have a minimum width so it doesn't look too narrow. let longest_line = longest_line.max(12); // The final text looks like "| |" let content_width = longest_line + 6; let mut prefix = vec![RenderOperation::SetColors(style.colors)]; let heading = Self::center_line(self.heading, longest_line as usize); prefix.extend(Border::Top.render_line(content_width)); prefix.extend([ RenderOperation::RenderText { line: Self::build_line(vec![Text::from(heading)], content_width).build(), alignment: Default::default(), }, RenderOperation::RenderLineBreak, ]); prefix.extend(Border::Separator.render_line(content_width)); let mut content = Vec::new(); for title in self.content { content.push(Self::build_line(title.0, content_width)); } let suffix = Border::Bottom.render_line(content_width).into_iter().collect(); ModalContent { prefix, content, suffix, content_width } } fn center_line(text: String, longest_line: usize) -> String { let missing = longest_line.saturating_sub(text.len()); let padding = missing / 2; let mut output = " ".repeat(padding); output.push_str(&text); output.extend(iter::repeat_n(' ', padding)); output } fn build_line(text_chunks: Vec, content_width: u16) -> ContentRow { let (opening, closing) = Border::Regular.edges(); let prefix = Text::from(format!("{opening} ")); let content = text_chunks; let total_width = content.iter().map(|c| c.content.width()).sum::() + prefix.content.width(); let missing = content_width as usize - 1 - total_width; let mut suffix = " ".repeat(missing); suffix.push(closing); ContentRow { prefix, content, suffix: suffix.into() } } } struct ModalContent { prefix: Vec, content: Vec, suffix: Vec, content_width: u16, } impl ModalContent { fn into_operations(self) -> Vec { let mut operations = self.prefix; operations.extend(self.content.into_iter().flat_map(|c| { [ RenderOperation::RenderText { line: c.build(), alignment: Default::default() }, RenderOperation::RenderLineBreak, ] })); operations.extend(self.suffix); operations } } #[derive(Clone, Debug)] struct ContentRow { prefix: Text, content: Vec, suffix: Text, } impl ContentRow { fn with_style(mut self, style: TextStyle) -> ContentRow { for chunk in &mut self.content { chunk.style.merge(&style); } self } fn build(self) -> WeightedLine { let mut chunks = self.content; chunks.insert(0, self.prefix); chunks.push(self.suffix); WeightedLine::from(chunks) } } enum Border { Regular, Top, Separator, Bottom, } impl Border { fn render_line(&self, content_length: u16) -> [RenderOperation; 2] { let (opening, closing) = self.edges(); let mut line = String::from(opening); line.push_str(&"─".repeat(content_length.saturating_sub(2) as usize)); line.push(closing); let horizontal_border = WeightedLine::from(vec![Text::from(line)]); [ RenderOperation::RenderText { line: horizontal_border.clone(), alignment: Default::default() }, RenderOperation::RenderLineBreak, ] } fn edges(&self) -> (char, char) { match self { Self::Regular => ('│', '│'), Self::Top => ('┌', '┐'), Self::Separator => ('├', '┤'), Self::Bottom => ('└', '┘'), } } } #[derive(Debug)] struct CenterModalContent { content_width: u16, content_height: usize, background: Option, } impl CenterModalContent { fn new(content_width: u16, content_height: usize, background: Option) -> Self { Self { content_width, content_height, background } } } impl AsRenderOperations for CenterModalContent { fn as_render_operations(&self, dimensions: &WindowSize) -> Vec { let margin = dimensions.columns.saturating_sub(self.content_width) / 2; let properties = MarginProperties { horizontal: Margin::Fixed(margin), top: 0, bottom: 0 }; // However many we see + 3 for the title and 1 at the bottom. let content_height = (self.content_height + 4) as u16; let target_row = dimensions.rows.saturating_sub(content_height) / 2; let mut operations = vec![RenderOperation::ApplyMargin(properties), RenderOperation::JumpToRow { index: target_row }]; if let Some(image) = &self.background { let properties = ImageRenderProperties { z_index: MODAL_Z_INDEX, size: ImageSize::Specific(self.content_width, content_height), restore_cursor: true, background_color: None, position: ImagePosition::Center, }; operations.push(RenderOperation::RenderImage(image.clone(), properties)); } operations } } impl From for RenderOperation { fn from(op: CenterModalContent) -> Self { Self::RenderDynamic(Rc::new(op)) } } presenterm-0.15.1/src/ui/separator.rs000064400000000000000000000055111046102023000156500ustar 00000000000000use crate::{ markdown::{ elements::{Line, Text}, text_style::TextStyle, }, render::{ layout::{Layout, Positioning}, operation::{AsRenderOperations, BlockLine, RenderOperation}, properties::WindowSize, }, theme::{Alignment, Margin}, }; use std::rc::Rc; #[derive(Clone, Copy, Debug, Default)] pub(crate) enum SeparatorWidth { Fixed(u16), #[default] FitToWindow, } #[derive(Clone, Debug)] pub(crate) struct RenderSeparator { heading: Line, width: SeparatorWidth, font_size: u8, } impl RenderSeparator { pub(crate) fn new>(heading: S, width: SeparatorWidth, font_size: u8) -> Self { let mut heading: Line = heading.into(); heading.apply_style(&TextStyle::default().size(font_size)); Self { heading, width, font_size } } } impl From for RenderOperation { fn from(separator: RenderSeparator) -> Self { Self::RenderDynamic(Rc::new(separator)) } } impl AsRenderOperations for RenderSeparator { fn as_render_operations(&self, dimensions: &WindowSize) -> Vec { let character = "—"; let width = match self.width { SeparatorWidth::Fixed(width) => { let Positioning { max_line_length, .. } = Layout::new(Alignment::Center { minimum_margin: Margin::Fixed(0), minimum_size: 0 }) .with_font_size(self.font_size) .compute(dimensions, width); max_line_length.min(width) as usize } SeparatorWidth::FitToWindow => dimensions.columns as usize, }; let style = TextStyle::default().size(self.font_size); let width = width / self.font_size as usize; let separator = match self.heading.width() == 0 { true => Line::from(Text::new(character.repeat(width), style)), false => { let width = width.saturating_sub(self.heading.width()); let (dashes_len, remainder) = (width / 2, width % 2); let mut dashes = character.repeat(dashes_len); let mut line = Line::from(Text::new(dashes.clone(), style)); line.0.extend(self.heading.0.iter().cloned()); if remainder > 0 { dashes.push_str(character); } line.0.push(Text::new(dashes, style)); line } }; vec![RenderOperation::RenderBlockLine(BlockLine { prefix: "".into(), right_padding_length: 0, repeat_prefix_on_wrap: false, text: separator.into(), block_length: width as u16, block_color: None, alignment: Alignment::Center { minimum_size: 1, minimum_margin: Margin::Fixed(0) }, })] } } presenterm-0.15.1/src/utils.rs000064400000000000000000000036431046102023000143770ustar 00000000000000use serde::{Deserializer, Serializer}; use std::{ fmt::{self, Display}, marker::PhantomData, str::FromStr, }; macro_rules! impl_deserialize_from_str { ($ty:ty) => { impl<'de> serde::de::Deserialize<'de> for $ty { fn deserialize(deserializer: D) -> Result where D: serde::de::Deserializer<'de>, { $crate::utils::deserialize_from_str(deserializer) } } }; } macro_rules! impl_serialize_from_display { ($ty:ty) => { impl serde::Serialize for $ty { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { $crate::utils::serialize_display(self, serializer) } } }; } pub(crate) use impl_deserialize_from_str; pub(crate) use impl_serialize_from_display; // Same behavior as serde_with::DeserializeFromStr pub(crate) fn deserialize_from_str<'de, D, T>(deserializer: D) -> Result where D: Deserializer<'de>, T: FromStr, T::Err: Display, { struct Visitor(PhantomData); impl serde::de::Visitor<'_> for Visitor where S: FromStr, ::Err: Display, { type Value = S; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { write!(formatter, "a string") } fn visit_str(self, value: &str) -> Result where E: serde::de::Error, { value.parse::().map_err(serde::de::Error::custom) } } deserializer.deserialize_str(Visitor(PhantomData)) } // Same behavior as serde_with::SerializeDisplay pub(crate) fn serialize_display(value: &T, serializer: S) -> Result where T: Display, S: Serializer, { serializer.serialize_str(&value.to_string()) } presenterm-0.15.1/themes/catppuccin-frappe.yaml000064400000000000000000000060241046102023000176530ustar 00000000000000--- default: margin: percent: 8 colors: foreground: palette:text background: palette:base slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: palette:yellow bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: palette:text background: palette:surface0 status: running: foreground: palette:blue success: foreground: palette:green failure: foreground: palette:red not_started: foreground: palette:yellow padding: horizontal: 2 vertical: 1 inline_code: colors: foreground: palette:green intro_slide: title: alignment: center colors: foreground: palette:green font_size: 2 subtitle: alignment: center colors: foreground: palette:sapphire event: alignment: center colors: foreground: palette:green location: alignment: center colors: foreground: palette:sapphire date: alignment: center colors: foreground: palette:yellow author: alignment: center colors: foreground: palette:subtext1 positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: palette:teal h2: prefix: "▓▓▓" colors: foreground: palette:mauve h3: prefix: "▒▒▒▒" colors: foreground: palette:blue h4: prefix: "░░░░░" colors: foreground: palette:red h5: prefix: "░░░░░░" colors: foreground: palette:green h6: prefix: "░░░░░░░" colors: foreground: palette:peach block_quote: prefix: "▍ " colors: foreground: palette:text background: palette:surface0 prefix: palette:yellow alert: prefix: "▍ " base_colors: foreground: palette:text background: palette:surface0 styles: note: color: palette:blue tip: color: palette:green important: color: palette:mauve warning: color: palette:yellow caution: color: palette:red typst: colors: foreground: palette:text background: palette:surface0 footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: palette:sapphire mermaid: background: transparent theme: dark d2: theme: 4 palette: colors: rosewater: "f2d5cf" flamingo: "eebebe" pink: "f4b8e4" mauve: "ca9ee6" red: "e78284" maroon: "ea999c" peach: "ef9f76" yellow: "e5c890" green: "a6d189" teal: "81c8be" sky: "99d1db" sapphire: "85c1dc" blue: "8caaee" lavender: "babbf1" text: "c6d0f5" subtext1: "b5bfe2" subtext0: "a5adce" overlay2: "949cbb" overlay1: "838ba7" overlay0: "737994" surface2: "626880" surface1: "51576d" surface0: "414559" base: "303446" mantle: "292c3c" crust: "232634" presenterm-0.15.1/themes/catppuccin-latte.yaml000064400000000000000000000060121046102023000175040ustar 00000000000000--- default: margin: percent: 8 colors: foreground: palette:text background: palette:base slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: palette:yellow bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: GitHub padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: palette:text background: palette:surface0 status: running: foreground: palette:sky success: foreground: palette:green failure: foreground: palette:red not_started: foreground: palette:yellow padding: horizontal: 2 vertical: 1 inline_code: colors: foreground: palette:green intro_slide: title: alignment: center colors: foreground: palette:green font_size: 2 subtitle: alignment: center colors: foreground: palette:sapphire event: alignment: center colors: foreground: palette:green location: alignment: center colors: foreground: palette:sapphire date: alignment: center colors: foreground: palette:yellow author: alignment: center colors: foreground: palette:subtext1 positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: palette:teal h2: prefix: "▓▓▓" colors: foreground: palette:mauve h3: prefix: "▒▒▒▒" colors: foreground: palette:blue h4: prefix: "░░░░░" colors: foreground: palette:red h5: prefix: "░░░░░░" colors: foreground: palette:green h6: prefix: "░░░░░░░" colors: foreground: palette:peach block_quote: prefix: "▍ " colors: foreground: palette:text background: palette:surface0 prefix: palette:yellow alert: prefix: "▍ " base_colors: foreground: palette:text background: palette:surface0 styles: note: color: palette:blue tip: color: palette:green important: color: palette:mauve warning: color: palette:yellow caution: color: palette:red typst: colors: foreground: palette:text background: palette:surface0 footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: palette:sapphire mermaid: background: transparent theme: default d2: theme: 100 palette: colors: rosewater: "dc8a78" flamingo: "dd7878" pink: "ea76cb" mauve: "8839ef" red: "d20f39" maroon: "e64553" peach: "fe640b" yellow: "df8e1d" green: "40a02b" teal: "179299" sky: "04a5e5" sapphire: "209fb5" blue: "1e66f5" lavender: "7287fd" text: "4c4f69" subtext1: "5c5f77" subtext0: "6c6f85" overlay2: "7c7f93" overlay1: "8c8fa1" overlay0: "9ca0b0" surface2: "acb0be" surface1: "bcc0cc" surface0: "ccd0da" base: "eff1f5" mantle: "e6e9ef" crust: "dce0e8" presenterm-0.15.1/themes/catppuccin-macchiato.yaml000064400000000000000000000060241046102023000203260ustar 00000000000000--- default: margin: percent: 8 colors: foreground: palette:text background: palette:base slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: palette:yellow bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: palette:text background: palette:surface0 status: running: foreground: palette:sky success: foreground: palette:green failure: foreground: palette:red not_started: foreground: palette:yellow padding: horizontal: 2 vertical: 1 inline_code: colors: foreground: palette:green intro_slide: title: alignment: center colors: foreground: palette:green font_size: 2 subtitle: alignment: center colors: foreground: palette:sapphire event: alignment: center colors: foreground: palette:green location: alignment: center colors: foreground: palette:sapphire date: alignment: center colors: foreground: palette:yellow author: alignment: center colors: foreground: palette:subtext1 positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: palette:teal h2: prefix: "▓▓▓" colors: foreground: palette:mauve h3: prefix: "▒▒▒▒" colors: foreground: palette:blue h4: prefix: "░░░░░" colors: foreground: palette:red h5: prefix: "░░░░░░" colors: foreground: palette:green h6: prefix: "░░░░░░░" colors: foreground: palette:peach block_quote: prefix: "▍ " colors: foreground: palette:text background: palette:surface0 prefix: palette:yellow alert: prefix: "▍ " base_colors: foreground: palette:text background: palette:surface0 styles: note: color: palette:blue tip: color: palette:green important: color: palette:mauve warning: color: palette:peach caution: color: palette:red typst: colors: foreground: palette:text background: palette:surface0 footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: palette:sapphire mermaid: background: transparent theme: dark d2: theme: 200 palette: colors: rosewater: "f4dbd6" flamingo: "f0c6c6" pink: "f5bde6" mauve: "c6a0f6" red: "ed8796" maroon: "ee99a0" peach: "f5a97f" yellow: "eed49f" green: "a6da95" teal: "8bd5ca" sky: "91d7e3" sapphire: "7dc4e4" blue: "8aadf4" lavender: "b7bdf8" text: "cad3f5" subtext1: "b8c0e0" subtext0: "a5adcb" overlay2: "939ab7" overlay1: "8087a2" overlay0: "6e738d" surface2: "5b6078" surface1: "494d64" surface0: "363a4f" base: "24273a" mantle: "1e2030" crust: "181926" presenterm-0.15.1/themes/catppuccin-mocha.yaml000064400000000000000000000060241046102023000174650ustar 00000000000000--- default: margin: percent: 8 colors: foreground: palette:text background: palette:base slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: palette:yellow bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: palette:text background: palette:surface0 status: running: foreground: palette:sky success: foreground: palette:green failure: foreground: palette:red not_started: foreground: palette:yellow padding: horizontal: 2 vertical: 1 inline_code: colors: foreground: palette:green intro_slide: title: alignment: center colors: foreground: palette:green font_size: 2 subtitle: alignment: center colors: foreground: palette:sapphire event: alignment: center colors: foreground: palette:green location: alignment: center colors: foreground: palette:sapphire date: alignment: center colors: foreground: palette:yellow author: alignment: center colors: foreground: palette:subtext1 positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: palette:teal h2: prefix: "▓▓▓" colors: foreground: palette:mauve h3: prefix: "▒▒▒▒" colors: foreground: palette:blue h4: prefix: "░░░░░" colors: foreground: palette:red h5: prefix: "░░░░░░" colors: foreground: palette:green h6: prefix: "░░░░░░░" colors: foreground: palette:peach block_quote: prefix: "▍ " colors: foreground: palette:text background: palette:surface0 prefix: palette:yellow alert: prefix: "▍ " base_colors: foreground: palette:text background: palette:surface0 styles: note: color: palette:blue tip: color: palette:green important: color: palette:mauve warning: color: palette:peach caution: color: palette:red typst: colors: foreground: palette:text background: palette:surface0 footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: palette:sapphire mermaid: background: transparent theme: dark d2: theme: 200 palette: colors: rosewater: "f5e0dc" flamingo: "f2cdcd" pink: "f5c2e7" mauve: "cba6f7" red: "f38ba8" maroon: "eba0ac" peach: "fab387" yellow: "f9e2af" green: "a6e3a1" teal: "94e2d5" sky: "89dceb" sapphire: "74c7ec" blue: "89b4fa" lavender: "b4befe" text: "cdd6f4" subtext1: "bac2de" subtext0: "a6adc8" overlay2: "9399b2" overlay1: "7f849c" overlay0: "6c7086" surface2: "585b70" surface1: "45475a" surface0: "313244" base: "1e1e2e" mantle: "181825" crust: "11111b" presenterm-0.15.1/themes/dark.yaml000064400000000000000000000054441046102023000151750ustar 00000000000000--- default: margin: percent: 8 colors: foreground: palette:white background: "040312" slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: palette:orange bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: palette:white background: palette:black status: running: foreground: palette:light_blue success: foreground: palette:light_green failure: foreground: palette:red not_started: foreground: palette:orange padding: horizontal: 2 vertical: 1 inline_code: colors: foreground: "04de20" background: "455045" intro_slide: title: alignment: center colors: foreground: palette:light_blue font_size: 2 subtitle: alignment: center colors: foreground: palette:aqua event: alignment: center colors: foreground: palette:light_blue location: alignment: center colors: foreground: palette:aqua date: alignment: center colors: foreground: palette:orange author: alignment: center colors: foreground: "b6eada" positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: palette:blue h2: prefix: "▓▓▓" colors: foreground: palette:light_green h3: prefix: "▒▒▒▒" colors: foreground: palette:red h4: prefix: "░░░░░" colors: foreground: palette:gray h5: prefix: "░░░░░░" colors: foreground: palette:gray h6: prefix: "░░░░░░░" colors: foreground: palette:gray block_quote: prefix: "▍ " colors: foreground: palette:light_gray background: palette:blue_gray prefix: palette:orange alert: prefix: "▍ " base_colors: foreground: palette:light_gray background: palette:blue_gray styles: note: color: palette:blue tip: color: palette:light_green important: color: palette:purple warning: color: palette:orange caution: color: palette:red typst: colors: foreground: palette:light_gray background: palette:blue_gray footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: palette:orange mermaid: background: transparent theme: dark d2: theme: 200 palette: colors: blue: "3085c3" light_blue: "b4ccff" blue_gray: "292e42" aqua: "a5d7e8" light_green: "a8df8e" red: "f78ca2" orange: "ee9322" purple: "986ee2" white: "e6e6e6" black: "2d2d2d" gray: "d2d2d2" light_gray: "f0f0f0" presenterm-0.15.1/themes/gruvbox-dark.yaml000064400000000000000000000044371046102023000166700ustar 00000000000000--- default: margin: percent: 8 colors: foreground: "ebdbb2" background: "282828" slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: "fabd2f" bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: "ebdbb2" background: "3c3836" status: running: foreground: "83a598" success: foreground: "b8bb26" failure: foreground: "fb4934" not_started: foreground: "fabd2f" padding: horizontal: 2 vertical: 1 inline_code: colors: foreground: "b8bb26" intro_slide: title: alignment: center colors: foreground: "b8bb26" font_size: 2 subtitle: alignment: center colors: foreground: "83a598" event: alignment: center colors: foreground: "b8bb26" location: alignment: center colors: foreground: "83a598" date: alignment: center colors: foreground: "fabd2f" author: alignment: center colors: foreground: "d5c4a1" positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: "8ec07c" h2: prefix: "▓▓▓" colors: foreground: "d3869b" h3: prefix: "▒▒▒▒" colors: foreground: "83a598" h4: prefix: "░░░░░" colors: foreground: "fb4934" h5: prefix: "░░░░░░" colors: foreground: "b8bb26" h6: prefix: "░░░░░░░" colors: foreground: "fe8019" block_quote: prefix: "▍ " colors: foreground: "ebdbb2" background: "3c3836" prefix: "fabd2f" alert: prefix: "▍ " base_colors: foreground: "ebdbb2" background: "3c3836" styles: note: color: "83a598" tip: color: "b8bb26" important: color: "d3869b" warning: color: "fe8019" caution: color: "fb4934" typst: colors: foreground: "ebdbb2" background: "3c3836" footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: "83a598" mermaid: background: transparent theme: dark d2: theme: 103 presenterm-0.15.1/themes/light.yaml000064400000000000000000000044531046102023000153620ustar 00000000000000--- default: margin: percent: 8 colors: foreground: "212529" background: "f8f9fa" slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: "f77f00" bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: GitHub padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: "212529" background: "e9ecef" status: running: foreground: "457b9d" success: foreground: "52b788" failure: foreground: "f07167" not_started: foreground: "f77f00" padding: horizontal: 2 vertical: 1 inline_code: colors: foreground: "f07167" background: "f5cac3" intro_slide: title: alignment: center colors: foreground: "52b788" font_size: 2 subtitle: alignment: center colors: foreground: "8e9aaf" event: alignment: center colors: foreground: "52b788" location: alignment: center colors: foreground: "f77f00" date: alignment: center colors: foreground: "f77f00" author: alignment: center colors: foreground: "4a4e69" positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: "1d3557" h2: prefix: "▓▓▓" colors: foreground: "457b9d" h3: prefix: "▒▒▒▒" colors: foreground: "4a4e69" h4: prefix: "░░░░░" colors: foreground: "4a4e69" h5: prefix: "░░░░░░" colors: foreground: "4a4e69" h6: prefix: "░░░░░░░" colors: foreground: "4a4e69" block_quote: prefix: "▍ " colors: foreground: "212529" background: "e9ecef" prefix: "f77f00" alert: prefix: "▍ " base_colors: foreground: "212529" background: "e9ecef" styles: note: color: "1e66f5" tip: color: "40a02b" important: color: "8839ef" warning: color: "df8e1d" caution: color: "d20f39" typst: colors: foreground: "212529" background: "e9ecef" footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: "f77f00" mermaid: background: transparent theme: default d2: theme: 4 presenterm-0.15.1/themes/terminal-dark.yaml000064400000000000000000000042331046102023000170010ustar 00000000000000--- default: margin: percent: 8 colors: foreground: null background: null slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: yellow bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 background: false execution_output: colors: foreground: white status: running: foreground: blue success: foreground: green failure: foreground: red not_started: foreground: yellow padding: horizontal: 2 vertical: 1 inline_code: colors: foreground: green intro_slide: title: alignment: center colors: foreground: green font_size: 2 subtitle: alignment: center colors: foreground: blue event: alignment: center colors: foreground: green location: alignment: center colors: foreground: blue date: alignment: center colors: foreground: yellow author: alignment: center colors: foreground: white positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: cyan h2: prefix: "▓▓▓" colors: foreground: magenta h3: prefix: "▒▒▒▒" colors: foreground: red h4: prefix: "░░░░░" colors: foreground: blue h5: prefix: "░░░░░░" colors: foreground: blue h6: prefix: "░░░░░░░" colors: foreground: blue block_quote: prefix: "▍ " colors: foreground: white background: black prefix: yellow alert: prefix: "▍ " base_colors: foreground: white background: black styles: note: color: blue tip: color: green important: color: magenta warning: color: yellow caution: color: red typst: colors: foreground: "f0f0f0" footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: yellow mermaid: background: transparent theme: dark d2: theme: 200 presenterm-0.15.1/themes/terminal-light.yaml000064400000000000000000000044011046102023000171640ustar 00000000000000--- default: margin: percent: 8 colors: foreground: null background: null slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: dark_yellow bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: GitHub padding: horizontal: 2 vertical: 1 background: false execution_output: colors: foreground: black status: running: foreground: dark_blue success: foreground: dark_green failure: foreground: dark_red not_started: foreground: dark_yellow padding: horizontal: 2 vertical: 1 inline_code: colors: foreground: dark_green intro_slide: title: alignment: center colors: foreground: dark_green font_size: 2 subtitle: alignment: center colors: foreground: dark_blue event: alignment: center colors: foreground: dark_green location: alignment: center colors: foreground: dark_blue date: alignment: center colors: foreground: dark_yellow author: alignment: center colors: foreground: black positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: dark_cyan h2: prefix: "▓▓▓" colors: foreground: dark_magenta h3: prefix: "▒▒▒▒" colors: foreground: dark_red h4: prefix: "░░░░░" colors: foreground: dark_blue h5: prefix: "░░░░░░" colors: foreground: dark_blue h6: prefix: "░░░░░░░" colors: foreground: dark_blue block_quote: prefix: "▍ " colors: foreground: black background: grey prefix: dark_red alert: prefix: "▍ " base_colors: foreground: black background: grey styles: note: color: dark_blue tip: color: dark_green important: color: dark_magenta warning: color: dark_yellow caution: color: dark_red typst: colors: foreground: "212529" footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: dark_yellow mermaid: background: transparent theme: default d2: theme: 4 presenterm-0.15.1/themes/tokyonight-storm.yaml000064400000000000000000000044701046102023000176130ustar 00000000000000--- default: margin: percent: 8 colors: foreground: "c0caf5" background: "24283b" slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: "e0af68" bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: "c0caf5" background: "2d2d2d" status: running: foreground: "7aa2f7" success: foreground: "9ece6a" failure: foreground: "f7768e" not_started: foreground: "e0af68" padding: horizontal: 2 vertical: 1 inline_code: colors: foreground: "9ece6a" background: "364a82" intro_slide: title: alignment: center colors: foreground: "7aa2f7" font_size: 2 subtitle: alignment: center colors: foreground: "a9b1d6" event: alignment: center colors: foreground: "7aa2f7" location: alignment: center colors: foreground: "a9b1d6" date: alignment: center colors: foreground: "e0af68" author: alignment: center colors: foreground: "9ece6a" positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: "9ece6a" h2: prefix: "▓▓▓" colors: foreground: "f7768e" h3: prefix: "▒▒▒▒" colors: foreground: "7aa2f7" h4: prefix: "░░░░░" colors: foreground: "bb9af7" h5: prefix: "░░░░░░" colors: foreground: "bb9af7" h6: prefix: "░░░░░░░" colors: foreground: "bb9af7" block_quote: prefix: "▍ " colors: foreground: "f0f0f0" background: "545c7e" prefix: "e0af68" alert: prefix: "▍ " base_colors: foreground: "f0f0f0" background: "545c7e" styles: note: color: "7aa2f7" tip: color: "9ece6a" important: color: "bb9af7" warning: color: "e0af68" caution: color: "f7768e" typst: colors: foreground: "f0f0f0" background: "545c7e" footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: "e0af68" mermaid: background: transparent theme: dark d2: theme: 200