mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6702ec8878 | ||
|
|
1bb86db54a | ||
|
|
ba56742fa9 | ||
|
|
8b160d5b03 | ||
|
|
39a8234633 | ||
|
|
98ea8d3195 | ||
|
|
2e68504e7c | ||
|
|
e6050b7d37 | ||
|
|
72d8e0ab42 | ||
|
|
2347a53845 | ||
|
|
692aa0103b | ||
|
|
ceb6e3b990 | ||
|
|
4d13652f0f | ||
|
|
235e34613b | ||
|
|
688c7ef592 | ||
|
|
a4540c1f0e | ||
|
|
0ec0cc5e81 | ||
|
|
be14248577 | ||
|
|
a98df49b21 | ||
|
|
f44857624e | ||
|
|
d556ff98b5 | ||
|
|
8fe168c07b | ||
|
|
7f1e152870 | ||
|
|
3985d768e6 | ||
|
|
e8c4c60884 | ||
|
|
a8c983ba6b | ||
|
|
788831834b | ||
|
|
ddbd71e297 | ||
|
|
b1e6867c0b | ||
|
|
c2e7f7cc3c | ||
|
|
9c3840ffa4 | ||
|
|
5c13f6ec72 | ||
|
|
c543ef350e | ||
|
|
322d85074e | ||
|
|
08d57ca15c | ||
|
|
e17fcd5598 | ||
|
|
84acb49895 | ||
|
|
4b09f35c1d | ||
|
|
57545a99c8 | ||
|
|
fa9a94a1c0 | ||
|
|
89d23ffa85 | ||
|
|
80e8a0fe53 | ||
|
|
ab6f511479 | ||
|
|
b48870989c | ||
|
|
b663be69b0 | ||
|
|
34d5197370 | ||
|
|
5fed177cbe | ||
|
|
baff6a2ff2 | ||
|
|
68aff057bf | ||
|
|
21f1703994 | ||
|
|
d0e57d7e25 | ||
|
|
3e9bd4398a | ||
|
|
db67e0109f | ||
|
|
1839f41fce | ||
|
|
286ff74c8b | ||
|
|
e659ebcd27 | ||
|
|
a4bb8fe763 | ||
|
|
9da767d5f7 | ||
|
|
efb03b90a8 |
@@ -23,12 +23,14 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-15]
|
||||
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
|
||||
# release.yml builds macOS (a failing release is fixed and rebuilt).
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
@@ -39,11 +41,11 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: ci-${{ matrix.os }}
|
||||
- name: cargo xtask ci
|
||||
|
||||
@@ -23,34 +23,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# The Japanese font tests run against craft-fonts (an optional build input; they skip without it).
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CRAFT_FONTS_DIR="$PWD/craft-fonts" CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
|
||||
@@ -19,39 +19,5 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/deckcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/deckcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.deckcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.deckcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.deckcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.deckcraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.deckcraft.yml')); assert m['id'] == 'ai.storyteller.deckcraft' and m['command'] == 'deckcraft'"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
+23
-414
@@ -1,14 +1,3 @@
|
||||
# Release pipeline: every push to `release` builds installers for every platform and creates or
|
||||
# updates the draft GitHub Release "DeckCraft v<version>" (tag v<version>). Recipe:
|
||||
# craftrules release/playbook.md.
|
||||
#
|
||||
# The version has one source: `[workspace.package] version` in Cargo.toml (`cargo xtask version`,
|
||||
# `cargo xtask version set X.Y.Z`). The app shows it in About and `--version`; the release name
|
||||
# and tag use it too. workflow_dispatch can override it for a test build (applied to the build).
|
||||
#
|
||||
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
|
||||
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
|
||||
# warning, never a failed build.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -22,413 +11,33 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
DECKCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
# Optional font input (storytold/craft-fonts docs/integration.md); releases always embed it.
|
||||
# This commit adds fonts/latin-manifest.txt (the Latin presentation fonts).
|
||||
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: version
|
||||
# Only the release branch builds (a manual dispatch from any other ref skips everything). The
|
||||
# branch itself is writable only by release-managers and org admins (ruleset "release").
|
||||
if: github.ref == 'refs/heads/release'
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/common.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### DeckCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15
|
||||
environment: release
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$DECKCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
- name: Build, sign, notarize
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$DECKCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10).
|
||||
runs-on: ${{ matrix.runner }}
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
desktop-file-utils appstream python3-yaml
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$DECKCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/deckcraft$|metainfo|applications'
|
||||
ldd target/release/deckcraft
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak-sources:
|
||||
name: Flatpak crate sources
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Vendor the crate sources (flatpak builds run offline)
|
||||
run: |
|
||||
python3 -m venv "$RUNNER_TEMP/venv"
|
||||
"$RUNNER_TEMP/venv/bin/pip" install --quiet aiohttp tomlkit
|
||||
curl -fsSL -o "$RUNNER_TEMP/flatpak-cargo-generator.py" \
|
||||
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
|
||||
"$RUNNER_TEMP/venv/bin/python" "$RUNNER_TEMP/flatpak-cargo-generator.py" Cargo.lock -o cargo-sources.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: flatpak-cargo-sources
|
||||
path: cargo-sources.json
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Linux Flatpak
|
||||
needs: [version, flatpak-sources]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
# Best effort: Flathub builds from source itself (playbook §7); the bundle is a convenience.
|
||||
continue-on-error: true
|
||||
container:
|
||||
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
|
||||
options: --privileged
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: |
|
||||
# No cargo in this container: the same edit `cargo xtask version set` makes.
|
||||
sed -i "0,/^version = \".*\"/s//version = \"$DECKCRAFT_VERSION\"/" Cargo.toml
|
||||
grep -m1 '^version' Cargo.toml
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: flatpak-cargo-sources
|
||||
path: packaging/linux/flatpak
|
||||
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
||||
with:
|
||||
manifest-path: packaging/linux/flatpak/ai.storyteller.deckcraft.yml
|
||||
bundle: deckcraft-${{ needs.version.outputs.version }}-linux-x86_64.flatpak
|
||||
upload-artifact: false
|
||||
cache-key: flatpak-builder-${{ github.sha }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak
|
||||
path: deckcraft-*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
VERSION_OVERRIDE: ${{ inputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
envs: DECKCRAFT_VERSION DECKCRAFT_BUILD_DATE DECKCRAFT_BUILD_SHA VERSION_OVERRIDE
|
||||
# Copies the workspace (with dist/release) back; the target dir lives outside it so the
|
||||
# copy stays small.
|
||||
copyback: true
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "*"
|
||||
export CARGO_TARGET_DIR="$HOME/target" CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
if [ -n "$VERSION_OVERRIDE" ]; then cargo xtask version set "$DECKCRAFT_VERSION"; fi
|
||||
bash packaging/freebsd/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$DECKCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
# The installers are required; the Flatpak bundle and FreeBSD tarball are attached when they
|
||||
# built.
|
||||
if: >-
|
||||
!cancelled() && needs.version.result == 'success' && needs.macos.result == 'success' &&
|
||||
needs.windows.result == 'success' && needs.linux.result == 'success' && needs.web.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="DeckCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: deckcraft
|
||||
app-title: DeckCraft
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
DeckCraft is a clean-room, open-source, pure-Rust presentation application targeting Microsoft
|
||||
PowerPoint parity — and superiority (speed, openness, agent control). It runs natively on macOS,
|
||||
Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions:
|
||||
PhotoCraft, VectorCraft, FilmCraft, LightCraft, PrintCraft, EffectCraft, DesignCraft
|
||||
PhotoCraft, VectorCraft, FilmCraft, LightCraft, PdfCraft, EffectCraft, DesignCraft
|
||||
(`../<app>`).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out
|
||||
@@ -158,5 +158,36 @@ to `main`.
|
||||
## 7. Planning
|
||||
|
||||
`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`,
|
||||
`STATUS.md` (start every session there) and `powerpoint/` observations. `ROADMAP.md` (committed)
|
||||
tracks status, milestones, parity and estimates; update it whenever a milestone task lands.
|
||||
`STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
|
||||
docs follow craftrules `standards/progress-docs.md`:
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `ROADMAP.md` | Stage, headline numbers, dimensions, features, languages, progress log |
|
||||
| `docs/target-app-parity.md` | The parity assessment against PowerPoint: percentages, hours, calibration |
|
||||
| `docs/gaps.md` | Every known shortfall, ranked: the work list. Pick from the top |
|
||||
| `docs/roadmap.md` | Current focus, the beta plan, milestones |
|
||||
| `docs/parity-checklist.md` | Feature rows scored D/P/M; `cargo xtask parity` regenerates its summary |
|
||||
| `docs/file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`, `animation-parity.md`, `slideshow-parity.md` | Per-dimension and per-feature detail |
|
||||
| `docs/architecture.md` | How DeckCraft is built today |
|
||||
|
||||
When a task lands, re-score its checklist row, delete or update its `gaps.md` entry, add a line to
|
||||
the `ROADMAP.md` progress log, and update each touched doc's status line and revision history.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
@@ -18,12 +18,17 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
|
||||
| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Mark for light backgrounds |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `standards/license-files` | — (licence text) | Terms for the ArtCraft marks |
|
||||
| `assets/app-icon/deckcraft-1024.png`, `assets/app-icon/hicolor/*/apps/ai.storyteller.deckcraft.png` | DeckCraft contributors | rendered by DeckCraft from `assets/app-icon/icon-source.deckcraft` (shapes only) | MIT OR Apache-2.0 | Placeholder app icon (presentation board) until the mascot icon is drawn |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | DeckCraft macOS DMG window background, original work for this repository (2026-10-08), derived from the DeckCraft app icon `assets/app-icon/deckcraft-1024.png` (referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Finder window of the macOS DMG; `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
| `assets/app-icon/deckcraft.icns`, `assets/app-icon/deckcraft.ico` | DeckCraft contributors | generated from the PNGs above by `packaging/icons.sh` (sips/iconutil, `cargo xtask ico`) | MIT OR Apache-2.0 | macOS and Windows app icons |
|
||||
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.deckcraft.svg` | DeckCraft contributors | the shapes of `assets/app-icon/icon-source.deckcraft` written out as SVG (same coordinates, colours and corner radii) | MIT OR Apache-2.0 | Scalable app icon for Linux desktops (hicolor theme) |
|
||||
| `assets/app-icon/icon-source.deckcraft` | DeckCraft contributors | made with `deckcraft-cli run` (see git history) | MIT OR Apache-2.0 | Source of the placeholder icon |
|
||||
| `docs/images/editor.png`, `docs/images/shape-format.png`, `docs/images/format-pane.png`, `docs/images/slide-sorter.png`, `docs/images/dark-mode.png`, `docs/images/sample-deck.png` | DeckCraft contributors | Screenshots of DeckCraft and slides rendered by `deckcraft-cli` from the built-in sample deck (original content; Inter font, OFL-1.1) | MIT OR Apache-2.0 | README screenshots |
|
||||
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
|
||||
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
|
||||
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
|
||||
| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests |
|
||||
| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font |
|
||||
|
||||
## Bundled through dependencies
|
||||
|
||||
|
||||
@@ -13,5 +13,5 @@ win over anything here.
|
||||
- Look at UI changes: `deckcraft --sample --control 7990` + `ui.screenshot`, or
|
||||
`deckcraft-cli render`.
|
||||
- Commit after each landed arc (`M<n>.<k>: what`) and push to `origin main`.
|
||||
- Keep `ROADMAP.md` and `plan/STATUS.md` current.
|
||||
- Keep `ROADMAP.md`, the progress docs in `docs/` (AGENTS.md §7) and `plan/STATUS.md` current.
|
||||
- Don't ask the user questions; record genuinely-theirs decisions in `plan/STATUS.md`.
|
||||
|
||||
Generated
+289
-27
@@ -644,6 +644,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -866,7 +877,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"cpal",
|
||||
@@ -885,7 +896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -894,7 +905,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -903,14 +914,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -919,14 +930,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
@@ -948,7 +959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -960,7 +971,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -971,7 +982,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -979,7 +990,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -988,7 +999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -997,7 +1008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1005,11 +1016,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1019,7 +1030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1037,7 +1048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1048,15 +1059,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-model",
|
||||
@@ -1071,7 +1082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1087,7 +1098,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
@@ -1097,25 +1108,28 @@ dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
"log",
|
||||
"skrifa 0.47.0",
|
||||
"vello_common 0.2.0",
|
||||
"vello_cpu 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
"unicode-bidi",
|
||||
"unicode-linebreak",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1132,11 +1146,12 @@ dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1145,7 +1160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -1202,6 +1217,17 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.3"
|
||||
@@ -1335,6 +1361,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"webbrowser",
|
||||
"winit",
|
||||
]
|
||||
|
||||
@@ -1633,6 +1660,15 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -2047,6 +2083,134 @@ version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9d4a992a9fe83130f41ceacceac3bb116a4355dfc9c8d6ecea4f1e4b4c6caf"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
@@ -2348,6 +2512,12 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
@@ -3172,6 +3342,15 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -4196,6 +4375,16 @@ dependencies = [
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
@@ -4383,6 +4572,24 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.27.0"
|
||||
@@ -4699,6 +4906,22 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webbrowser"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
|
||||
dependencies = [
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"ndk-context",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"url",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -5356,6 +5579,12 @@ dependencies = [
|
||||
"read-fonts 0.39.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
@@ -5427,7 +5656,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -5602,6 +5831,39 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "3.0.0"
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -48,6 +48,7 @@ rayon = "1"
|
||||
unicode-bidi = "0.3"
|
||||
unicode-segmentation = "1"
|
||||
unicode-linebreak = "0.1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
|
||||
# text / rendering / formats
|
||||
skrifa = "0.47"
|
||||
|
||||
@@ -51,13 +51,14 @@
|
||||
<a href="#getting-started">Getting started</a> ·
|
||||
<a href="#agents-cli-and-mcp">Agents, CLI and MCP</a> ·
|
||||
<a href="#roadmap">Roadmap</a> ·
|
||||
<a href="#downloads">Downloads</a> ·
|
||||
<a href="#the-crafting-apps">The Crafting Apps</a> ·
|
||||
<a href="#license-and-credits">License</a>
|
||||
</p>
|
||||
|
||||
## What works today
|
||||
|
||||
DeckCraft is in early development, heading for its first alpha (see the [roadmap](ROADMAP.md)).
|
||||
DeckCraft is in alpha, heading for beta (see the [roadmap](ROADMAP.md)).
|
||||
Today it can:
|
||||
|
||||
- **Make decks**: slide masters and eleven layouts with placeholders that inherit position and
|
||||
@@ -102,10 +103,43 @@ cd deckcraft
|
||||
cargo run --release -p deckcraft-cli -- render --sample --all --scale 1 out/
|
||||
```
|
||||
|
||||
On Linux, the build also needs the ALSA headers for slide-show sound: `alsa-lib-devel` on Fedora,
|
||||
`libasound2-dev` on Debian and Ubuntu, `alsa-lib` on Arch. Without them it stops at `alsa-sys`.
|
||||
|
||||
Fonts come from [craft-fonts](https://github.com/storytold/craft-fonts): clone it next to this repo
|
||||
(`../craft-fonts`) and local builds pick it up automatically; without it DeckCraft uses your system
|
||||
fonts.
|
||||
|
||||
Each [GitHub release](https://github.com/storytold/deckcraft/releases) has ready-made builds, on Linux as
|
||||
an AppImage, a `.deb`, an `.rpm` and a tarball. On Gentoo, the community [::snakebyte
|
||||
overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as
|
||||
`app-office/deckcraft-bin` (not maintained by the DeckCraft team):
|
||||
|
||||
```sh
|
||||
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
|
||||
emaint sync -r snakebyte
|
||||
echo 'app-office/deckcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/deckcraft
|
||||
emerge --ask app-office/deckcraft-bin
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
The desktop app writes its `log` records to standard error and to `logs/deckcraft.log` in the
|
||||
settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/deckcraft/logs/`, or
|
||||
`$XDG_CONFIG_HOME/deckcraft/logs/`; macOS `~/Library/Application Support/DeckCraft/logs/`; Windows
|
||||
`%APPDATA%\DeckCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so this file
|
||||
is what to attach to a bug report: the crash guard's panic report, a failed crash-recovery save,
|
||||
PowerPoint parts skipped on import and audio output problems land there. Each launch moves the
|
||||
previous log to `deckcraft.1.log` (and that one to `deckcraft.2.log`), so the log of a run that
|
||||
crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file.
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DeckCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,deckcraft_pptx=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`deckcraft*=debug`). |
|
||||
|
||||
The logger is `apps/deckcraft/src/logging.rs`; the realtime audio callback does not log. The web
|
||||
app logs to the browser console instead.
|
||||
|
||||
## Agents, CLI and MCP
|
||||
|
||||
Every action in DeckCraft is a command with a stable id, so people, scripts and AI agents use the
|
||||
@@ -120,10 +154,72 @@ deckcraft-cli mcp --connect 7990 # drive the running app (deckcraft --con
|
||||
|
||||
## Roadmap
|
||||
|
||||
DeckCraft covers about **79% of PowerPoint's features by breadth** (93% of the core ones) and is
|
||||
roughly **80% of the way to a first alpha**. See [ROADMAP.md](ROADMAP.md) for the alpha checklist,
|
||||
parity estimates and what's next, and [docs/parity.md](docs/parity.md) for the feature-by-feature
|
||||
scorecard.
|
||||
DeckCraft is in **alpha**. It covers **79% of PowerPoint's features by breadth** (92% of the core
|
||||
ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated).
|
||||
See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next,
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment,
|
||||
[docs/gaps.md](docs/gaps.md) for every known shortfall and
|
||||
[docs/parity-checklist.md](docs/parity-checklist.md) for the feature-by-feature checklist.
|
||||
|
||||
## Downloads
|
||||
|
||||
**Download DeckCraft** from GitHub: the [latest release](https://github.com/storytold/deckcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/deckcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `deckcraft-<ver>-windows-x64.msi` | `deckcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `deckcraft-<ver>-windows-arm64.msi` | `deckcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `deckcraft-<ver>-windows-x86.msi` | `deckcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
|
||||
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
|
||||
`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit.
|
||||
`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In
|
||||
PowerShell, from the folder containing the executable:
|
||||
|
||||
```powershell
|
||||
$env:WGPU_BACKEND = "vulkan"
|
||||
& .\deckcraft.exe
|
||||
Remove-Item Env:WGPU_BACKEND # restore the default for later launches
|
||||
```
|
||||
|
||||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| App, universal (Apple silicon + Intel) | `deckcraft-<ver>-macos-universal.dmg` | Signed and notarized |
|
||||
| Command-line tool, universal | `deckcraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | aarch64 (ARM64) | Notes |
|
||||
|---|---|---|---|
|
||||
| AppImage | `deckcraft-<ver>-linux-x86_64.AppImage` | `deckcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
|
||||
| Flatpak | `deckcraft-<ver>-linux-x86_64.flatpak` | `deckcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `deckcraft-<ver>-linux-x86_64.deb` | `deckcraft-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `deckcraft-<ver>-linux-x86_64.rpm` | `deckcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `deckcraft-<ver>-linux-x86_64.tar.gz` | `deckcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
RISC-V 64 (riscv64): `deckcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (cross-compiled; needs glibc 2.39+, e.g. Ubuntu 24.04+).
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
|---|---|
|
||||
| x86_64 | `deckcraft-<ver>-freebsd-x86_64.tar.gz` |
|
||||
|
||||
### Web (WebAssembly)
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| Static site | `deckcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
@@ -137,7 +233,7 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | **Presentations and slide shows · you are here** | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
|
||||
+128
-82
@@ -1,100 +1,146 @@
|
||||
# DeckCraft roadmap
|
||||
|
||||
DeckCraft aims at full PowerPoint parity — and to be better: faster, open (a documented zip+JSON
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
|
||||
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
|
||||
(macOS, Windows, Linux, BSD and the web).
|
||||
(macOS, Windows, Linux, BSD and the web). This page is the summary; the detail is in
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) and the work list in
|
||||
[docs/gaps.md](docs/gaps.md).
|
||||
|
||||
## Status (2026-10-07)
|
||||
## Headline numbers
|
||||
|
||||
**Working today**
|
||||
- Document model: presentations, slides, masters and 11 standard layouts with placeholder inheritance
|
||||
(slide → layout → master → theme), sections, notes, comments, custom shows, header/footer fields,
|
||||
embedded media, unlimited undo built on shared slide snapshots.
|
||||
- Themes: 8 original themes with colour and font schemes, custom colours/fonts, backgrounds,
|
||||
slide size presets.
|
||||
- Shapes: ~150 preset geometries with adjustment handles, text boxes, pictures (with crop and
|
||||
adjustments), tables with styles, charts (column, bar, line, area, pie, doughnut, scatter and more),
|
||||
basic SmartArt, groups, connectors, ink, action buttons, WordArt.
|
||||
- Text: in-place editing with caret, selection, keyboard and mouse; every Home-tab font and paragraph
|
||||
control; bullets and numbering; levels; autofit; columns; vertical text; fields; hyperlinks.
|
||||
- Editing: select, marquee, move, resize, rotate, adjust; smart guides; nudge; duplicate; z-order;
|
||||
group/ungroup; align/distribute; Format Painter; Selection pane; clipboard incl. images.
|
||||
- Transitions (fade, push, wipe, split, cover, uncover, zoom, morph…) and animations (entrance,
|
||||
emphasis, exit, motion paths, triggers, by-paragraph builds) on a shared timeline engine.
|
||||
- Slide show: full screen, keyboard/mouse navigation, blank screens, go-to-slide, pen, presenter
|
||||
window, rehearse timings, custom shows, Set Up Show; Reading View.
|
||||
- Views: Normal (thumbnails with sections, slide, notes), Outline, Slide Sorter, Notes Page,
|
||||
Slide Master; zoom; grayscale.
|
||||
- Review: comments, accessibility checker, spelling.
|
||||
- Audio and video: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis and Opus audio; H.264,
|
||||
HEVC, VP9 and AV1 video in MP4/MOV/WebM/MKV (pure-Rust decoders); poster frames, trim, fades,
|
||||
volume, loop, rewind, play across slides, hide during show, full screen; an in-place control bar
|
||||
in the editor and `media.play/pause/stop/seek/info/posterFrame` for agents. WMA/WMV are
|
||||
recognised and embedded but not yet playable.
|
||||
- PDF export: slides, notes pages and handouts (1–9 per page) with a selectable real-text layer, hyperlinks and slide bookmarks (`file.export {format: "pdf", layout}`, File › Export…).
|
||||
- UI: PowerPoint-style ribbon with contextual tabs, ~240 original icons, status bar, panes, command
|
||||
palette, light/dark.
|
||||
- Automation: ~200 commands, every one reachable from `deckcraft-cli`, the app's JSON control
|
||||
channel and the MCP server (headless or connected to the running app).
|
||||
- Web: `apps/deckcraft-web` runs the same UI in the browser (trunk; WebGPU with WebGL2 fallback),
|
||||
opening the sample deck; Open/Insert use the browser file picker, Save/Export download.
|
||||
- Release CI: pushes to `release` build a draft GitHub Release (macOS universal dmg, Windows
|
||||
x64/x86 msi + zip, Linux AppImage/deb/rpm/tar.gz + Flatpak, FreeBSD tar.gz, web zip); signing
|
||||
secrets live in the `release` environment. Version: `cargo xtask version`.
|
||||
|
||||
**Next (in order):** first signed release run · PPTX corpus hardening against real-world decks ·
|
||||
Animation Pane and presenter view polish · vector PDF artwork · Format Shape pane depth (3-D,
|
||||
picture/texture options) · edit points · native macOS menu bar · print · Notes/Handout masters ·
|
||||
equations · SVG pictures · WMA/WMV decoding.
|
||||
|
||||
Milestone details live in `plan/execution-plan.md` (M0–M14, local planning notes).
|
||||
|
||||
## How close to an alpha (estimate, 2026-10-07)
|
||||
|
||||
An **alpha** here means: someone can install a signed build on macOS, Windows or Linux, make a real
|
||||
deck from scratch or from a PowerPoint file, present it with transitions, animations and media,
|
||||
save it back to `.pptx`/PDF without losing work, and hit no crashes on the common paths.
|
||||
|
||||
**DeckCraft is about 80% of the way to that alpha — roughly 30–40 wall-clock hours of a single
|
||||
Claude Opus 5.5 agent** (about 12–18 hours with three agents in parallel).
|
||||
|
||||
| Alpha blocker | State | Estimate |
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| First real release run: signing, notarization, installers verified on each OS | Workflows written and linted, never run | 6 h |
|
||||
| PPTX fidelity on a corpus of real decks (import, round-trip, opens without repair) | Verified on generated decks only | 8 h |
|
||||
| Presenter view and Animation Pane polish (timeline, reorder, preview) | Working, rough | 6 h |
|
||||
| Soak and fuzz the editor (random command sequences, big decks, undo/redo) for crashes and slowness | Unit tests and guards only | 5 h |
|
||||
| UI fidelity pass on the most-used ribbon groups and dialogs; first-run experience | Mostly there | 6 h |
|
||||
| Mascot app icon (owner), README/site screenshots, user docs | Placeholder icon | 3 h |
|
||||
| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 36%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands |
|
||||
| **Ready for real work** | **~55%** | estimated: weighted dimensions below |
|
||||
| Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
|
||||
| Essentials user | ~62% | estimated: core features × discounts for launch (×0.90), clarity (×0.95) and opening received files (×0.90), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
|
||||
| Hours to beta | **~120–210** Opus 5.5 agent hours (~40–70 h with 3–4 agents) | estimated, [docs/roadmap.md](docs/roadmap.md) |
|
||||
| Hours to full parity | **~365–650** Opus 5.5 agent hours | estimated, [docs/target-app-parity.md](docs/target-app-parity.md) |
|
||||
|
||||
Already alpha-ready: editing, text, shapes (incl. connectors, freeform, merge), themes and
|
||||
backgrounds, tables, charts, transitions, animations, slide show, audio/video playback, PDF export,
|
||||
AutoRecover, CLI/MCP automation, web build.
|
||||
### Readiness by audience
|
||||
|
||||
## How far from full parity (estimate, 2026-10-07)
|
||||
| Audience | Ready | Opus 5.5 agent hours to ~95% | Work that dominates |
|
||||
|---|---:|---|---|
|
||||
| Full target (ready for real work) | ~55% | 340–600 (~60% parallelizes: ~120–210 h wall clock with 3–4 agents) | Feature depth across all 20 areas, legacy `.ppt`/`.odp` and video export, localization in 11+ languages, GPU raster and hardware decode, AI features |
|
||||
| Mainstream practitioner | ~53% | 150–250 (~50% parallelizes: ~70–120 h with 3 agents) | `.pptx` exchange (real-deck corpus, round-trip losses, EMF/SVG, embedded fonts) 45–75 h; depth in charts, animations, pictures, text, presenting, print/vector PDF 70–110 h; interaction (menu bar, Format Shape pane, font faces, dialogs) 25–40 h; stability 10–20 h |
|
||||
| Essentials user | ~62% | 50–90 (~40% parallelizes: ~30–55 h with 2–3 agents) | Opening received decks (corpus subset, EMF, SVG icons) 25–45 h; Windows launch fallback and Close prompt 6–12 h; font faces, Tab, paste, print, show navigation 12–20 h; start screen, menu bar, palette 6–10 h |
|
||||
|
||||
**Breadth: ~79% weighted** (P0 core 93%, P1 74%, P2 35%) over the 187 features of the PowerPoint catalogue, scored row by
|
||||
row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored
|
||||
done still lack some of PowerPoint's options, dialogs or pixel fidelity, so **overall parity
|
||||
including depth is about 62%**.
|
||||
Calibration is the same as for the full number (see [docs/target-app-parity.md](docs/target-app-parity.md#calibration-how-the-hours-were-derived)): a fix is 0.5–1 h, a checklist row to PowerPoint depth 2–4 h, a subsystem 6–35 h. The sets are nested, so essentials ≤ mainstream ≤ full. The full number is an additive weighted sum over the dimension table; only mainstream and essentials use multiplicative discounts.
|
||||
|
||||
**Remaining work to 100%: about 190 wall-clock hours of a single Claude Opus 5.5 agent** (±30%),
|
||||
or roughly 65–90 hours with four agents in parallel on separate crates:
|
||||
**Why alpha:** all six of PowerPoint's core workflows pass the alpha gate
|
||||
([docs/roadmap.md](docs/roadmap.md#alpha-gate-powerpoints-core-workflows)): build, open and save
|
||||
`.pptx`, present, add tables and charts, share as PDF, review. Core workflows work end to end (build a deck, open and save `.pptx`, present with
|
||||
transitions, animations, media and presenter view, export PDF), but depth, fidelity and polish
|
||||
have known gaps, and PPTX has never been tested on a corpus of real decks. Beta needs ~75% ready
|
||||
and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74, #77),
|
||||
EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the beta list in
|
||||
[docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
| Work | Estimate |
|
||||
|---|---|
|
||||
| Alpha blockers above | 35 h |
|
||||
| Open P0/P1 rows (vector PDF, print, edit points, SVG, multi-monitor, draw table, SmartArt text pane, Notes/Handout masters…) | 45 h |
|
||||
| Open P2 rows (equations, video export, 3-D, remove background, thesaurus, compare, record show…) | 40 h |
|
||||
| Depth and pixel fidelity of every ribbon group, dialog and pane against PowerPoint | 50 h |
|
||||
| Performance (incremental rendering, GPU raster, streaming media decode) | 20 h |
|
||||
## By dimension
|
||||
|
||||
Basis: in this session the engine, renderer, UI, show engine, PPTX, PDF, media and release
|
||||
pipeline were built in about 30 wall-clock hours with up to three agents in parallel; recent
|
||||
catalogue rows (connectors, freeform, merge shapes, AutoRecover, gradients) took 1–2 hours each.
|
||||
| Dimension | Ready | Hours to full | Doc |
|
||||
|---|---:|---|---|
|
||||
| Features (breadth 79% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| UI/UX fidelity | ~60% | 30–50 | [docs/ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats (PPTX, PDF, legacy, export) | ~58% | 60–110 | [docs/file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware (GPU raster, video decode, displays, pens) | ~40% | 25–45 | [docs/hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization | ~8% | 55–90 + native review | [docs/localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance (unmeasured) | ~55% | 10–20 | [docs/hardware-parity.md](docs/hardware-parity.md#performance) |
|
||||
| Stability | ~60% | 10–20 | [docs/gaps.md](docs/gaps.md#stability) |
|
||||
| Platforms | ~80% | 5–10 | [Downloads in README](README.md#downloads) |
|
||||
| Ecosystem (templates; add-ins out of scope) | ~25% | 10–20 | [docs/gaps.md](docs/gaps.md#ecosystem) |
|
||||
| AI features | ~10% | 20–40 | [docs/gaps.md](docs/gaps.md#ai-features) |
|
||||
| Automation ✱ (CLI, control channel, MCP) | ahead | 0 | [docs/mcp.md](docs/mcp.md) |
|
||||
|
||||
## Features
|
||||
|
||||
| Area | Breadth (measured) | Ready (est.) | Hours | Doc |
|
||||
|---|---:|---:|---|---|
|
||||
| Application shell | 71% | ~55% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Slides and sections | 90% | ~85% | 3–5 | [checklist](docs/parity-checklist.md) |
|
||||
| Views | 90% | ~80% | 3–6 | [ui-parity](docs/ui-parity.md) |
|
||||
| Masters and themes | 85% | ~70% | 8–14 | [checklist](docs/parity-checklist.md) |
|
||||
| Shapes | 86% | ~75% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Format (fill, line, effects) | 81% | ~65% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Arrange | 100% | ~90% | 1–2 | [ui-parity](docs/ui-parity.md) |
|
||||
| Text | 80% | ~62% | 18–30 | [gaps](docs/gaps.md#features) |
|
||||
| Tables | 89% | ~75% | 3–6 | [checklist](docs/parity-checklist.md) |
|
||||
| Charts | 65% | ~45% | 12–20 | [gaps](docs/gaps.md#charts-data-grid-elements-and-the-newer-chart-types) |
|
||||
| SmartArt | 33% | ~25% | 10–16 | [gaps](docs/gaps.md#smartart-is-pre-drawn-shapes-only) |
|
||||
| Pictures | 48% | ~45% | 12–20 | [file formats](docs/file-format-parity.md) |
|
||||
| Media | 67% | ~60% | 8–14 | [file formats](docs/file-format-parity.md) |
|
||||
| Transitions | 92% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) |
|
||||
| Animations | 79% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) |
|
||||
| Slide show and presenter view | 66% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) |
|
||||
| Review | 60% | ~45% | 8–14 | [gaps](docs/gaps.md) |
|
||||
| Draw (ink) | 33% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Undo and clipboard | 88% | ~75% | 3–5 | [ui-parity](docs/ui-parity.md) |
|
||||
| Files (commands; formats above) | 69% | ~50% | in file formats | [file formats](docs/file-format-parity.md) |
|
||||
|
||||
## Languages
|
||||
|
||||
English only today; no string catalog yet (Ukrainian is in review as PR #34). Detail:
|
||||
[docs/localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Code | UI strings | Status | Hours to full |
|
||||
|---|---|---:|---|---|
|
||||
| English | en | 100% | full | 0 |
|
||||
| Simplified Chinese | zh-Hans | 0% | none | 6–10 |
|
||||
| Spanish | es | 0% | none | 3–5 |
|
||||
| Hindi | hi | 0% | none | 4–7 |
|
||||
| Arabic | ar | 0% (slide text: bidi and shaping done) | none | 10–16 |
|
||||
| French | fr | 0% | none | 3–5 |
|
||||
| Portuguese | pt | 0% | none | 3–5 |
|
||||
| Indonesian | id | 0% | none | 3–5 |
|
||||
| Japanese | ja | 0% | none | 4–7 |
|
||||
| German | de | 0% | none | 3–5 |
|
||||
| Korean | ko | 0% | none | 4–7 |
|
||||
| Vietnamese | vi | 0% | none | 3–5 |
|
||||
|
||||
Plus 8–14 h once for the catalog infrastructure. Other languages shipped: none. PowerPoint for Mac
|
||||
ships 26.
|
||||
|
||||
## Upcoming
|
||||
|
||||
Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
1. **No data loss, no startup crashes:** Close's save prompt (#60), Windows GPU crashes (#40, #59),
|
||||
image paste (#53), editor soak tests. 10–20 h.
|
||||
2. **PPTX on real decks:** owned corpus, round-trip checks, fixes; #70, #74, #77; EMF/WMF and SVG.
|
||||
36–64 h.
|
||||
3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h.
|
||||
4. **Output:** vector PDF, printing. 10–16 h.
|
||||
5. **Depth that users hit:** font weights (#69), chart data and elements, Animation Pane timeline,
|
||||
presenter view on the right display, native macOS menu bar, Format Shape pane. 33–56 h.
|
||||
|
||||
## Agents: CLI and MCP
|
||||
|
||||
Every command is reachable from `deckcraft-cli` (`run`, `describe`, `commands`, `app` for the running
|
||||
window, `render`, `convert`), from MCP (`deckcraft-cli mcp`, optionally `--connect PORT`), and from the
|
||||
app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
## Progress log
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
|
||||
| 2026-10-07 | v0.1.0 released, signed and notarized; About window with contributor credits (#3); PrintCraft renamed PdfCraft |
|
||||
| 2026-10-06 | Renamed SlideCraft to DeckCraft. PPTX import/export, PDF export, media playback with pure-Rust codecs, connectors with glue, freeform tools, merge shapes, AutoRecover, gradient editor, web build, release CI, parity catalogue |
|
||||
| 2026-10-05 | M0: workspace, model, geometry, fonts, text, renderer, engine, MCP, CLI, PowerPoint-style egui UI, slide show engine |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
|
||||
| 2026-10-10 | major | Restructured to craftrules `standards/progress-docs.md`: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md |
|
||||
| 2026-10-07 | major | Alpha checklist (~80% to a self-defined alpha), breadth ~80%, overall ~62%, ~190 h to full parity |
|
||||
|
||||
@@ -14,7 +14,7 @@ workspace = true
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-ui-egui = { workspace = true }
|
||||
deckcraft-media = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -1,12 +1,50 @@
|
||||
//! cpal audio output for media playback: the device pulls mixed samples from the
|
||||
//! `deckcraft_media::Player`, which makes it the playback clock video frames follow.
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use deckcraft_media::AudioOut;
|
||||
|
||||
/// The first stream error since it was last reported. cpal calls the error callback on its
|
||||
/// realtime audio thread (on ALSA in a loop while the device stays broken), which must not log:
|
||||
/// the callback only keeps the first error, and the UI thread logs it when the stream stops.
|
||||
type ErrorSlot = Arc<Mutex<Option<String>>>;
|
||||
|
||||
/// Keep `e` unless an error is already kept. Never blocks: a busy slot drops the error.
|
||||
fn keep_stream_error(slot: &ErrorSlot, e: &dyn std::fmt::Display) {
|
||||
if let Ok(mut kept) = slot.try_lock()
|
||||
&& kept.is_none()
|
||||
{
|
||||
*kept = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// The kept error, once (a poisoned lock is tolerated).
|
||||
fn take_stream_error(slot: &ErrorSlot) -> Option<String> {
|
||||
slot.lock().unwrap_or_else(PoisonError::into_inner).take()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CpalOut {
|
||||
stream: Option<cpal::Stream>,
|
||||
error: ErrorSlot,
|
||||
}
|
||||
|
||||
impl CpalOut {
|
||||
/// Log the stream error the realtime callback kept (UI thread).
|
||||
fn report(&self) {
|
||||
if let Some(e) = take_stream_error(&self.error) {
|
||||
log::warn!("audio stream error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CpalOut {
|
||||
fn drop(&mut self) {
|
||||
self.stream = None;
|
||||
self.report();
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioOut for CpalOut {
|
||||
@@ -17,7 +55,8 @@ impl AudioOut for CpalOut {
|
||||
let channels = cfg.channels() as usize;
|
||||
let rate = cfg.sample_rate().0;
|
||||
let config: cpal::StreamConfig = cfg.clone().into();
|
||||
let err = |e| log::warn!("audio stream error: {e}");
|
||||
let slot = self.error.clone();
|
||||
let err = move |e: cpal::StreamError| keep_stream_error(&slot, &e);
|
||||
let stream = match cfg.sample_format() {
|
||||
cpal::SampleFormat::F32 => dev.build_output_stream(&config, move |buf: &mut [f32], _| fill(buf, channels, rate), err, None),
|
||||
cpal::SampleFormat::I16 => {
|
||||
@@ -45,5 +84,37 @@ impl AudioOut for CpalOut {
|
||||
|
||||
fn stop(&mut self) {
|
||||
self.stream = None;
|
||||
self.report();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_the_first_stream_error_is_kept_and_it_is_taken_once() {
|
||||
let slot = ErrorSlot::default();
|
||||
assert_eq!(take_stream_error(&slot), None);
|
||||
keep_stream_error(&slot, &"device unplugged");
|
||||
keep_stream_error(&slot, &"device unplugged again");
|
||||
assert_eq!(take_stream_error(&slot).as_deref(), Some("device unplugged"));
|
||||
assert_eq!(take_stream_error(&slot), None);
|
||||
keep_stream_error(&slot, &"a later error");
|
||||
assert_eq!(take_stream_error(&slot).as_deref(), Some("a later error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_slot_still_reports() {
|
||||
let slot = ErrorSlot::default();
|
||||
keep_stream_error(&slot, &"lost");
|
||||
let s = slot.clone();
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _g = s.lock();
|
||||
panic!("poison the slot");
|
||||
})
|
||||
.join();
|
||||
assert!(slot.is_poisoned());
|
||||
assert_eq!(take_stream_error(&slot).as_deref(), Some("lost"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("deckcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("deckcraft: control server listening on 127.0.0.1:{port}");
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
|
||||
|
||||
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
|
||||
|
||||
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
|
||||
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// eframe's default includes GL. Creating that backend can crash inside AMD's
|
||||
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
|
||||
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
|
||||
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
|
||||
// workaround without mutating process environment variables.
|
||||
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn configured_backends(backend_override: Option<Backends>) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, backend_override);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
create.instance_descriptor.backends
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_default_initializes_only_dx12() {
|
||||
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
|
||||
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
|
||||
assert_eq!(configured_backends(None), Backends::DX12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_backend_override_is_preserved() {
|
||||
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
|
||||
assert_eq!(configured_backends(Some(backends)), backends);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
//! The desktop app's logger: `log::` records go to standard error and to a log file.
|
||||
//!
|
||||
//! Without a logger every `log::warn!`/`log::error!` in the workspace (the crash guard's panic
|
||||
//! report, a failed crash-recovery save, PowerPoint parts that were skipped on import, audio output
|
||||
//! problems) vanished. A launch from a desktop menu has no terminal, so the file is what a bug report
|
||||
//! can attach: `<settings dir>/logs/deckcraft.log`, beside `ui.json` and `prefs.json`. Each start
|
||||
//! moves the previous log to `deckcraft.1.log` (and that one to `.2`), so the log of a run that
|
||||
//! crashed survives the next launch.
|
||||
//!
|
||||
//! Levels: `info` for DeckCraft's own crates, `warn` for everything else (wgpu and naga are
|
||||
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
|
||||
//! `warn,deckcraft_pptx=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
|
||||
//! that starts with it (`deckcraft*=debug`).
|
||||
//!
|
||||
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
|
||||
//! written once the file is attached. Writing never panics: a file that can't be created or written
|
||||
//! leaves standard error as the only sink.
|
||||
//!
|
||||
//! Realtime audio: the cpal callbacks (`audio.rs`) do not log; a stream error is kept and logged
|
||||
//! from the UI thread when the stream stops. A record takes the sink lock only for one `write_all`
|
||||
//! of an already formatted line (standard error is written outside the lock).
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use log::LevelFilter;
|
||||
|
||||
/// The current log file's name inside the log directory.
|
||||
pub const LOG_FILE: &str = "deckcraft.log";
|
||||
/// The log directory inside the settings directory.
|
||||
pub const LOG_DIR: &str = "logs";
|
||||
/// How many previous logs are kept (`deckcraft.1.log` … `deckcraft.<KEEP>.log`).
|
||||
pub const KEEP: usize = 2;
|
||||
/// The log file stops growing past this size (a runaway warning can't fill the disk).
|
||||
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// Records kept in memory until the log file is attached.
|
||||
pub const MAX_PENDING: usize = 512;
|
||||
/// The built-in filter when `RUST_LOG` is unset or empty.
|
||||
pub const DEFAULT_FILTER: &str = "warn,deckcraft*=info";
|
||||
|
||||
/// Per-target level filter parsed from env_logger-style directives.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
default: LevelFilter,
|
||||
directives: Vec<(String, LevelFilter)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
|
||||
/// directive names log errors only.
|
||||
pub fn parse(spec: &str) -> Filter {
|
||||
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
|
||||
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match part.split_once('=') {
|
||||
Some((name, level)) => {
|
||||
let name = name.trim();
|
||||
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
|
||||
f.directives.push((name.to_owned(), level));
|
||||
}
|
||||
}
|
||||
None => match parse_level(part) {
|
||||
Some(level) => f.default = level,
|
||||
// A bare target name: everything from it (env_logger does the same).
|
||||
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
|
||||
},
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The level that applies to `target` (the most specific matching directive wins).
|
||||
pub fn level_for(&self, target: &str) -> LevelFilter {
|
||||
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
|
||||
}
|
||||
|
||||
/// The most verbose level any target can reach (for `log::set_max_level`).
|
||||
pub fn max(&self) -> LevelFilter {
|
||||
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> Option<LevelFilter> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
|
||||
/// `name*` matches every target starting with `name`.
|
||||
fn matches(name: &str, target: &str) -> bool {
|
||||
match name.strip_suffix('*') {
|
||||
Some(prefix) => target.starts_with(prefix),
|
||||
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("deckcraft.{n}.log")) }
|
||||
}
|
||||
|
||||
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
|
||||
/// and create a fresh, empty log file in `dir` (created if missing).
|
||||
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
for n in (1..=KEEP).rev() {
|
||||
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
/// then appended to it until it reaches its size cap.
|
||||
pub struct Sink {
|
||||
file: Option<File>,
|
||||
pending: Vec<String>,
|
||||
dropped: usize,
|
||||
written: u64,
|
||||
max_bytes: u64,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
pub fn new(max_bytes: u64) -> Sink {
|
||||
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
|
||||
}
|
||||
|
||||
/// Append `line` to the file, or keep it until [`Sink::attach`].
|
||||
pub fn write(&mut self, line: &str) {
|
||||
if self.file.is_none() {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
if self.pending.len() < MAX_PENDING {
|
||||
self.pending.push(line.to_owned());
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.append(line);
|
||||
}
|
||||
|
||||
/// Start writing to `file`, first the lines kept so far.
|
||||
pub fn attach(&mut self, file: File) {
|
||||
self.file = Some(file);
|
||||
for line in std::mem::take(&mut self.pending) {
|
||||
self.append(&line);
|
||||
}
|
||||
if self.dropped > 0 {
|
||||
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
|
||||
self.dropped = 0;
|
||||
self.append(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// No file will come: drop the kept lines and keep none from now on.
|
||||
pub fn close(&mut self) {
|
||||
self.file = None;
|
||||
self.pending = Vec::new();
|
||||
self.capped = true;
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink (and
|
||||
// stop buffering, since no file will come).
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
||||
pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<Sink>,
|
||||
}
|
||||
|
||||
impl AppLogger {
|
||||
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
|
||||
}
|
||||
|
||||
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
|
||||
/// An unusable directory leaves standard error as the only sink ([`AppLogger::stderr_only`]).
|
||||
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
|
||||
match rotate(dir) {
|
||||
Ok((path, file)) => {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
|
||||
Ok(path)
|
||||
}
|
||||
Err(e) => {
|
||||
self.stderr_only();
|
||||
Err(format!("{}: {e}", dir.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No log file this run (no settings directory, or it can't be used): stop keeping records for one.
|
||||
pub fn stderr_only(&self) {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).close();
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for AppLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= self.filter.level_for(metadata.target())
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
// Format before taking the lock, so the lock covers one write only.
|
||||
let thread = std::thread::current();
|
||||
let line =
|
||||
format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
if self.stderr {
|
||||
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||
}
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
||||
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
|
||||
let max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
log::set_max_level(max);
|
||||
Some(logger)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use log::Log;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("deckcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &'static str) {
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_deckcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("deckcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("deckcraft::audio"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("deckcraft_engine::guard"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("deckcraft_pptx::read"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,deckcraft_pptx=trace,deckcraft_pptx::opc=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("deckcraft_pptx::read"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("deckcraft_pptx::opc"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("warn,deckcraft_ui*=debug").level_for("deckcraft_ui_egui::canvas"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("warn,deckcraft_ui*=debug").level_for("deckcraft_engine"), LevelFilter::Warn);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive; the last bare level wins.
|
||||
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "a".repeat(100_000);
|
||||
let specs = [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"deckcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=debug",
|
||||
"a=b=c",
|
||||
"warn,,deckcraft=DEBUG",
|
||||
" \t ",
|
||||
"\u{0}",
|
||||
"é*=trace",
|
||||
&long,
|
||||
];
|
||||
for spec in specs {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["deckcraft", "", "🦀", "é", "a::b"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,deckcraft=DEBUG").level_for("deckcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
|
||||
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
|
||||
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("deckcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("deckcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("deckcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
assert!(rotate(&dir.join("below")).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line(
|
||||
"2026-10-08T07:59:17.728Z",
|
||||
log::Level::Warn,
|
||||
"main",
|
||||
"deckcraft_pptx::opc",
|
||||
"pptx: skipping oversized part ppt/media/image1.png"
|
||||
),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] deckcraft_pptx::opc: pptx: skipping oversized part ppt/media/image1.png\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_max_pending_lines_drop_nothing() {
|
||||
let dir = temp_dir("exact");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
record(&logger, log::Level::Info, "deckcraft", "DeckCraft starting");
|
||||
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(&logger, log::Level::Debug, "deckcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("deckcraft: DeckCraft starting")), "{text}");
|
||||
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("deckcraft_engine::recovery").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// A second launch rotates: the first log becomes `.1`.
|
||||
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
|
||||
assert_eq!(read(&dir.join("deckcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
let dir = temp_dir("threads");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
std::thread::scope(|s| {
|
||||
for _ in 0..4 {
|
||||
s.spawn(|| {
|
||||
for _ in 0..100 {
|
||||
record(&logger, log::Level::Info, "deckcraft", "from a worker");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 400);
|
||||
assert!(text.lines().all(|l| l.ends_with("deckcraft: from a worker")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
|
||||
assert!(e.contains(&dir.display().to_string()), "{e}");
|
||||
record(&logger, log::Level::Error, "x", "still fine");
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
|
||||
drop(sink);
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_lock_does_not_stop_logging() {
|
||||
let dir = temp_dir("poison");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _guard = logger.sink.lock();
|
||||
panic!("poison the sink");
|
||||
}));
|
||||
assert!(logger.sink.is_poisoned());
|
||||
record(&logger, log::Level::Info, "deckcraft", "after the panic");
|
||||
assert!(read(&path).contains("after the panic"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,18 @@
|
||||
//! `--control <port>` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! See `deckcraft_ui_egui::control` for the methods.
|
||||
//!
|
||||
//! `log` records go to standard error and `<settings dir>/logs/deckcraft.log` (`RUST_LOG` sets the
|
||||
//! levels); see [`logging`].
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod audio;
|
||||
mod control_server;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_ui_egui::{Services, SlideApp};
|
||||
@@ -32,8 +38,9 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
let base = if cfg!(target_os = "macos") {
|
||||
/// The per-user settings directory: `ui.json`, `prefs.json` and `logs/` live here.
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DeckCraft"))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("DeckCraft"))
|
||||
@@ -42,8 +49,11 @@ fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("deckcraft"))
|
||||
};
|
||||
base.map(|b| b.join("ui.json"))
|
||||
}
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
config_dir().map(|b| b.join("ui.json"))
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut SlideApp) {
|
||||
@@ -125,6 +135,9 @@ fn app_icon() -> Option<egui::IconData> {
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so every start-up record (and the engine's panic hook, installed with the first
|
||||
// Session) is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
let mut control_port: Option<u16> = std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
@@ -142,6 +155,19 @@ fn main() -> eframe::Result {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
log::info!("DeckCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
|
||||
// The log file lives under the settings directory; opened after the arguments, so `--version`
|
||||
// leaves no file behind. Records logged until now are written to it first.
|
||||
if let Some(logger) = logger {
|
||||
match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
|
||||
Some(Ok(path)) => log::info!("log file {}", path.display()),
|
||||
Some(Err(e)) => log::warn!("no log file: {e}"),
|
||||
None => {
|
||||
logger.stderr_only();
|
||||
log::warn!("no log file: no settings directory (HOME, XDG_CONFIG_HOME or APPDATA is not set)");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("DeckCraft")
|
||||
@@ -164,6 +190,9 @@ fn main() -> eframe::Result {
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
eframe::run_native(
|
||||
"DeckCraft",
|
||||
options,
|
||||
@@ -185,11 +214,11 @@ fn main() -> eframe::Result {
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if sample && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
|
||||
eprintln!("deckcraft: sample: {e}");
|
||||
log::error!("sample: {e}");
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.open_path(&f) {
|
||||
eprintln!("deckcraft: {f}: {e}");
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
if show {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect x="16" y="16" width="480" height="480" rx="111.84" fill="#f26b1d"/><rect x="246" y="330" width="20" height="110" fill="#0b0b0c"/><rect x="176" y="430" width="160" height="18" fill="#0b0b0c"/><rect x="86" y="96" width="340" height="244" rx="14.64" fill="#efe9dc" stroke="#0b0b0c" stroke-width="12"/><rect x="126" y="136" width="170" height="20" fill="#0b0b0c"/><rect x="136" y="236" width="46" height="70" fill="#f26b1d"/><rect x="202" y="196" width="46" height="110" fill="#0b0b0c"/><rect x="268" y="166" width="46" height="140" fill="#f26b1d"/><circle cx="364" cy="178" r="28" fill="#0b0b0c"/></svg>
|
||||
|
After Width: | Height: | Size: 669 B |
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"repo": "storytold/deckcraft",
|
||||
"branch": "main",
|
||||
"head": "4b09f35c1d6fa50e2d94243a5d65d98fe49558e0",
|
||||
"head_date": "2026-10-09T14:48:26Z",
|
||||
"commits": 41,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 28,
|
||||
"lines_added": 124410,
|
||||
"lines_deleted": 3748,
|
||||
"binary_added": 47,
|
||||
"binary_deleted": 12,
|
||||
"first_commit": "2026-10-06T02:13:02Z",
|
||||
"last_commit": "2026-10-09T12:51:28Z"
|
||||
},
|
||||
{
|
||||
"login": "h-a-n-a-s-h-i",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 2,
|
||||
"lines_added": 56,
|
||||
"lines_deleted": 56,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T12:13:34Z",
|
||||
"last_commit": "2026-10-08T14:02:34Z"
|
||||
},
|
||||
{
|
||||
"login": "XusBadia",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 241,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:12:32Z",
|
||||
"last_commit": "2026-10-08T13:12:32Z"
|
||||
},
|
||||
{
|
||||
"login": "Pedroj-64",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 162,
|
||||
"lines_deleted": 42,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:28:31Z",
|
||||
"last_commit": "2026-10-09T14:28:31Z"
|
||||
},
|
||||
{
|
||||
"login": "switch87",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 706,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:28:35Z",
|
||||
"last_commit": "2026-10-09T14:29:47Z"
|
||||
},
|
||||
{
|
||||
"login": "alexanderalber",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 718,
|
||||
"lines_deleted": 53,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:28:40Z",
|
||||
"last_commit": "2026-10-09T14:32:41Z"
|
||||
},
|
||||
{
|
||||
"login": "wurstbrotdlx",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 65,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:28:45Z",
|
||||
"last_commit": "2026-10-09T14:28:45Z"
|
||||
},
|
||||
{
|
||||
"login": "MarekSpodBiedry",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 265,
|
||||
"lines_deleted": 77,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:28:49Z",
|
||||
"last_commit": "2026-10-09T14:40:13Z"
|
||||
},
|
||||
{
|
||||
"login": "DWAA1660",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 67,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:30:12Z",
|
||||
"last_commit": "2026-10-09T14:30:12Z"
|
||||
},
|
||||
{
|
||||
"login": "hamzaelkachtaf4-star",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1902,
|
||||
"lines_deleted": 282,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:48:26Z",
|
||||
"last_commit": "2026-10-09T14:48:26Z"
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"id": "anthropic/claude-opus/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 33,
|
||||
"lines_added": 126890,
|
||||
"lines_deleted": 4162
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-fable/5.1",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Fable",
|
||||
"version": "5.1",
|
||||
"commits": 1,
|
||||
"lines_added": 65,
|
||||
"lines_deleted": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
# display_name = "Nickname" # shown when the viewer picks "Display name"
|
||||
# sync_github_name = true # or: use the name on your public GitHub profile as display name
|
||||
#
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.alexanderalber]
|
||||
real_name = "Alexander Alber"
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
|
||||
[people.DimaSmaha]
|
||||
real_name = "Dmytro Smaha"
|
||||
display_name = "Dima Smaha"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Johannesgauss]
|
||||
sync_github_name = true
|
||||
@@ -3,7 +3,8 @@
|
||||
//! - [`Timeline`]: a slide's animation list grouped into click steps, with per-shape (and
|
||||
//! per-paragraph) [`AnimState`] at any point of the show.
|
||||
//! - [`transition_layers`]: a slide transition frame as textured quads of the old/new slide images.
|
||||
//! - [`morph_pairs`] / [`morph_xfrm`]: Morph transition shape matching and interpolation.
|
||||
//! - [`morph_frame`]: a Morph transition frame (shapes matched by [`morph_pairs`], moved by
|
||||
//! [`morph_xfrm`]).
|
||||
//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings).
|
||||
//!
|
||||
//! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or
|
||||
@@ -26,9 +27,9 @@ pub use deckcraft_model::Rgba;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use easing::ease;
|
||||
pub use morph::{morph_pairs, morph_xfrm};
|
||||
pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm};
|
||||
pub use path::{MotionPath, default_path};
|
||||
pub use show::{ShowAction, ShowState, show_order};
|
||||
pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order};
|
||||
pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay};
|
||||
pub use transition::{Layer, Source, transition_layers};
|
||||
|
||||
|
||||
+161
-2
@@ -1,8 +1,10 @@
|
||||
//! Morph: matching shapes between two slides and interpolating their boxes.
|
||||
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
|
||||
|
||||
use deckcraft_model::{Shape, ShapeId, Slide, Xfrm};
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
|
||||
|
||||
use crate::clampf;
|
||||
use crate::easing::smooth;
|
||||
|
||||
fn text_of(s: &Shape) -> String {
|
||||
s.text.as_ref().map(|t| t.text()).unwrap_or_default()
|
||||
@@ -91,3 +93,160 @@ pub fn morph_xfrm(a: Xfrm, b: Xfrm, t: f64) -> Xfrm {
|
||||
flip_v: if late { b.flip_v } else { a.flip_v },
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame of a Morph transition, ready for the renderer's `render_blend`: the backdrops of the
|
||||
/// two slides cross-fade by [`MorphFrame::mix`], the shapes are drawn in order on top.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MorphFrame {
|
||||
/// Shapes with fresh ids, each flagged `true` when it comes from the old slide (it resolves
|
||||
/// its placeholder, theme and style against that slide).
|
||||
pub shapes: Vec<(Shape, bool)>,
|
||||
/// Opacity of the frame's shapes by id; shapes not listed are opaque.
|
||||
pub opacity: Vec<(ShapeId, f64)>,
|
||||
pub mix: f64,
|
||||
/// With the Words or Characters option: pairs of frame shapes whose texts morph by words or
|
||||
/// characters instead of cross-fading (the renderer's `TextMorph`).
|
||||
pub text: Vec<MorphText>,
|
||||
/// Frame shapes whose outline turns from another geometry into their own (the renderer's
|
||||
/// `PathMorph`).
|
||||
pub paths: Vec<MorphPath>,
|
||||
}
|
||||
|
||||
/// A frame shape whose outline morphs from `from` (the old shape's geometry) into its own.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MorphPath {
|
||||
pub id: ShapeId,
|
||||
pub from: Geom,
|
||||
/// The boxes of the old and the new shape on their own slides.
|
||||
pub from_box: Xfrm,
|
||||
pub to_box: Xfrm,
|
||||
}
|
||||
|
||||
/// Two frame shapes whose texts morph by words or characters.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MorphText {
|
||||
/// Frame shape ids: from the old slide, from the new slide.
|
||||
pub old: ShapeId,
|
||||
pub new: ShapeId,
|
||||
/// The boxes of the two shapes on their own slides.
|
||||
pub from: Xfrm,
|
||||
pub to: Xfrm,
|
||||
pub chars: bool,
|
||||
}
|
||||
|
||||
impl MorphFrame {
|
||||
/// Opacity of shape `id` of the frame.
|
||||
pub fn opacity_of(&self, id: ShapeId) -> f64 {
|
||||
self.opacity.iter().find(|(s, _)| *s == id).map(|(_, o)| *o).unwrap_or(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn mix_color(a: Rgba, b: Rgba, t: f64) -> Rgba {
|
||||
let m = |x: u8, y: u8| clampf(lerp(x as f64, y as f64, t), 0.0, 255.0, 0.0).round() as u8;
|
||||
Rgba { r: m(a.r, b.r), g: m(a.g, b.g), b: m(a.b, b.b), a: m(a.a, b.a) }
|
||||
}
|
||||
|
||||
fn solid_color(ctx: Option<&Ctx>, s: &Shape) -> Option<Rgba> {
|
||||
let ctx = ctx?;
|
||||
match resolve::fill(ctx, s) {
|
||||
(Some(Fill::Solid { color }), ph) => Some(ctx.color(&color, ph)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn box_of(ctx: Option<&Ctx>, s: &Shape) -> Option<Xfrm> {
|
||||
match ctx {
|
||||
Some(c) => Some(resolve::xfrm(c, s)),
|
||||
None => s.xfrm,
|
||||
}
|
||||
}
|
||||
|
||||
/// The content of a shape apart from what Morph interpolates (identity, box and fill).
|
||||
fn content(s: &Shape) -> Shape {
|
||||
Shape { id: ShapeId(0), name: String::new(), xfrm: None, fill: None, ..s.clone() }
|
||||
}
|
||||
|
||||
/// Whether two shapes differ in their geometry only, and it is drawn as an outline (shapes,
|
||||
/// pictures, connectors), so the outline can morph.
|
||||
fn reshaped(a: &Shape, b: &Shape) -> bool {
|
||||
let outlined = |s: &Shape| matches!(s.kind, ShapeKind::Shape | ShapeKind::Picture { .. } | ShapeKind::Connector { .. });
|
||||
a.geom != b.geom && outlined(a) && outlined(b) && Shape { geom: Geom::default(), ..content(a) } == Shape { geom: Geom::default(), ..content(b) }
|
||||
}
|
||||
|
||||
/// Morph frame from slide `a` to slide `b` at progress `t` (0..1, eased here). Paired shapes
|
||||
/// ([`morph_pairs`]) move along [`morph_xfrm`]; when they differ only in solid fill colour the
|
||||
/// colour blends, when only in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]),
|
||||
/// otherwise the new shape fades in under the fading old one on the same moving
|
||||
/// box; with the transition option `words` or `characters` their texts are listed in
|
||||
/// [`MorphFrame::text`] to morph word by word or character by character. Unpaired shapes of `a` fade out, unpaired shapes of `b` fade in, and shapes of `b` for
|
||||
/// which `hidden_b` is true (entrance animations, hidden media) stay out of the frame.
|
||||
pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b: &dyn Fn(ShapeId) -> bool) -> MorphFrame {
|
||||
let s = smooth(clampf(t, 0.0, 1.0, 0.0));
|
||||
let ca = Ctx::for_slide(pres, a);
|
||||
let cb = Ctx::for_slide(pres, b);
|
||||
let pairs: Vec<(ShapeId, ShapeId)> = morph_pairs(a, b).into_iter().filter(|(_, ib)| !hidden_b(*ib)).collect();
|
||||
let mut next = 1u32;
|
||||
walk(&a.shapes, &mut |x, _| next = next.max(x.id.0.saturating_add(1)));
|
||||
walk(&b.shapes, &mut |x, _| next = next.max(x.id.0.saturating_add(1)));
|
||||
let mut f = MorphFrame { mix: s, ..Default::default() };
|
||||
let by = b.transition.as_ref().map(|tr| tr.option.as_str()).unwrap_or("");
|
||||
let (by_text, chars) = (by == "words" || by == "characters", by == "characters");
|
||||
let has_text = |x: &Shape| x.text.as_ref().is_some_and(|t| !t.is_empty()) && !matches!(x.kind, ShapeKind::Group { .. } | ShapeKind::Table(_));
|
||||
let mut text = Vec::new();
|
||||
let mut paths = Vec::new();
|
||||
let mut push = |mut sh: Shape, old: bool, op: f64| {
|
||||
let id = ShapeId(next);
|
||||
sh.id = id;
|
||||
next = next.saturating_add(1);
|
||||
if op < 1.0 {
|
||||
f.opacity.push((id, op));
|
||||
}
|
||||
f.shapes.push((sh, old));
|
||||
id
|
||||
};
|
||||
for sa in &a.shapes {
|
||||
if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
|
||||
push(sa.clone(), true, 1.0 - s);
|
||||
}
|
||||
}
|
||||
for sb in &b.shapes {
|
||||
let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia));
|
||||
let Some(sa) = pair else {
|
||||
if !hidden_b(sb.id) {
|
||||
push(sb.clone(), false, s);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let (xa, xb) = (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb));
|
||||
let x = match (xa, xb) {
|
||||
(Some(xa), Some(xb)) => Some(morph_xfrm(xa, xb, s)),
|
||||
(_, xb) => xb,
|
||||
};
|
||||
let reshape = reshaped(sa, sb);
|
||||
if reshape || content(sa) == content(sb) {
|
||||
let mut m = Shape { xfrm: x, ..sb.clone() };
|
||||
if let (Some(fa), Some(fb)) = (solid_color(ca.as_ref(), sa), solid_color(cb.as_ref(), sb))
|
||||
&& fa != fb
|
||||
{
|
||||
m.fill = Some(Fill::solid(ColorRef::rgb(mix_color(fa, fb, s))));
|
||||
}
|
||||
let id = push(m, false, 1.0);
|
||||
if reshape && let (Some(from_box), Some(to_box)) = (xa, xb) {
|
||||
paths.push(MorphPath { id, from: sa.geom.clone(), from_box, to_box });
|
||||
}
|
||||
} else {
|
||||
let new = push(Shape { xfrm: x, ..sb.clone() }, false, s);
|
||||
let old = push(Shape { xfrm: x, ..sa.clone() }, true, 1.0 - s);
|
||||
if by_text
|
||||
&& has_text(sa)
|
||||
&& has_text(sb)
|
||||
&& let (Some(from), Some(to)) = (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb))
|
||||
{
|
||||
text.push(MorphText { old, new, from, to, chars });
|
||||
}
|
||||
}
|
||||
}
|
||||
f.text = text;
|
||||
f.paths = paths;
|
||||
f
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! and automatic advance.
|
||||
|
||||
use deckcraft_model::Presentation;
|
||||
use deckcraft_model::text::Action;
|
||||
|
||||
use crate::Timeline;
|
||||
|
||||
@@ -25,6 +26,32 @@ pub enum ShowAction {
|
||||
Exit,
|
||||
}
|
||||
|
||||
/// Can a link from a (possibly untrusted) deck be opened outside the show? Only web pages and
|
||||
/// mail: `file:`, `javascript:`, relative paths and other schemes (OS protocol handlers) are not
|
||||
/// opened on a click.
|
||||
pub fn safe_url(url: &str) -> bool {
|
||||
let u = url.trim();
|
||||
let Some((scheme, rest)) = u.split_once(':') else { return false };
|
||||
match scheme.to_ascii_lowercase().as_str() {
|
||||
"http" | "https" => rest.starts_with("//") && rest.len() > 2,
|
||||
"mailto" => !rest.is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a clicked link does in the show (`ShowState::follow`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LinkJump {
|
||||
/// Navigation inside the show (already applied to the state).
|
||||
Show(ShowAction),
|
||||
/// Open this URL (`http`, `https` or `mailto`, see [`safe_url`]) outside the show.
|
||||
Open(String),
|
||||
/// End the show.
|
||||
Exit,
|
||||
/// Nothing to follow.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Where a slide show is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub struct ShowState {
|
||||
@@ -211,6 +238,27 @@ impl ShowState {
|
||||
ShowAction::Slide(i)
|
||||
}
|
||||
|
||||
/// Follow a clicked hyperlink or shape action. `last_viewed` is the slide shown before the
|
||||
/// current one (Last Slide Viewed). Moves to slide targets; everything else is for the UI.
|
||||
pub fn follow(&mut self, pres: &Presentation, action: &Action, last_viewed: Option<usize>) -> LinkJump {
|
||||
let target = match action {
|
||||
Action::Url { url } => return if safe_url(url) { LinkJump::Open(url.trim().to_string()) } else { LinkJump::None },
|
||||
Action::EndShow => return LinkJump::Exit,
|
||||
Action::Slide { slide } => pres.slides.iter().position(|s| s.id == *slide),
|
||||
Action::NextSlide => self.next_index(pres),
|
||||
Action::PreviousSlide => self.prev_index(pres),
|
||||
Action::FirstSlide => show_order(pres).first().copied(),
|
||||
Action::LastSlide => show_order(pres).last().copied(),
|
||||
Action::LastViewed => last_viewed,
|
||||
// Custom shows, programs and media actions aren't followed (yet).
|
||||
Action::CustomShow { .. } | Action::Program { .. } | Action::PlayMedia => None,
|
||||
};
|
||||
match target {
|
||||
Some(i) => LinkJump::Show(self.goto(pres, i)),
|
||||
None => LinkJump::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds after which the current slide advances by itself (Advance Slide ▸ After), when the
|
||||
/// show uses timings.
|
||||
pub fn advance_after(&self, pres: &Presentation) -> Option<f64> {
|
||||
|
||||
@@ -543,6 +543,117 @@ fn morph_xfrm_interpolates() {
|
||||
assert!(m.rot.is_finite());
|
||||
}
|
||||
|
||||
fn solid(s: Shape, r: u8) -> Shape {
|
||||
Shape { fill: Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r, g: 0, b: 0, a: 255 }))), ..s }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_frame_moves_blends_and_fades() {
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
let moved = |id, x| Shape { xfrm: Some(Xfrm::new(x, 100.0, 200.0, 100.0)), ..shape(id, "Box") };
|
||||
a.shapes = vec![solid(moved(1, 0.0), 0), shape(2, "Gone")];
|
||||
let new = Shape { text: Some(TextBody::from_text("Hi")), ..shape(3, "New") };
|
||||
b.shapes = vec![solid(moved(1, 400.0), 200), new, shape(4, "Later")];
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|id| id == ShapeId(4));
|
||||
assert!((f.mix - 0.5).abs() < 1e-9);
|
||||
// Gone fades out, Box moves halfway and blends red, New fades in, Later stays out.
|
||||
let names: Vec<(&str, bool)> = f.shapes.iter().map(|(s, old)| (s.name.as_str(), *old)).collect();
|
||||
assert_eq!(names, [("Gone", true), ("Box", false), ("New", false)]);
|
||||
let m = &f.shapes[1].0;
|
||||
assert!((m.xfrm.unwrap().x - 200.0).abs() < 1e-9);
|
||||
assert_eq!(m.fill, Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r: 100, g: 0, b: 0, a: 255 }))));
|
||||
assert_eq!(f.opacity_of(m.id), 1.0);
|
||||
assert!((f.opacity_of(f.shapes[0].0.id) - 0.5).abs() < 1e-9);
|
||||
assert!((f.opacity_of(f.shapes[2].0.id) - 0.5).abs() < 1e-9);
|
||||
// Ids are fresh and unique.
|
||||
let mut ids: Vec<_> = f.shapes.iter().map(|(s, _)| s.id).collect();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 3);
|
||||
assert!(ids.iter().all(|i| i.0 > 4));
|
||||
// At the start only the old slide shows.
|
||||
let f0 = morph_frame(&p, &a, &b, 0.0, &|id| id == ShapeId(4));
|
||||
assert!(f0.shapes.iter().filter(|(s, _)| f0.opacity_of(s.id) > 0.0).all(|(s, _)| s.name != "New"));
|
||||
assert_eq!(morph_frame(&p, &a, &b, f64::NAN, &|_| false).mix, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_frame_cross_fades_changed_content() {
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
a.shapes = vec![Shape { text: Some(TextBody::from_text("Before")), ..shape(1, "T") }];
|
||||
b.shapes = vec![Shape { text: Some(TextBody::from_text("After")), ..shape(1, "T") }];
|
||||
let f = morph_frame(&p, &a, &b, 0.25, &|_| false);
|
||||
// The new text under the fading old one, both on the moving box.
|
||||
assert_eq!(f.shapes.len(), 2);
|
||||
assert_eq!((&f.shapes[0].0.text, f.shapes[0].1), (&b.shapes[0].text, false));
|
||||
assert_eq!((&f.shapes[1].0.text, f.shapes[1].1), (&a.shapes[0].text, true));
|
||||
let s = f.mix;
|
||||
assert!((f.opacity_of(f.shapes[0].0.id) - s).abs() < 1e-9);
|
||||
assert!((f.opacity_of(f.shapes[1].0.id) - (1.0 - s)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_frame_lists_texts_for_words_and_characters() {
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
a.shapes = vec![Shape { text: Some(TextBody::from_text("The Science")), ..shape(1, "T") }];
|
||||
b.shapes = vec![Shape { text: Some(TextBody::from_text("Science rocks")), ..shape(1, "T") }];
|
||||
for (option, want) in [("objects", None), ("words", Some(false)), ("characters", Some(true))] {
|
||||
b.transition = Some(Transition { kind: "morph".into(), option: option.into(), ..Default::default() });
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
assert_eq!(f.text.first().map(|m| m.chars), want, "{option}");
|
||||
if let Some(m) = f.text.first() {
|
||||
assert_eq!((m.new, m.old), (f.shapes[0].0.id, f.shapes[1].0.id));
|
||||
assert_eq!(m.from, a.shapes[0].xfrm.unwrap());
|
||||
}
|
||||
}
|
||||
// Same text: one shape moves, nothing to morph.
|
||||
b.shapes[0].text = a.shapes[0].text.clone();
|
||||
assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).text.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_frame_morphs_the_outline_of_a_changed_geometry() {
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
let geo = |s: Shape, g: &str| Shape { geom: deckcraft_model::Geom::preset(g), ..s };
|
||||
a.shapes = vec![solid(geo(shape(1, "Form"), "ellipse"), 0)];
|
||||
b.shapes = vec![solid(Shape { xfrm: Some(Xfrm::new(300.0, 100.0, 100.0, 100.0)), ..geo(shape(1, "Form"), "star5") }, 200)];
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
// One shape: the new one, on the moving box, its colour blending and its outline listed to morph.
|
||||
assert_eq!(f.shapes.len(), 1);
|
||||
let (m, old) = &f.shapes[0];
|
||||
assert!(!old && m.geom == b.shapes[0].geom);
|
||||
assert_eq!(f.opacity_of(m.id), 1.0);
|
||||
assert_eq!(m.fill, Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r: 100, g: 0, b: 0, a: 255 }))));
|
||||
assert_eq!(f.paths.len(), 1);
|
||||
assert_eq!(f.paths[0].id, m.id);
|
||||
assert_eq!(f.paths[0].from, a.shapes[0].geom);
|
||||
assert_eq!((f.paths[0].from_box, f.paths[0].to_box), (a.shapes[0].xfrm.unwrap(), b.shapes[0].xfrm.unwrap()));
|
||||
// Other adjust values of the same preset morph too.
|
||||
b.shapes[0].geom = deckcraft_model::Geom::Preset { name: "ellipse".into(), adj: vec![1.0] };
|
||||
assert_eq!(morph_frame(&p, &a, &b, 0.5, &|_| false).paths.len(), 1);
|
||||
// The same geometry only moves.
|
||||
b.shapes[0].geom = a.shapes[0].geom.clone();
|
||||
assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).paths.is_empty());
|
||||
// Another text as well: the two cross-fade as before.
|
||||
b.shapes[0].geom = deckcraft_model::Geom::preset("star5");
|
||||
b.shapes[0].text = Some(TextBody::from_text("Star"));
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
assert!(f.paths.is_empty() && f.shapes.len() == 2);
|
||||
// Tables and groups have no outline to morph.
|
||||
let table = |g: &str| Shape { kind: deckcraft_model::ShapeKind::Table(Default::default()), ..geo(shape(1, "Form"), g) };
|
||||
a.shapes = vec![table("rect")];
|
||||
b.shapes = vec![table("ellipse")];
|
||||
assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).paths.is_empty());
|
||||
}
|
||||
|
||||
fn pres(n: usize) -> Presentation {
|
||||
Presentation { slides: (0..n).map(|i| Arc::new(Slide { id: SlideId(1000 + i as u32), ..Default::default() })).collect(), ..Default::default() }
|
||||
}
|
||||
@@ -650,3 +761,31 @@ fn compose_and_sanitize() {
|
||||
let bad = AnimState { opacity: f64::NAN, rotate: f64::INFINITY, clip: Some([f64::NAN, 2.0, -1.0, 0.5]), ..Default::default() }.sanitized();
|
||||
assert!(finite(&bad));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_follows_links() {
|
||||
use deckcraft_model::text::Action;
|
||||
let mut p = pres(4);
|
||||
Arc::make_mut(&mut p.slides[3]).hidden = true;
|
||||
let mut s = ShowState::new(&p);
|
||||
assert_eq!(s.follow(&p, &Action::Slide { slide: SlideId(1002) }, None), LinkJump::Show(ShowAction::Slide(2)));
|
||||
assert_eq!(s.follow(&p, &Action::PreviousSlide, None), LinkJump::Show(ShowAction::Slide(1)));
|
||||
assert_eq!(s.follow(&p, &Action::NextSlide, None), LinkJump::Show(ShowAction::Slide(2)));
|
||||
// Last skips the hidden slide; a link to the hidden slide shows it.
|
||||
assert_eq!(s.follow(&p, &Action::LastSlide, None), LinkJump::Show(ShowAction::Slide(2)));
|
||||
assert_eq!(s.follow(&p, &Action::Slide { slide: SlideId(1003) }, None), LinkJump::Show(ShowAction::Slide(3)));
|
||||
assert_eq!(s.follow(&p, &Action::FirstSlide, None), LinkJump::Show(ShowAction::Slide(0)));
|
||||
assert_eq!(s.follow(&p, &Action::LastViewed, Some(3)), LinkJump::Show(ShowAction::Slide(3)));
|
||||
assert_eq!(s.slide, 3);
|
||||
assert_eq!(s.follow(&p, &Action::Url { url: "https://example.org".into() }, None), LinkJump::Open("https://example.org".into()));
|
||||
for bad in
|
||||
["file:///etc/passwd", "javascript:alert(1)", "ms-settings:", "smb://host/share", "C:\\Windows\\calc.exe", "../notes.txt", "https:", ""]
|
||||
{
|
||||
assert_eq!(s.follow(&p, &Action::Url { url: bad.into() }, None), LinkJump::None, "{bad} must not open");
|
||||
}
|
||||
assert_eq!(s.follow(&p, &Action::Url { url: " MAILTO:a@example.org".into() }, None), LinkJump::Open("MAILTO:a@example.org".into()));
|
||||
assert_eq!(s.follow(&p, &Action::EndShow, None), LinkJump::Exit);
|
||||
assert_eq!(s.follow(&p, &Action::Slide { slide: SlideId(9) }, None), LinkJump::None, "a link to a deleted slide does nothing");
|
||||
assert_eq!(s.follow(&p, &Action::LastViewed, None), LinkJump::None);
|
||||
assert_eq!(s.slide, 3);
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ fn scale_rect(r: R, s: f64) -> R {
|
||||
/// Layers of the transition `kind` (a [`deckcraft_model::anim::TRANSITIONS`] id) with `option` at
|
||||
/// progress `t` (0..1). Draw in order over black. At `t >= 1` the result is the new slide alone;
|
||||
/// at `t <= 0` (or NaN) the old slide alone. Morph returns a plain crossfade (the UI renders morph
|
||||
/// frames with [`crate::morph_pairs`]).
|
||||
/// frames with [`crate::morph_frame`]).
|
||||
pub fn transition_layers(kind: &str, option: &str, t: f64) -> Vec<Layer> {
|
||||
let t = clampf(t, 0.0, 1.0, 0.0);
|
||||
let v = if t >= 1.0 {
|
||||
|
||||
@@ -134,6 +134,12 @@ impl<'a> BitReader<'a> {
|
||||
u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// `ue(v) + add` (the `..._minus1` / `..._minus8` fields), failing instead of overflowing on a
|
||||
/// hostile value near `u32::MAX`.
|
||||
pub fn read_ue_plus(&mut self, add: u32) -> Result<u32> {
|
||||
self.read_ue()?.checked_add(add).ok_or(BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// Signed Exp-Golomb `se(v)`.
|
||||
pub fn read_se(&mut self) -> Result<i32> {
|
||||
let k = self.read_ue()? as i64;
|
||||
@@ -343,6 +349,18 @@ mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn read_ue_plus_rejects_overflow() {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_ue(7);
|
||||
w.write_ue(u32::MAX);
|
||||
w.rbsp_trailing();
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
assert_eq!(r.read_ue_plus(1).unwrap(), 8);
|
||||
assert!(r.read_ue_plus(1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_bits() {
|
||||
let d = [0b1010_1100, 0b0101_0011];
|
||||
|
||||
@@ -49,6 +49,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
options
|
||||
),
|
||||
cmd!("animation.clear", "Remove All Animations", [], None, "{index?: slide}", has_slide, clear),
|
||||
cmd!(
|
||||
noundo "animation.painter",
|
||||
"Animation Painter",
|
||||
["Animations", "Advanced Animation"],
|
||||
Some("Alt+Shift+C"),
|
||||
"{sticky?: bool, id?}",
|
||||
has_selection,
|
||||
painter_pick
|
||||
),
|
||||
cmd!("animation.painterApply", "Apply Animation", [], Some("Alt+Shift+V"), "{ids?}", has_selection, painter_apply),
|
||||
cmd!(query "animation.list", "Animation Gallery", [], None, "{} → [{id, label, class, options}]", always, list),
|
||||
cmd!(query "animation.get", "Animations on Slide", [], None, "{slide?} → [animation]", has_slide, get),
|
||||
]
|
||||
@@ -292,3 +302,152 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sl = st.doc.slides.get(i).ok_or_else(|| bad("animation.get", "no such slide"))?;
|
||||
Ok(serde_json::to_value(&sl.animations).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn painter_pick(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id = id_param(p, "id")
|
||||
.or_else(|| st.selection.shapes.first().copied())
|
||||
.ok_or_else(|| bad("animation.painter", "select an animated shape first"))?;
|
||||
let sl = st.current_slide().ok_or_else(|| bad("animation.painter", "no slide"))?;
|
||||
let anims: Vec<Animation> = sl.animations.iter().filter(|a| a.shape == id).cloned().collect();
|
||||
if anims.is_empty() {
|
||||
return Err(bad("animation.painter", format!("shape {id} has no animations to copy")));
|
||||
}
|
||||
let sticky = bool_or(p, "sticky", false);
|
||||
let count = anims.len();
|
||||
s.anim_painter = Some((anims, sticky));
|
||||
Ok(json!({"armed": true, "count": count}))
|
||||
}
|
||||
|
||||
fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some((painted, sticky)) = s.anim_painter.clone() else {
|
||||
return Err(bad("animation.painterApply", "pick animations with the Animation Painter first"));
|
||||
};
|
||||
if !sticky {
|
||||
s.anim_painter = None;
|
||||
}
|
||||
let ids = targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad("animation.painterApply", "no target shapes selected"));
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.painterApply", "no slide"))?);
|
||||
// As in PowerPoint, painting replaces the target's own animations (painting a shape onto
|
||||
// itself doesn't double them). A paragraph effect keeps its paragraph only if the target
|
||||
// has that paragraph; otherwise it animates the whole shape.
|
||||
sl.animations.retain(|a| !ids.contains(&a.shape));
|
||||
let mut added = 0;
|
||||
for id in &ids {
|
||||
let paras = deckcraft_model::find_shape(&sl.shapes, *id).and_then(|sh| sh.text.as_ref()).map_or(0, |t| t.paragraphs.len());
|
||||
for a in &painted {
|
||||
let mut cloned = a.clone();
|
||||
cloned.shape = *id;
|
||||
if cloned.paragraph.is_some_and(|i| i as usize >= paras) {
|
||||
cloned.paragraph = None;
|
||||
}
|
||||
sl.animations.push(cloned);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"applied": added}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn animation_painter_copies_effects_between_shapes() {
|
||||
let mut s = Session::with_new();
|
||||
// Insert two shapes
|
||||
let s1 = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id1 = s1.get("id").unwrap().as_u64().unwrap();
|
||||
let s2 = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [150.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id2 = s2.get("id").unwrap().as_u64().unwrap();
|
||||
|
||||
// Add 2 animations to shape 1: fade (entrance) and spin (emphasis)
|
||||
s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id1]})).unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "spin", "class": "emphasis", "ids": [id1]})).unwrap();
|
||||
|
||||
// Shape 2 initially has no animations
|
||||
let anims_before = s.execute("animation.get", &json!({})).unwrap();
|
||||
let arr_before = anims_before.as_array().unwrap();
|
||||
assert_eq!(arr_before.len(), 2);
|
||||
assert!(arr_before.iter().all(|a| a.get("shape").unwrap().as_u64().unwrap() == id1));
|
||||
|
||||
// Pick animations from shape 1
|
||||
s.select(|_, sel| {
|
||||
sel.shapes = vec![deckcraft_model::ShapeId(id1 as u32)];
|
||||
})
|
||||
.unwrap();
|
||||
let res = s.execute("animation.painter", &json!({})).unwrap();
|
||||
assert_eq!(res.get("armed").unwrap().as_bool(), Some(true));
|
||||
assert_eq!(res.get("count").unwrap().as_u64(), Some(2));
|
||||
assert!(s.anim_painter.is_some());
|
||||
|
||||
// Apply animations to shape 2
|
||||
s.select(|_, sel| {
|
||||
sel.shapes = vec![deckcraft_model::ShapeId(id2 as u32)];
|
||||
})
|
||||
.unwrap();
|
||||
let applied = s.execute("animation.painterApply", &json!({})).unwrap();
|
||||
assert_eq!(applied.get("applied").unwrap().as_u64(), Some(2));
|
||||
|
||||
// Single-click should disarm the painter
|
||||
assert!(s.anim_painter.is_none());
|
||||
|
||||
// Painting again replaces shape 2's animations instead of adding to them; undo restores.
|
||||
s.execute("animation.painter", &json!({"id": id1})).unwrap();
|
||||
s.execute("animation.painterApply", &json!({"ids": [id2]})).unwrap();
|
||||
let count_on = |s: &mut Session, id: u64| {
|
||||
let all = s.execute("animation.get", &json!({})).unwrap();
|
||||
all.as_array().unwrap().iter().filter(|a| a.get("shape").unwrap().as_u64().unwrap() == id).count()
|
||||
};
|
||||
assert_eq!(count_on(&mut s, id2), 2);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(count_on(&mut s, id2), 2, "undo restores the first painting");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(count_on(&mut s, id2), 0, "undo removes the painted animations");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
|
||||
// Verify shape 2 now has the 2 animations cloned
|
||||
let anims_after = s.execute("animation.get", &json!({})).unwrap();
|
||||
let arr_after = anims_after.as_array().unwrap();
|
||||
assert_eq!(arr_after.len(), 4);
|
||||
let s2_anims: Vec<_> = arr_after.iter().filter(|a| a.get("shape").unwrap().as_u64().unwrap() == id2).collect();
|
||||
assert_eq!(s2_anims.len(), 2);
|
||||
assert_eq!(s2_anims[0].get("effect").unwrap().as_str(), Some("fade"));
|
||||
assert_eq!(s2_anims[1].get("effect").unwrap().as_str(), Some("spin"));
|
||||
|
||||
// Picking from shape with no animations returns an error
|
||||
s.execute("animation.clear", &json!({})).unwrap();
|
||||
let err = s.execute("animation.painter", &json!({"id": id1}));
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_painter_sticky_mode() {
|
||||
let mut s = Session::with_new();
|
||||
let s1 = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id1 = s1.get("id").unwrap().as_u64().unwrap();
|
||||
let s2 = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [150.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id2 = s2.get("id").unwrap().as_u64().unwrap();
|
||||
let s3 = s.execute("shape.insert", &json!({"preset": "roundRect", "rect": [300.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id3 = s3.get("id").unwrap().as_u64().unwrap();
|
||||
|
||||
s.execute("animation.add", &json!({"effect": "zoom", "class": "entrance", "ids": [id1]})).unwrap();
|
||||
|
||||
// Pick with sticky = true
|
||||
s.execute("animation.painter", &json!({"id": id1, "sticky": true})).unwrap();
|
||||
assert!(s.anim_painter.is_some());
|
||||
|
||||
// Apply to shape 2 - painter remains armed
|
||||
s.execute("animation.painterApply", &json!({"ids": [id2]})).unwrap();
|
||||
assert!(s.anim_painter.is_some());
|
||||
|
||||
// Apply to shape 3 - painter still armed
|
||||
s.execute("animation.painterApply", &json!({"ids": [id3]})).unwrap();
|
||||
assert!(s.anim_painter.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +307,7 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
hf.hide_on_title = v;
|
||||
}
|
||||
let hf = hf.clone();
|
||||
let date_given = str_param(p, "dateText").is_some();
|
||||
let all = bool_or(p, "all", true);
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] };
|
||||
for i in list {
|
||||
@@ -372,6 +373,11 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sh.text = Some(TextBody::from_text(&hf.footer_text));
|
||||
}
|
||||
}
|
||||
(Some(pos), true) if k == PhType::Date && date_given => {
|
||||
if let Some(sh) = sl.shapes.get_mut(pos) {
|
||||
set_date(sh, &hf.date_text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -381,19 +387,32 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Today's date as M/D/YYYY (no clock crate: computed from the system time).
|
||||
/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty
|
||||
/// `fixed`, at an automatic date field (one it already has keeps its format).
|
||||
fn set_date(sh: &mut Shape, fixed: &str) {
|
||||
let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs);
|
||||
let has_field = runs().any(|r| matches!(&r.kind, RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field)));
|
||||
let props = runs().next().map(|r| r.props.clone()).unwrap_or_default();
|
||||
let (text, kind) = match fixed {
|
||||
"" if has_field => return,
|
||||
"" => (today(), RunKind::Field { field: "datetime1".into() }),
|
||||
_ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return,
|
||||
_ => (fixed.to_string(), RunKind::Text),
|
||||
};
|
||||
let para = deckcraft_model::Paragraph { runs: vec![Run { text, props, kind }], ..Default::default() };
|
||||
sh.text = Some(TextBody { paragraphs: vec![para], ..sh.text.clone().unwrap_or_default() });
|
||||
}
|
||||
|
||||
/// Today's local date as a `datetime1` field shows it (M/D/YYYY): the saved text of a new date
|
||||
/// field. Empty where there is no clock (wasm).
|
||||
pub fn today() -> String {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let days = (secs / 86_400) as i64;
|
||||
let (y, m, d) = civil(days);
|
||||
format!("{m}/{d}/{y}")
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
date_field_text("datetime1")
|
||||
}
|
||||
|
||||
/// What date field `kind` shows now (empty if unknown or there is no clock).
|
||||
pub fn date_field_text(kind: &str) -> String {
|
||||
use deckcraft_text::datetime::{DateTime, field_text};
|
||||
DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm).
|
||||
@@ -464,6 +483,7 @@ fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
background: None,
|
||||
show_master_shapes: true,
|
||||
preserve: true,
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -336,6 +336,8 @@ fn select_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn deselect(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.painter = None;
|
||||
s.anim_painter = None;
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.take() {
|
||||
// Esc while editing text selects the shape.
|
||||
|
||||
@@ -71,7 +71,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_text_or_shapes,
|
||||
text_outline
|
||||
),
|
||||
cmd!("format.align", "Align", ["Home", "Paragraph"], None, "{align: left|center|right|justify|distributed}", has_text_or_shapes, align),
|
||||
cmd!(
|
||||
"format.align",
|
||||
"Align",
|
||||
["Home", "Paragraph"],
|
||||
None,
|
||||
"{align: left|center|right|justify|justifyLow|distributed}",
|
||||
has_text_or_shapes,
|
||||
align
|
||||
),
|
||||
cmd!(
|
||||
"format.rtl",
|
||||
"Right-to-Left Text Direction",
|
||||
["Home", "Paragraph"],
|
||||
None,
|
||||
"{on?: bool} (off = left-to-right; switching mirrors left/right alignment)",
|
||||
has_text_or_shapes,
|
||||
rtl
|
||||
),
|
||||
cmd!("format.alignLeft", "Align Left", ["Home", "Paragraph"], Some("Cmd+L"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Left)),
|
||||
cmd!("format.alignCenter", "Center", ["Home", "Paragraph"], Some("Cmd+E"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Center)),
|
||||
cmd!("format.alignRight", "Align Right", ["Home", "Paragraph"], Some("Cmd+R"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Right)),
|
||||
@@ -367,12 +384,29 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"center" | "centre" | "ctr" => Align::Center,
|
||||
"right" | "r" => Align::Right,
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justifyLow" | "justLow" => Align::JustLow,
|
||||
"distributed" | "dist" => Align::Distributed,
|
||||
_ => Align::Left,
|
||||
};
|
||||
align_to(s, a)
|
||||
}
|
||||
|
||||
/// Paragraph reading direction. Switching it mirrors left and right alignment, so text that sat
|
||||
/// at the start edge stays there.
|
||||
fn rtl(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = on_param(p, current_para(s).rtl);
|
||||
format_para(s, &move |para| {
|
||||
if para.props.rtl.unwrap_or(false) != on {
|
||||
para.props.align = match para.props.align {
|
||||
None | Some(Align::Left) => Some(Align::Right),
|
||||
Some(Align::Right) => Some(Align::Left),
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
para.props.rtl = Some(on);
|
||||
})
|
||||
}
|
||||
|
||||
fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = matches!(current_para(s).bullet, Some(Bullet::Char { .. }));
|
||||
let on = on_param(p, Some(cur));
|
||||
|
||||
@@ -49,7 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("insert.slideNumber", "Slide Number", ["Insert", "Text"], None, "{}", has_slide, slide_number),
|
||||
cmd!("insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..}", has_slide, date_time),
|
||||
cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol),
|
||||
cmd!("insert.hyperlink", "Link", ["Insert", "Links"], Some("Cmd+K"), "{url? | slide?: index, tooltip?, ids?}", has_text_or_shapes, hyperlink),
|
||||
cmd!(
|
||||
"insert.hyperlink",
|
||||
"Link",
|
||||
["Insert", "Links"],
|
||||
Some("Cmd+K"),
|
||||
"{url? | slide?: index, tooltip?, text?: shown when nothing is selected, ids?}",
|
||||
has_text_or_shapes,
|
||||
hyperlink
|
||||
),
|
||||
cmd!("chart.type", "Change Chart Type", ["Chart Design", "Type"], None, "{type, id?}", has_selection, chart_type_cmd),
|
||||
cmd!(
|
||||
"chart.data",
|
||||
@@ -556,7 +564,8 @@ fn slide_number(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
fn date_time(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = str_param(p, "format").unwrap_or("datetime1").to_string();
|
||||
field_run(s, &f, "")
|
||||
// Saved with the date it shows now (the text kept where the date can't be computed).
|
||||
field_run(s, &f, &super::design::date_field_text(&f))
|
||||
}
|
||||
|
||||
fn symbol(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -569,18 +578,20 @@ fn symbol(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn hyperlink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
let action = if let Some(u) = str_param(p, "url") {
|
||||
Action::Url { url: u.to_string() }
|
||||
// `display`: what a caret with nothing to link inserts, the address or the slide's title.
|
||||
let (action, display) = if let Some(u) = str_param(p, "url") {
|
||||
(Action::Url { url: u.to_string() }, u.strip_prefix("mailto:").unwrap_or(u).to_string())
|
||||
} else if let Some(i) = usize_param(p, "slide") {
|
||||
let id = s.doc()?.doc.slides.get(i).map(|x| x.id).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?;
|
||||
Action::Slide { slide: id }
|
||||
let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?;
|
||||
let title = slide.title();
|
||||
(Action::Slide { slide: slide.id }, if title.is_empty() { format!("Slide {}", i + 1) } else { title })
|
||||
} else {
|
||||
return Err(bad("insert.hyperlink", "missing `url` or `slide`"));
|
||||
};
|
||||
let display = str_param(p, "text").map_or(display, str::to_string);
|
||||
let link = Hyperlink { action, tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), highlight_click: false };
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
let l = link.clone();
|
||||
return super::text::format_run(s, &move |rp| rp.link = Some(l.clone()));
|
||||
return super::text::link_text(s, link, &display);
|
||||
}
|
||||
edit_shapes(s, p, "insert.hyperlink", |sh| {
|
||||
sh.click = Some(link.clone());
|
||||
|
||||
@@ -33,6 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
poster_frame
|
||||
),
|
||||
cmd!(noundo "media.gifPlay", "Play/Pause GIF", [], None, "{id?, play?: bool (default: toggle)} → {id, playing}", has_doc, gif_play),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -148,6 +149,34 @@ fn poster_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id, "ms": ms}))
|
||||
}
|
||||
|
||||
/// Pause or play an animated GIF picture (`id` / `ids` / the selection, else the only one on the
|
||||
/// slide). The UI host keeps its clock: a paused GIF holds its frame and resumes from it.
|
||||
fn gif_play(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "media.gifPlay";
|
||||
let st = s.doc()?;
|
||||
let is_gif = |id: ShapeId| {
|
||||
let Some(ShapeKind::Picture { fill }) = st.shape(id).map(|sh| &sh.kind) else { return false };
|
||||
st.doc.media(fill.media).and_then(|m| crate::render::gif_frame(&m.data, &fill.adjust, 0.0)).is_some()
|
||||
};
|
||||
let ids = targets(s, p)?;
|
||||
let id = match ids.iter().copied().find(|id| is_gif(*id)) {
|
||||
Some(id) => id,
|
||||
None if !ids.is_empty() && (p.get("id").is_some() || p.get("ids").is_some()) => return Err(bad(cmd, "not an animated GIF")),
|
||||
None => match st.shapes().iter().map(|sh| sh.id).filter(|id| is_gif(*id)).collect::<Vec<_>>().as_slice() {
|
||||
[id] => *id,
|
||||
[] => return Err(bad(cmd, "no animated GIF on this slide")),
|
||||
_ => return Err(bad(cmd, "select an animated GIF, or pass `id`")),
|
||||
},
|
||||
};
|
||||
let play = bool_param(p, "play").unwrap_or_else(|| s.gif_paused.contains(&id));
|
||||
if play {
|
||||
s.gif_paused.remove(&id);
|
||||
} else {
|
||||
s.gif_paused.insert(id);
|
||||
}
|
||||
Ok(json!({"id": id, "playing": play}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -208,6 +237,27 @@ mod tests {
|
||||
assert!(s.execute("media.play", &json!({"id": sh["id"]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gif_play_pauses_and_resumes_an_animated_gif() {
|
||||
use image::{Delay, Frame, Rgba, RgbaImage};
|
||||
let frame = |c: [u8; 4]| Frame::from_parts(RgbaImage::from_pixel(8, 8, Rgba(c)), 0, 0, Delay::from_numer_denom_ms(100, 1));
|
||||
let mut gif = Vec::new();
|
||||
image::codecs::gif::GifEncoder::new(&mut gif).encode_frames([frame([255, 0, 0, 255]), frame([0, 0, 255, 255])]).expect("gif");
|
||||
let mut s = Session::with_new();
|
||||
let _ = s.execute("slide.new", &json!({"layout": "blank"}));
|
||||
assert!(s.execute("media.gifPlay", &json!({})).is_err(), "no GIF yet");
|
||||
let r = s.execute("insert.picture", &json!({"name": "a.gif", "data": base64_encode(&gif)})).expect("insert");
|
||||
let id = ShapeId(r["id"].as_u64().expect("id") as u32);
|
||||
let r = s.execute("media.gifPlay", &json!({})).expect("toggle");
|
||||
assert_eq!(r["playing"], false);
|
||||
assert!(s.gif_paused.contains(&id));
|
||||
let r = s.execute("media.gifPlay", &json!({"id": id, "play": true})).expect("play");
|
||||
assert_eq!(r["playing"], true);
|
||||
assert!(s.gif_paused.is_empty());
|
||||
let sh = s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 10, 10]})).expect("shape");
|
||||
assert!(s.execute("media.gifPlay", &json!({"id": sh["id"]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wma_inserts_but_reports_unplayable() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -246,12 +246,25 @@ pub(crate) fn shape_of(s: &Session, id: ShapeId) -> Result<deckcraft_model::Shap
|
||||
s.doc()?.shape(id).cloned().ok_or_else(|| EngineError::Other(format!("no shape {id}")))
|
||||
}
|
||||
|
||||
/// Effective box of a shape (placeholders inherit theirs).
|
||||
/// Effective box of a shape (placeholders inherit theirs). A table's box is at least as tall as its
|
||||
/// rows are drawn (rows grow to fit their text), so handles and hit tests match what is shown.
|
||||
pub fn xfrm_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape: &deckcraft_model::Shape) -> Xfrm {
|
||||
if let Some(x) = shape.xfrm {
|
||||
return x;
|
||||
let mut x = shape.xfrm.unwrap_or_else(|| ctx_xfrm(doc, sel, shape));
|
||||
if let deckcraft_model::ShapeKind::Table(t) = &shape.kind {
|
||||
let drawn: f64 = table_drawn_heights(doc, sel, t).iter().sum();
|
||||
if drawn.is_finite() && drawn > x.h {
|
||||
x.h = drawn;
|
||||
}
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// Row heights of table `t` as drawn: a stored height is a minimum, rows grow to fit their text.
|
||||
pub fn table_drawn_heights(doc: &deckcraft_model::Presentation, sel: &crate::Selection, t: &deckcraft_model::Table) -> Vec<f64> {
|
||||
match text::ctx_for(doc, sel) {
|
||||
Some(ctx) => deckcraft_render::table_row_heights(&ctx, t, &deckcraft_text::NoFields),
|
||||
None => t.rows.iter().map(|r| r.height).collect(),
|
||||
}
|
||||
ctx_xfrm(doc, sel, shape)
|
||||
}
|
||||
|
||||
fn ctx_xfrm(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape: &deckcraft_model::Shape) -> Xfrm {
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::edit::{self as ed, Pos};
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{AutoFit, Run, RunKind, RunProps, TextBody};
|
||||
use deckcraft_model::text::{AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody};
|
||||
use deckcraft_model::{Presentation, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -228,7 +228,7 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
|
||||
let pos = edit_text(s, |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = ed::delete(body, a, b);
|
||||
let props = props.clone().or_else(|| if a != b { Some(ed::props_at(body, at)) } else { None });
|
||||
let props = props.clone().or_else(|| if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) });
|
||||
let c = ed::insert(body, at, &text, props);
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
@@ -238,6 +238,20 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
|
||||
Ok(json!({"caret": [pos.0, pos.1]}))
|
||||
}
|
||||
|
||||
/// Typing right after a hyperlink doesn't extend it (as in PowerPoint): at the end of a linked
|
||||
/// run, the props to type with are the run's without the link. `None` elsewhere.
|
||||
fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option<RunProps> {
|
||||
let mut props = ed::props_at(body, at);
|
||||
props.link.as_ref()?;
|
||||
let len = body.paragraphs.get(at.0).map_or(0, |p| p.text().chars().count());
|
||||
// props_at(c + 1) is the props of the character at c.
|
||||
if at.1 < len && ed::props_at(body, (at.0, at.1 + 1)).link == props.link {
|
||||
return None;
|
||||
}
|
||||
props.link = None;
|
||||
Some(props)
|
||||
}
|
||||
|
||||
const AUTOCORRECT: &[(&str, &str)] = &[
|
||||
("teh", "the"),
|
||||
("adn", "and"),
|
||||
@@ -535,21 +549,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
if let Some(t) = st.selection.text.clone() {
|
||||
let body = body_of(st, &t).cloned().unwrap_or_default();
|
||||
let (mut a, mut b) = t.ordered();
|
||||
if a == b {
|
||||
let (wa, wb) = ed::word_at(&body, a);
|
||||
let inside = wa.1 < a.1 && a.1 < wb.1;
|
||||
if inside {
|
||||
a = wa;
|
||||
b = wb;
|
||||
// Don't include the trailing space.
|
||||
let line: Vec<char> = body.paragraphs.get(b.0).map(|p| p.text().chars().collect()).unwrap_or_default();
|
||||
while b.1 > a.1 && line.get(b.1 - 1) == Some(&' ') {
|
||||
b.1 -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (a, b) = format_range(st, &t);
|
||||
let collapsed = a == b;
|
||||
edit_text(s, |body, _| {
|
||||
if collapsed {
|
||||
@@ -591,6 +591,51 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
ok()
|
||||
}
|
||||
|
||||
/// What `format_run` formats while editing: the selection, or for a bare caret the word it's in
|
||||
/// (without its trailing space). Empty when the caret is at a word boundary.
|
||||
fn format_range(st: &DocState, t: &TextSel) -> (Pos, Pos) {
|
||||
let body = body_of(st, t).cloned().unwrap_or_default();
|
||||
let (mut a, mut b) = t.ordered();
|
||||
if a == b {
|
||||
let (wa, wb) = ed::word_at(&body, a);
|
||||
let inside = wa.1 < a.1 && a.1 < wb.1;
|
||||
if inside {
|
||||
a = wa;
|
||||
b = wb;
|
||||
// Don't include the trailing space.
|
||||
let line: Vec<char> = body.paragraphs.get(b.0).map(|p| p.text().chars().collect()).unwrap_or_default();
|
||||
while b.1 > a.1 && line.get(b.1 - 1) == Some(&' ') {
|
||||
b.1 -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// Link the text being edited: the selection or the word at the caret, as `format_run` would.
|
||||
/// With nothing to link (caret at a word boundary, empty paragraph) `display` is inserted at the
|
||||
/// caret as linked text in the surrounding formatting, as PowerPoint's Insert Hyperlink does.
|
||||
pub(crate) fn link_text(s: &mut Session, link: Hyperlink, display: &str) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let Some(t) = st.selection.text.clone() else { return Err(bad("insert.hyperlink", "not editing text")) };
|
||||
let (a, b) = format_range(st, &t);
|
||||
if a != b {
|
||||
return format_run(s, &move |rp| rp.link = Some(link.clone()));
|
||||
}
|
||||
if display.is_empty() {
|
||||
return Err(bad("insert.hyperlink", "nothing to link: select text or give `text`"));
|
||||
}
|
||||
let pos = edit_text(s, |body, t| {
|
||||
let mut props = ed::props_at(body, a);
|
||||
props.link = Some(link);
|
||||
let c = ed::insert(body, a, display, Some(props));
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
c
|
||||
})?;
|
||||
Ok(json!({"caret": [pos.0, pos.1]}))
|
||||
}
|
||||
|
||||
/// Apply paragraph changes to the paragraphs touched by the selection (or all paragraphs of the
|
||||
/// selected shapes).
|
||||
pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::Paragraph) + Send + Sync)) -> Result<Value> {
|
||||
|
||||
@@ -300,6 +300,8 @@ pub struct Session {
|
||||
pub(crate) untitled: u32,
|
||||
/// Format Painter: copied shape/text formatting and whether it stays on (double-click).
|
||||
pub painter: Option<(cmd::format::Painted, bool)>,
|
||||
/// Animation Painter: copied shape animations and whether it stays on (double-click).
|
||||
pub anim_painter: Option<(Vec<deckcraft_model::anim::Animation>, bool)>,
|
||||
/// Set as Default Shape: the look new shapes get.
|
||||
pub default_look: Option<cmd::format::Painted>,
|
||||
/// Nesting of commands run by commands: only the outermost one is an undo step and a journal
|
||||
@@ -309,6 +311,8 @@ pub struct Session {
|
||||
pub recovery_dir: Option<std::path::PathBuf>,
|
||||
/// Media playback state, written by the UI host every frame (empty without a host).
|
||||
pub media_status: std::collections::HashMap<ShapeId, MediaStatus>,
|
||||
/// Animated GIF pictures paused with `media.gifPlay` (the others play).
|
||||
pub gif_paused: std::collections::HashSet<ShapeId>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -331,9 +335,11 @@ impl Session {
|
||||
untitled: 0,
|
||||
default_look: None,
|
||||
painter: None,
|
||||
anim_painter: None,
|
||||
depth: 0,
|
||||
recovery_dir: None,
|
||||
media_status: Default::default(),
|
||||
gif_paused: Default::default(),
|
||||
}
|
||||
}
|
||||
/// A session with one new presentation open.
|
||||
|
||||
@@ -366,3 +366,180 @@ fn freeform_tools_and_command() {
|
||||
g => panic!("{g:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_in_table_uses_grown_row_heights() {
|
||||
// Rows stored 4 pt tall are drawn tall enough for their text, so 6 pt down is still row 0.
|
||||
let mut s = session();
|
||||
let data = json!([["a", "b"], ["c", "d"], ["e", "f"]]);
|
||||
let id = s.execute("insert.table", &json!({"rows": 3, "cols": 2, "rect": [100.0, 100.0, 400.0, 120.0], "data": data})).unwrap()["id"].clone();
|
||||
for row in 0..3 {
|
||||
s.execute("table.rowHeight", &json!({"id": id, "row": row, "height": 4.0})).unwrap();
|
||||
}
|
||||
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
|
||||
s.pointer(ev(PointerKind::DoubleClick, 120.0, 106.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.text.as_ref().and_then(|t| t.cell), Some((0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grown_table_frame_covers_its_drawn_rows() {
|
||||
// Rows stored 4 pt tall are drawn taller: the box, its handles and hit tests follow the drawn rows.
|
||||
let mut s = session();
|
||||
let data = json!([["a", "b"], ["c", "d"], ["e", "f"]]);
|
||||
let id = s.execute("insert.table", &json!({"rows": 3, "cols": 2, "rect": [100.0, 100.0, 400.0, 120.0], "data": data})).unwrap()["id"].clone();
|
||||
for row in 0..3 {
|
||||
s.execute("table.rowHeight", &json!({"id": id, "row": row, "height": 4.0})).unwrap();
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
let sh = st.shapes().iter().find(|sh| json!(sh.id) == id).unwrap().clone();
|
||||
let deckcraft_model::ShapeKind::Table(t) = &sh.kind else { panic!("not a table") };
|
||||
let drawn: f64 = tools::table_rows(st, t).iter().sum();
|
||||
assert!(drawn > 12.0, "rows should grow: {drawn}");
|
||||
let x = cmd::xfrm_of(&st.doc, &st.selection, &sh);
|
||||
assert!((x.h - drawn).abs() < 1e-6, "frame {} vs drawn {drawn}", x.h);
|
||||
let below_stored = deckcraft_geom::Point::new(120.0, 100.0 + drawn - 2.0);
|
||||
assert!(matches!(s.hit_test(below_stored, 1.0), Some(tools::Hit::Shape { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_paragraphs() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "سنة 2024 (Retrait) فقط"})).unwrap();
|
||||
s.execute("format.rtl", &json!({})).unwrap();
|
||||
let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone();
|
||||
assert_eq!(para(&s).rtl, Some(true));
|
||||
// Left (the default) becomes right: the text stays at its start edge.
|
||||
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::Right));
|
||||
s.execute("format.align", &json!({"align": "justifyLow"})).unwrap();
|
||||
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::JustLow));
|
||||
// Toggling back keeps a justified alignment.
|
||||
s.execute("format.rtl", &json!({})).unwrap();
|
||||
assert_eq!(para(&s).rtl, Some(false));
|
||||
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::JustLow));
|
||||
s.execute("format.rtl", &json!({"on": true})).unwrap();
|
||||
s.execute("format.alignLeft", &json!({})).unwrap();
|
||||
s.execute("format.rtl", &json!({"on": false})).unwrap();
|
||||
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::Right));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(para(&s).rtl, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_footer_fixed_date_is_the_slides_own_text() {
|
||||
use deckcraft_model::text::RunKind;
|
||||
let mut s = session();
|
||||
let date = |s: &Session| {
|
||||
let slide = s.doc().unwrap().current_slide().unwrap();
|
||||
slide.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)).and_then(|x| x.text.clone()).unwrap()
|
||||
};
|
||||
let kind = |s: &Session| date(s).paragraphs[0].runs[0].kind.clone();
|
||||
// Automatic: a date field, saved with today's date.
|
||||
s.execute("design.headerFooter", &json!({"date": true})).unwrap();
|
||||
assert_eq!(kind(&s), RunKind::Field { field: "datetime1".into() });
|
||||
assert_eq!(date(&s).text().matches('/').count(), 2, "M/D/YYYY");
|
||||
// Fixed: plain text in the slide's own placeholder (other slides' fields aren't affected).
|
||||
s.execute("design.headerFooter", &json!({"date": true, "dateText": "Launch day"})).unwrap();
|
||||
assert_eq!((kind(&s), date(&s).text()), (RunKind::Text, "Launch day".to_string()));
|
||||
// Back to automatic.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "dateText": ""})).unwrap();
|
||||
assert!(matches!(kind(&s), RunKind::Field { .. }));
|
||||
// A new date field is saved with the date it shows.
|
||||
s.execute("text.edit", &json!({"id": s.doc().unwrap().current_slide().unwrap().shapes[0].id.0})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Updated "})).unwrap();
|
||||
s.execute("insert.dateTime", &json!({"format": "datetime10"})).unwrap();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap();
|
||||
let run = title.paragraphs[0].runs.iter().find(|r| matches!(r.kind, RunKind::Field { .. })).cloned().unwrap();
|
||||
assert!(run.text.contains(':'), "H:mm {run:?}");
|
||||
}
|
||||
|
||||
/// (text, linked?) of each run in the first paragraph of the current slide's title.
|
||||
fn title_runs(s: &Session) -> Vec<(String, bool)> {
|
||||
let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0];
|
||||
let p = &sh.text.as_ref().unwrap().paragraphs[0];
|
||||
p.runs.iter().map(|r| (r.text.clone(), r.props.link.is_some())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlink_with_a_bare_caret_inserts_the_address() {
|
||||
// #31: with the caret at the end of the text, Insert Hyperlink used to format an empty range
|
||||
// and change nothing.
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "See "})).unwrap();
|
||||
s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": " now"})).unwrap();
|
||||
assert_eq!(
|
||||
title_runs(&s),
|
||||
vec![("See ".into(), false), ("https://example.org".into(), true), (" now".into(), false)],
|
||||
"the address is inserted as a link, and typing after it doesn't extend it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlink_display_text_and_targets() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("insert.hyperlink", &json!({"url": "mailto:a@b.org"})).unwrap();
|
||||
assert_eq!(title_runs(&s), vec![("a@b.org".into(), true)], "email links show the address without mailto:");
|
||||
s.execute("text.insert", &json!({"text": " "})).unwrap();
|
||||
s.execute("insert.hyperlink", &json!({"slide": 0, "text": "start"})).unwrap();
|
||||
assert_eq!(title_runs(&s), vec![("a@b.org".into(), true), (" ".into(), false), ("start".into(), true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlink_on_a_word_or_shape_links_it() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Hello world"})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "left"})).unwrap();
|
||||
s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap();
|
||||
assert_eq!(title_runs(&s), vec![("Hello ".into(), false), ("world".into(), true)], "a caret inside a word links the word");
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap();
|
||||
assert!(s.doc().unwrap().current_slide().unwrap().shapes[0].click.is_some(), "a selected shape gets the link as its click action");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn() {
|
||||
// Flips don't mirror text, so a click lands on the character drawn under it.
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 400, 100], "text": "Hello flipped world"})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
let pos = |s: &Session, x: f64, y: f64| {
|
||||
let st = s.doc().unwrap();
|
||||
tools::text_pos(st, st.shape(id).unwrap(), deckcraft_geom::Point::new(x, y))
|
||||
};
|
||||
let xs = [240.0, 300.0, 360.0];
|
||||
let plain = xs.map(|x| pos(&s, x, 150.0));
|
||||
assert!(plain[0].1 < plain[1].1 && plain[1].1 < plain[2].1, "{plain:?}");
|
||||
s.execute("shape.flip", &json!({"axis": "horizontal"})).unwrap();
|
||||
assert_eq!(xs.map(|x| pos(&s, x, 150.0)), plain);
|
||||
// flipV turns the text 180° about the box centre (300, 150).
|
||||
s.execute("shape.flip", &json!({"axis": "horizontal"})).unwrap();
|
||||
s.execute("shape.flip", &json!({"axis": "vertical"})).unwrap();
|
||||
assert_eq!(xs.map(|x| pos(&s, 600.0 - x, 150.0)), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserted_hyperlinks_look_like_links() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Read the docs"})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "wordLeft", "extend": true})).unwrap();
|
||||
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false);
|
||||
s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap();
|
||||
// Underlined (and hlink-coloured: same resolution as files) without touching the run's own props.
|
||||
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], true);
|
||||
let body = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap();
|
||||
let run = body.paragraphs[0].runs.iter().find(|r| r.props.link.is_some()).unwrap();
|
||||
assert!(run.props.fill.is_none() && run.props.underline.is_none(), "{run:?}");
|
||||
// Underline off is the run's own u="none", as in PowerPoint.
|
||||
s.execute("format.underline", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false);
|
||||
}
|
||||
|
||||
+34
-11
@@ -434,6 +434,10 @@ impl Session {
|
||||
if self.painter.is_some() && !toggle {
|
||||
let _ = self.execute("format.painterApply", &json!({"ids": [id]}));
|
||||
}
|
||||
// Animation Painter applies on click.
|
||||
if self.anim_painter.is_some() && !toggle {
|
||||
let _ = self.execute("animation.painterApply", &json!({"ids": [id]}));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let add = mods.shift || mods.cmd;
|
||||
@@ -789,7 +793,7 @@ impl Session {
|
||||
let x = xfrm_of(&st.doc, &st.selection, &sh);
|
||||
let local = x.affine().inverse() * p;
|
||||
if let ShapeKind::Table(t) = &sh.kind {
|
||||
let cell = deckcraft_render_cell(t, local);
|
||||
let cell = deckcraft_render_cell(t, &table_rows(st, t), local);
|
||||
return self.execute("text.edit", &json!({"id": id, "cell": [cell.0, cell.1]}));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
@@ -855,16 +859,28 @@ impl Session {
|
||||
}
|
||||
|
||||
fn set_xfrm(&mut self, id: ShapeId, x: Xfrm) -> Result<()> {
|
||||
// Rows scale from their drawn heights: a stored height is only a minimum (often 0).
|
||||
let drawn = self.active().and_then(|st| match &st.shape(id)?.kind {
|
||||
ShapeKind::Table(t) => Some(table_rows(st, t)),
|
||||
_ => None,
|
||||
});
|
||||
self.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("tool", "no slide"))?;
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(list, id) {
|
||||
sh.xfrm = Some(x);
|
||||
if let ShapeKind::Table(t) = &mut sh.kind {
|
||||
// Tables resize their columns and rows proportionally.
|
||||
let (tw, th) = (t.width().max(1e-6), t.height().max(1e-6));
|
||||
let (kx, ky) = (x.w / tw, x.h / th);
|
||||
let heights: Vec<f64> = match &drawn {
|
||||
Some(d) if d.len() == t.rows.len() => d.clone(),
|
||||
_ => t.rows.iter().map(|r| r.height).collect(),
|
||||
};
|
||||
let (tw, th) = (t.width().max(1e-6), heights.iter().map(|h| h.max(0.0)).sum::<f64>());
|
||||
let kx = x.w / tw;
|
||||
t.cols.iter_mut().for_each(|c| *c *= kx);
|
||||
t.rows.iter_mut().for_each(|r| r.height *= ky);
|
||||
if th > 1e-6 {
|
||||
let ky = x.h / th;
|
||||
t.rows.iter_mut().zip(&heights).for_each(|(r, h)| r.height = h.max(0.0) * ky);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1246,10 +1262,12 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit
|
||||
/// Text position under slide point `p` in shape `sh`.
|
||||
pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (usize, usize) {
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let local = x.affine().inverse() * p;
|
||||
let t = TextSel { shape: sh.id, ..Default::default() };
|
||||
match cmd::text::layout_for(st, &t) {
|
||||
Some(l) => {
|
||||
// The text's own transform: flips don't mirror text, vertical text is turned.
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let local = deckcraft_render::text_transform(x.affine(), tr, l.rotation).inverse() * p;
|
||||
let h = l.hit(local);
|
||||
(h.para, h.ch)
|
||||
}
|
||||
@@ -1257,16 +1275,21 @@ pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (usize, usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which table cell contains a table-local point.
|
||||
fn deckcraft_render_cell(t: &deckcraft_model::Table, p: Point) -> (usize, usize) {
|
||||
/// Row heights of table `t` as drawn: rows grow to fit their text.
|
||||
pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec<f64> {
|
||||
cmd::table_drawn_heights(&st.doc, &st.selection, t)
|
||||
}
|
||||
|
||||
/// Which table cell contains a table-local point (`heights` from [`table_rows`]).
|
||||
fn deckcraft_render_cell(t: &deckcraft_model::Table, heights: &[f64], p: Point) -> (usize, usize) {
|
||||
let mut y = 0.0;
|
||||
let mut row = t.rows.len().saturating_sub(1);
|
||||
for (i, r) in t.rows.iter().enumerate() {
|
||||
if p.y < y + r.height {
|
||||
let mut row = heights.len().saturating_sub(1);
|
||||
for (i, h) in heights.iter().enumerate() {
|
||||
if p.y < y + h {
|
||||
row = i;
|
||||
break;
|
||||
}
|
||||
y += r.height;
|
||||
y += h;
|
||||
}
|
||||
let mut x = 0.0;
|
||||
let mut col = t.cols.len().saturating_sub(1);
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Font fallback per grapheme cluster, and ligature caret positions.
|
||||
//!
|
||||
//! The cascade for a cluster (a base character with its marks, never split across faces) is:
|
||||
//! 1. the faces the document asks for, in order (the run's slot typeface, then the theme's script
|
||||
//! font, `a:font[@script="Arab"]`);
|
||||
//! 2. for scripts with a platform cascade (Arabic, Hebrew, Thai, Devanagari): the craft-fonts face
|
||||
//! bundled for the script (Noto Naskh Arabic for Arabic), so every machine draws the same; then
|
||||
//! openly licensed or OS-native families found by name among the installed fonts (no platform
|
||||
//! API is called); then any installed face covering the cluster;
|
||||
//! 3. any loaded face (bundled first: Japanese keeps its craft-fonts faces), then the first
|
||||
//! requested face (drawn as .notdef).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use skrifa::raw::TableProvider;
|
||||
|
||||
use crate::script::{ScriptTag, script_of};
|
||||
use crate::{CRAFT_FONTS, FontDb, FontFace};
|
||||
|
||||
/// Characters that never decide a face: they're drawn by whatever face the cluster gets.
|
||||
fn ignorable(c: char) -> bool {
|
||||
c.is_whitespace()
|
||||
|| c.is_control()
|
||||
|| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2069 | 0xFE00..=0xFE0F | 0xFEFF | 0xE0100..=0xE01EF)
|
||||
}
|
||||
|
||||
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation, or
|
||||
/// any sequence with an emoji presentation selector (U+FE0F), a zero-width joiner between
|
||||
/// pictographs, a skin tone modifier, regional indicators (flags) or a keycap?
|
||||
pub fn is_emoji_cluster(cluster: &[char]) -> bool {
|
||||
if cluster.contains(&'\u{FE0E}') {
|
||||
return false;
|
||||
}
|
||||
let pictographic = |c: char| matches!(c as u32, 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF | 0x2190..=0x21FF | 0x2300..=0x23FF);
|
||||
let default_emoji =
|
||||
|c: char| matches!(c as u32, 0x1F300..=0x1F64F | 0x1F680..=0x1F6FF | 0x1F900..=0x1F9FF | 0x1FA70..=0x1FAFF | 0x1F1E6..=0x1F1FF);
|
||||
cluster.iter().any(|&c| default_emoji(c))
|
||||
|| (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c) || c.is_ascii_digit() || c == '#' || c == '*'))
|
||||
|| (cluster.contains(&'\u{200D}') && cluster.iter().any(|&c| pictographic(c)))
|
||||
|| cluster.contains(&'\u{20E3}')
|
||||
}
|
||||
|
||||
/// Does `face` draw every character of the cluster?
|
||||
pub fn covers_cluster(face: &FontFace, cluster: &[char]) -> bool {
|
||||
cluster.iter().all(|c| ignorable(*c) || face.covers(*c))
|
||||
}
|
||||
|
||||
/// Installed families tried by name for a script after the bundled face, before scanning every
|
||||
/// installed font. Microsoft font names are not listed (AGENTS.md §1.1); an installed one is
|
||||
/// still used by the scan when nothing earlier covers the cluster.
|
||||
fn os_families(script: ScriptTag) -> &'static [&'static str] {
|
||||
match &script {
|
||||
b"Arab" => &["Noto Naskh Arabic", "Noto Sans Arabic", "Geeza Pro", "SF Arabic", "DejaVu Sans"],
|
||||
b"Hebr" => &["Noto Sans Hebrew", "Arial Hebrew", "SF Hebrew", "DejaVu Sans"],
|
||||
b"Thai" => &["Noto Sans Thai", "Thonburi"],
|
||||
b"Deva" => &["Noto Sans Devanagari", "Kohinoor Devanagari", "Devanagari Sangam MN"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The craft-fonts families for a script (manifest order), e.g. Noto Naskh Arabic for `Arab`.
|
||||
pub fn bundled_families(script: ScriptTag) -> Vec<&'static str> {
|
||||
let tag = std::str::from_utf8(&script).unwrap_or("");
|
||||
let mut v: Vec<&'static str> = CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&tag)).map(|f| f.family).collect();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
impl FontDb {
|
||||
/// The face for one grapheme cluster: the first of `chain` that draws all of it, else the
|
||||
/// bundled face for its script, else an installed face for the script, else any loaded
|
||||
/// face that draws it, else `chain[0]` (or the default face if `chain` is empty).
|
||||
pub fn cascade(&self, cluster: &[char], chain: &[Arc<FontFace>], style: &str) -> Arc<FontFace> {
|
||||
// Emoji take a colour face (with its ZWJ sequences, skin tones and flags) before the
|
||||
// document's fonts, as PowerPoint draws them, even when a text font has a plain glyph.
|
||||
if is_emoji_cluster(cluster) {
|
||||
if let Some(f) = chain.iter().find(|f| f.is_color() && covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
if let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c))
|
||||
&& let Some(f) = self.color_face_for(key, cluster)
|
||||
{
|
||||
return f;
|
||||
}
|
||||
}
|
||||
if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
// Text presentation (U+FE0E): a plain face, not a colour emoji one.
|
||||
if cluster.contains(&'\u{FE0E}') {
|
||||
let faces = self.read_faces();
|
||||
if let Some(f) = faces.iter().find(|f| !f.is_color() && covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
}
|
||||
let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style));
|
||||
let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() };
|
||||
let script = cluster.iter().find_map(|c| script_of(*c));
|
||||
// Scripts with a platform cascade (Arabic, Hebrew, Thai, Devanagari): the bundled face,
|
||||
// then installed ones; the others keep the loaded-faces-first order (Japanese documents
|
||||
// prefer the bundled craft-fonts faces).
|
||||
if let Some(s) = script.filter(|s| !os_families(*s).is_empty()) {
|
||||
let bundled = bundled_families(s);
|
||||
for fam in &bundled {
|
||||
let f = self.face(fam, style);
|
||||
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
for fam in os_families(s) {
|
||||
if bundled.contains(fam) {
|
||||
continue;
|
||||
}
|
||||
if self.is_loaded(fam) || self.has_family(fam) {
|
||||
let f = self.face(fam, style);
|
||||
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(f) = self.system_face_for(key, cluster, &bundled) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
let exclude = chain.first().map(|f| f.id()).unwrap_or(u32::MAX);
|
||||
match self.fallback_for(key, exclude) {
|
||||
Some(f) if covers_cluster(&f, cluster) => f,
|
||||
Some(f) if chain.iter().all(|c| !c.covers(key)) => f,
|
||||
_ => first(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An installed (non-bundled) face covering the cluster, loading one from the system catalog
|
||||
/// if no loaded face does.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_face_for(&self, key: char, cluster: &[char], bundled: &[&str]) -> Option<Arc<FontFace>> {
|
||||
let pick = |db: &FontDb| {
|
||||
let faces = db.read_faces();
|
||||
let mut v: Vec<&Arc<FontFace>> = faces
|
||||
.iter()
|
||||
.filter(|f| !bundled.iter().any(|b| f.family.eq_ignore_ascii_case(b)) && !CRAFT_FONTS.iter().any(|c| c.family == f.family))
|
||||
.filter(|f| covers_cluster(f, cluster))
|
||||
.collect();
|
||||
v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
|
||||
v.first().map(|f| (*f).clone())
|
||||
};
|
||||
if let Some(f) = pick(self) {
|
||||
return Some(f);
|
||||
}
|
||||
if self.system_fallback(key) { pick(self) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
|
||||
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
|
||||
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
|
||||
let Some(font) = face.skrifa() else { return Vec::new() };
|
||||
let Ok(gdef) = font.gdef() else { return Vec::new() };
|
||||
let Some(Ok(list)) = gdef.lig_caret_list() else { return Vec::new() };
|
||||
let Ok(cov) = list.coverage() else { return Vec::new() };
|
||||
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
|
||||
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
|
||||
let mut out: Vec<i32> = lig
|
||||
.caret_values()
|
||||
.iter()
|
||||
.filter_map(|cv| cv.ok())
|
||||
.filter_map(|cv| match cv {
|
||||
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
|
||||
skrifa::raw::tables::gdef::CaretValue::Format3(f) => Some(f.coordinate() as i32),
|
||||
// Format 2 is a contour point index; outlines aren't consulted here.
|
||||
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
|
||||
})
|
||||
.take(64)
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn requested_face_wins_when_it_covers() {
|
||||
let db = FontDb::global();
|
||||
let inter = db.face(crate::DEFAULT_FAMILY, "Regular");
|
||||
let f = db.cascade(&['a'], std::slice::from_ref(&inter), "Regular");
|
||||
assert!(Arc::ptr_eq(&f, &inter));
|
||||
// Spaces and joiners never force a fallback.
|
||||
let f = db.cascade(&[' ', '\u{200D}'], std::slice::from_ref(&inter), "Regular");
|
||||
assert!(Arc::ptr_eq(&f, &inter));
|
||||
// Nothing to go on: the default face.
|
||||
assert!(!db.cascade(&[], &[], "Regular").family.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_cluster_falls_back_to_an_arabic_face() {
|
||||
let db = FontDb::global();
|
||||
let inter = db.face(crate::DEFAULT_FAMILY, "Regular");
|
||||
let cluster = ['\u{0628}', '\u{064E}']; // beh + fatha: one cluster, one face.
|
||||
let f = db.cascade(&cluster, std::slice::from_ref(&inter), "Regular");
|
||||
if covers_cluster(&f, &cluster) {
|
||||
assert!(f.covers('\u{0628}'));
|
||||
} else {
|
||||
eprintln!("skipped: no installed or bundled face covers Arabic");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligature_carets_never_fail() {
|
||||
let face = FontDb::global().face(crate::DEFAULT_FAMILY, "Regular");
|
||||
for gid in [0, 1, 100, u32::MAX] {
|
||||
let c = ligature_carets(&face, gid);
|
||||
assert!(c.windows(2).all(|w| w[0] <= w[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,10 @@ pub struct FontFace {
|
||||
/// values).
|
||||
pub cap_height: f64,
|
||||
pub x_height: f64,
|
||||
/// Where the baseline sits in a line, as a share of the line's height from its top: the
|
||||
/// Windows ascent over the Windows ascent plus descent (OS/2 `usWinAscent`/`usWinDescent`),
|
||||
/// the split PowerPoint uses (computed by `line_split`).
|
||||
pub line_split: f64,
|
||||
pub shaper: harfrust::ShaperData,
|
||||
/// Variable fonts: the named instance's axis settings (user units; empty = default instance),
|
||||
/// the normalised location and the shaper's view of it.
|
||||
@@ -87,6 +91,8 @@ pub struct FontFace {
|
||||
pub instance: Option<harfrust::ShaperInstance>,
|
||||
/// Basic Multilingual Plane coverage bitset, built on first use.
|
||||
bmp: std::sync::OnceLock<Box<[u64]>>,
|
||||
/// Colour glyph tables present (`is_color`), looked up on first use.
|
||||
color: std::sync::OnceLock<bool>,
|
||||
}
|
||||
|
||||
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
|
||||
@@ -154,6 +160,11 @@ impl FontFace {
|
||||
pub fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
|
||||
skrifa::FontRef::from_index(self.data(), self.index).ok()
|
||||
}
|
||||
/// Does the face draw colour glyphs (a `COLR`, `CBDT`, `sbix` or `SVG ` table, as colour emoji
|
||||
/// fonts have)?
|
||||
pub fn is_color(&self) -> bool {
|
||||
*self.color.get_or_init(|| has_color_tables(self.data(), self.index))
|
||||
}
|
||||
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
|
||||
harfrust::FontRef::from_index(self.data(), self.index).ok()
|
||||
}
|
||||
@@ -254,6 +265,13 @@ struct SysFallback {
|
||||
misses: std::collections::HashSet<char>,
|
||||
}
|
||||
|
||||
/// Does face `index` of `data` carry colour glyph tables?
|
||||
fn has_color_tables(data: &[u8], index: u32) -> bool {
|
||||
use skrifa::raw::types::Tag;
|
||||
let Ok(f) = skrifa::FontRef::from_index(data, index) else { return false };
|
||||
[b"COLR", b"CBDT", b"sbix", b"SVG "].iter().any(|t| f.table_data(Tag::new(t)).is_some())
|
||||
}
|
||||
|
||||
/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const SYSTEM_FALLBACKS: &[&str] = &[
|
||||
@@ -436,6 +454,23 @@ pub(crate) fn last_resort_face() -> Arc<FontFace> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The share of a line above the baseline (`FontFace::line_split`): OS/2 `usWinAscent` over
|
||||
/// `usWinAscent + usWinDescent`, else the ascent over ascent plus descent. Fonts whose Windows
|
||||
/// extent is huge (Gabriola, Cambria Math: over 1.5 em) get 0.81, where PowerPoint puts their
|
||||
/// baseline (observed).
|
||||
fn line_split(f: &skrifa::FontRef, ascent: f64, descent: f64, upem: f64) -> f64 {
|
||||
use skrifa::raw::TableProvider;
|
||||
let (a, d) = match f.os2() {
|
||||
Ok(os2) if os2.us_win_ascent() > 0 => (os2.us_win_ascent() as f64, os2.us_win_descent() as f64),
|
||||
_ => (ascent, descent),
|
||||
};
|
||||
let total = a + d;
|
||||
if !total.is_finite() || total <= 0.0 || a < 0.0 || d < 0.0 {
|
||||
return 0.8;
|
||||
}
|
||||
if total > upem * 1.5 { 0.81 } else { (a / total).clamp(0.5, 1.0) }
|
||||
}
|
||||
|
||||
fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords: Vec<([u8; 4], f32)>) -> Option<FontFace> {
|
||||
let data: &[u8] = match &bytes {
|
||||
FontBytes::Static(b) => b,
|
||||
@@ -467,6 +502,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
descent: -(m.descent as f64),
|
||||
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
|
||||
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
|
||||
line_split: line_split(&f, m.ascent as f64, -(m.descent as f64), m.units_per_em.max(1) as f64),
|
||||
shaper,
|
||||
coords,
|
||||
location,
|
||||
@@ -474,6 +510,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
bytes,
|
||||
index,
|
||||
bmp: std::sync::OnceLock::new(),
|
||||
color: std::sync::OnceLock::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -557,7 +594,7 @@ impl FontDb {
|
||||
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
|
||||
}
|
||||
|
||||
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
pub(crate) fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
self.faces.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
@@ -744,7 +781,7 @@ impl FontDb {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_loaded(&self, family: &str) -> bool {
|
||||
pub(crate) fn is_loaded(&self, family: &str) -> bool {
|
||||
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
|
||||
}
|
||||
|
||||
@@ -842,7 +879,7 @@ impl FontDb {
|
||||
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
|
||||
/// file under 40 MB). Returns true if one was loaded. Misses are remembered.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_fallback(&self, c: char) -> bool {
|
||||
pub(crate) fn system_fallback(&self, c: char) -> bool {
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
@@ -880,6 +917,61 @@ impl FontDb {
|
||||
false
|
||||
}
|
||||
|
||||
/// A loaded colour face drawing all of `cluster`, loading an installed colour font that covers
|
||||
/// `key` if none is loaded yet. Misses are remembered.
|
||||
pub(crate) fn color_face_for(&self, key: char, cluster: &[char]) -> Option<Arc<FontFace>> {
|
||||
let pick = |db: &FontDb| {
|
||||
let faces = db.read_faces();
|
||||
let mut v: Vec<&Arc<FontFace>> = faces.iter().filter(|f| crate::covers_cluster(f, cluster) && f.is_color()).collect();
|
||||
v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
|
||||
v.first().map(|f| (*f).clone())
|
||||
};
|
||||
if let Some(f) = pick(self) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_color_fallback(key) {
|
||||
return pick(self);
|
||||
}
|
||||
let _ = key;
|
||||
None
|
||||
}
|
||||
|
||||
/// Load an installed colour font covering `c`. True if one was loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_color_fallback(&self, c: char) -> bool {
|
||||
static MISSES: Mutex<Option<std::collections::HashSet<char>>> = Mutex::new(None);
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !sys.enabled {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
|
||||
return false;
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
for p in paths {
|
||||
if std::fs::metadata(&p).map(|m| m.len() > 64 << 20).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Ok(data) = std::fs::read(&p) else { continue };
|
||||
let hit = enumerate_faces(&data).iter().any(|(i, _, _, _)| {
|
||||
has_color_tables(&data, *i) && skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())
|
||||
});
|
||||
if hit && self.add_font(data) > 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
|
||||
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
|
||||
let key = (face.id, gid);
|
||||
|
||||
+60
-83
@@ -1,21 +1,25 @@
|
||||
//! DeckCraft fonts: the font database (craft-fonts families, user and system fonts, and open
|
||||
//! substitutes for the Office fonts presentations ask for), vertical metrics, glyph outlines and
|
||||
//! OpenType shaping.
|
||||
//! OpenType shaping, script/slot classification and per-cluster fallback.
|
||||
//!
|
||||
//! Shaping here is style-agnostic: [`shape`] turns a string in one face into glyph ids, clusters
|
||||
//! and advances in font units. `deckcraft-text` applies sizes, tracking, scaling and
|
||||
//! Shaping here is style-agnostic: [`shape`] and [`shape_range`] turn text in one face into glyph
|
||||
//! ids, clusters and advances in font units. `deckcraft-text` applies sizes, tracking, scaling and
|
||||
//! justification on top.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod fallback;
|
||||
mod fontdb;
|
||||
pub mod script;
|
||||
mod shape;
|
||||
|
||||
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
|
||||
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs};
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
|
||||
use harfrust::Tag;
|
||||
pub use kurbo::BezPath;
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
pub use script::{FontSlot, ScriptTag};
|
||||
pub use shape::{ShapeParams, ShapedGlyph, shape_range};
|
||||
|
||||
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
|
||||
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
|
||||
@@ -53,17 +57,6 @@ pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
|
||||
/// The default theme's font.
|
||||
pub const DEFAULT_FAMILY: &str = "Inter";
|
||||
|
||||
/// One shaped glyph, in font units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ShapedGlyph {
|
||||
pub gid: u32,
|
||||
/// Byte offset (in the shaped string) of the cluster this glyph belongs to.
|
||||
pub cluster: usize,
|
||||
pub x_advance: i32,
|
||||
pub x_offset: i32,
|
||||
pub y_offset: i32,
|
||||
}
|
||||
|
||||
/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`.
|
||||
pub fn feature(tag: &str) -> Option<Feature> {
|
||||
let (on, t) = match tag.strip_prefix('-') {
|
||||
@@ -83,6 +76,26 @@ pub fn is_rtl(c: char) -> bool {
|
||||
matches!(unicode_bidi::bidi_class(c), R | AL)
|
||||
}
|
||||
|
||||
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
|
||||
/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical
|
||||
/// order: each direction run is shaped with [`shape_range`] (right-to-left runs right to left,
|
||||
/// with the whole string as context), then its clusters put back in text order (each cluster's
|
||||
/// glyphs keep the shaper's order). Paragraph layout uses [`shape_range`] directly and keeps the
|
||||
/// visual order.
|
||||
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
for (r, rtl) in direction_runs(text) {
|
||||
let mut g = shape_range(face, text, r, &ShapeParams { features, rtl, ..Default::default() }, &map);
|
||||
if rtl {
|
||||
// Visual (clusters descending) → logical; the sort is stable, so each cluster's
|
||||
// glyphs keep the shaper's order.
|
||||
g.sort_by_key(|x| x.cluster);
|
||||
}
|
||||
out.extend(g);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Maximal runs of one direction (neutrals join the run they're in): (byte range, right-to-left).
|
||||
fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
|
||||
let mut runs: Vec<(std::ops::Range<usize>, bool)> = Vec::new();
|
||||
@@ -110,72 +123,6 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
|
||||
runs
|
||||
}
|
||||
|
||||
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
|
||||
/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical
|
||||
/// order: right-to-left runs are shaped right to left, then their clusters put back in text
|
||||
/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually.
|
||||
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
if !text.chars().any(is_rtl) {
|
||||
return shape_dir(face, text, features, &map, false);
|
||||
}
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
for (r, rtl) in direction_runs(text) {
|
||||
let mut g = shape_dir(face, &text[r.clone()], features, &map, rtl);
|
||||
for x in &mut g {
|
||||
x.cluster += r.start;
|
||||
}
|
||||
if rtl {
|
||||
// Visual (clusters descending) → logical, cluster by cluster.
|
||||
let mut groups: Vec<Vec<ShapedGlyph>> = Vec::new();
|
||||
for x in g {
|
||||
match groups.last_mut() {
|
||||
Some(last) if last[0].cluster == x.cluster => last.push(x),
|
||||
_ => groups.push(vec![x]),
|
||||
}
|
||||
}
|
||||
groups.sort_by_key(|grp| grp[0].cluster);
|
||||
g = groups.into_iter().flatten().collect();
|
||||
}
|
||||
out.extend(g);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text.char_indices() {
|
||||
buf.add(map(c), i as u32);
|
||||
}
|
||||
buf.guess_segment_properties();
|
||||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
out.push(ShapedGlyph {
|
||||
gid: info.glyph_id,
|
||||
cluster: info.cluster as usize,
|
||||
x_advance: pos.x_advance,
|
||||
x_offset: pos.x_offset,
|
||||
y_offset: pos.y_offset,
|
||||
});
|
||||
}
|
||||
});
|
||||
if shaped.is_none()
|
||||
&& let Some(f) = face.skrifa()
|
||||
{
|
||||
let cmap = f.charmap();
|
||||
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
||||
for (i, c) in text.char_indices() {
|
||||
let g = cmap.map(map(c)).unwrap_or_default();
|
||||
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
||||
out.push(ShapedGlyph { gid: g.to_u32(), cluster: i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Glyph id of the first of `chars` the face has (0 = .notdef).
|
||||
pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 {
|
||||
chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0)
|
||||
@@ -189,6 +136,25 @@ mod tests {
|
||||
FontDb::global().face(DEFAULT_FAMILY, "Regular")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emoji_clusters_are_recognised() {
|
||||
let c = |s: &str| s.chars().collect::<Vec<char>>();
|
||||
for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "1\u{FE0F}\u{20E3}", "🏳\u{FE0F}\u{200D}🌈"] {
|
||||
assert!(is_emoji_cluster(&c(yes)), "{yes:?}");
|
||||
}
|
||||
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→"] {
|
||||
assert!(!is_emoji_cluster(&c(no)), "{no:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_fonts_are_recognised() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0);
|
||||
assert!(db.face("DeckCraft Colr Squares", "Regular").is_color());
|
||||
assert!(!fontdb::last_resort_face().is_color());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_resort_face_parses() {
|
||||
let f = fontdb::last_resort_face();
|
||||
@@ -196,6 +162,17 @@ mod tests {
|
||||
assert!(f.upem > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_split_follows_the_windows_ascent_and_descent() {
|
||||
use skrifa::raw::TableProvider;
|
||||
let f = fontdb::last_resort_face();
|
||||
let font = skrifa::FontRef::new(f.data()).unwrap();
|
||||
let os2 = font.os2().unwrap();
|
||||
let (a, d) = (os2.us_win_ascent() as f64, os2.us_win_descent() as f64);
|
||||
assert!((f.line_split - a / (a + d)).abs() < 1e-9, "{} vs {}", f.line_split, a / (a + d));
|
||||
assert!(f.line_split > 0.5 && f.line_split < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn some_face_always_resolves() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Script and font-slot classification.
|
||||
//!
|
||||
//! DrawingML gives every run three typefaces: `a:latin`, `a:ea` (East Asian) and `a:cs` (complex
|
||||
//! script: Arabic, Hebrew, Thai, Indic…). Which one a character uses follows its Unicode script
|
||||
//! (UAX #24). Characters with no script of their own (Common and Inherited: spaces, digits,
|
||||
//! punctuation, combining marks) take the script and slot of the text around them, so a space or
|
||||
//! a parenthesis between two Arabic words is shaped with them.
|
||||
|
||||
/// An ISO 15924 script tag, e.g. `*b"Arab"`.
|
||||
pub type ScriptTag = [u8; 4];
|
||||
|
||||
pub const ARABIC: ScriptTag = *b"Arab";
|
||||
pub const HEBREW: ScriptTag = *b"Hebr";
|
||||
pub const LATIN: ScriptTag = *b"Latn";
|
||||
|
||||
/// The DrawingML typeface slot a character is drawn with.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum FontSlot {
|
||||
/// `a:latin`.
|
||||
#[default]
|
||||
Latin,
|
||||
/// `a:ea`.
|
||||
EastAsian,
|
||||
/// `a:cs`.
|
||||
ComplexScript,
|
||||
}
|
||||
|
||||
/// Inclusive code point ranges and their script (a subset of `Scripts.txt` covering the scripts
|
||||
/// the slot decision and shaping need; everything else is Latin-slot or Common).
|
||||
const RANGES: &[(u32, u32, ScriptTag)] = &[
|
||||
(0x0041, 0x005A, *b"Latn"),
|
||||
(0x0061, 0x007A, *b"Latn"),
|
||||
(0x00AA, 0x00AA, *b"Latn"),
|
||||
(0x00BA, 0x00BA, *b"Latn"),
|
||||
(0x00C0, 0x00D6, *b"Latn"),
|
||||
(0x00D8, 0x00F6, *b"Latn"),
|
||||
(0x00F8, 0x024F, *b"Latn"),
|
||||
(0x0370, 0x03FF, *b"Grek"),
|
||||
(0x0400, 0x052F, *b"Cyrl"),
|
||||
(0x0530, 0x058F, *b"Armn"),
|
||||
(0x0591, 0x05F4, *b"Hebr"),
|
||||
// Arabic block, minus the Common/Inherited code points handled in `script_of`.
|
||||
(0x0600, 0x06FF, *b"Arab"),
|
||||
(0x0700, 0x074F, *b"Syrc"),
|
||||
(0x0750, 0x077F, *b"Arab"),
|
||||
(0x0780, 0x07BF, *b"Thaa"),
|
||||
(0x07C0, 0x07FF, *b"Nkoo"),
|
||||
(0x0870, 0x08FF, *b"Arab"),
|
||||
(0x0900, 0x097F, *b"Deva"),
|
||||
(0x0980, 0x09FF, *b"Beng"),
|
||||
(0x0A00, 0x0A7F, *b"Guru"),
|
||||
(0x0A80, 0x0AFF, *b"Gujr"),
|
||||
(0x0B00, 0x0B7F, *b"Orya"),
|
||||
(0x0B80, 0x0BFF, *b"Taml"),
|
||||
(0x0C00, 0x0C7F, *b"Telu"),
|
||||
(0x0C80, 0x0CFF, *b"Knda"),
|
||||
(0x0D00, 0x0D7F, *b"Mlym"),
|
||||
(0x0D80, 0x0DFF, *b"Sinh"),
|
||||
(0x0E00, 0x0E7F, *b"Thai"),
|
||||
(0x0E80, 0x0EFF, *b"Laoo"),
|
||||
(0x0F00, 0x0FFF, *b"Tibt"),
|
||||
(0x1000, 0x109F, *b"Mymr"),
|
||||
(0x10A0, 0x10FF, *b"Geor"),
|
||||
(0x1100, 0x11FF, *b"Hang"),
|
||||
(0x1200, 0x139F, *b"Ethi"),
|
||||
(0x1780, 0x17FF, *b"Khmr"),
|
||||
(0x1E00, 0x1EFF, *b"Latn"),
|
||||
(0x1F00, 0x1FFF, *b"Grek"),
|
||||
(0x2C60, 0x2C7F, *b"Latn"),
|
||||
(0x2E80, 0x2FDF, *b"Hani"),
|
||||
(0x3040, 0x309F, *b"Hira"),
|
||||
(0x30A0, 0x30FF, *b"Kana"),
|
||||
(0x3100, 0x312F, *b"Bopo"),
|
||||
(0x3130, 0x318F, *b"Hang"),
|
||||
(0x31F0, 0x31FF, *b"Kana"),
|
||||
(0x3400, 0x4DBF, *b"Hani"),
|
||||
(0x4E00, 0x9FFF, *b"Hani"),
|
||||
(0xA720, 0xA7FF, *b"Latn"),
|
||||
(0xAC00, 0xD7AF, *b"Hang"),
|
||||
(0xF900, 0xFAFF, *b"Hani"),
|
||||
(0xFB00, 0xFB06, *b"Latn"),
|
||||
(0xFB1D, 0xFB4F, *b"Hebr"),
|
||||
(0xFB50, 0xFDFF, *b"Arab"),
|
||||
(0xFE70, 0xFEFF, *b"Arab"),
|
||||
(0xFF21, 0xFF3A, *b"Latn"),
|
||||
(0xFF41, 0xFF5A, *b"Latn"),
|
||||
(0xFF66, 0xFF9F, *b"Kana"),
|
||||
(0x10E60, 0x10E7F, *b"Arab"),
|
||||
(0x1EE00, 0x1EEFF, *b"Arab"),
|
||||
(0x20000, 0x323AF, *b"Hani"),
|
||||
];
|
||||
|
||||
/// Code points inside the Arabic blocks whose script is Common or Inherited: the Arabic comma,
|
||||
/// semicolon and question mark, tatweel, the harakat (combining vowel marks) and the BOM-like
|
||||
/// U+FEFF. They take the script of the text around them.
|
||||
fn arabic_common(cp: u32) -> bool {
|
||||
matches!(cp, 0x060C | 0x061B | 0x061F | 0x0640 | 0x064B..=0x0655 | 0x0670 | 0xFEFF | 0xFD3E | 0xFD3F)
|
||||
}
|
||||
|
||||
/// The script of `c`, or `None` for Common and Inherited characters (spaces, digits,
|
||||
/// punctuation, combining marks), which take the script of their neighbours.
|
||||
pub fn script_of(c: char) -> Option<ScriptTag> {
|
||||
let cp = c as u32;
|
||||
if arabic_common(cp) {
|
||||
return None;
|
||||
}
|
||||
// Ranges are sorted and disjoint: binary search on the start.
|
||||
let i = RANGES.partition_point(|(lo, _, _)| *lo <= cp);
|
||||
let (lo, hi, tag) = RANGES.get(i.checked_sub(1)?)?;
|
||||
(cp >= *lo && cp <= *hi).then_some(*tag)
|
||||
}
|
||||
|
||||
/// The slot a script is drawn with.
|
||||
pub fn slot_of_script(s: ScriptTag) -> FontSlot {
|
||||
match &s {
|
||||
b"Hani" | b"Hira" | b"Kana" | b"Hang" | b"Bopo" => FontSlot::EastAsian,
|
||||
b"Arab" | b"Hebr" | b"Syrc" | b"Thaa" | b"Nkoo" | b"Deva" | b"Beng" | b"Guru" | b"Gujr" | b"Orya" | b"Taml" | b"Telu" | b"Knda" | b"Mlym"
|
||||
| b"Sinh" | b"Thai" | b"Laoo" | b"Tibt" | b"Mymr" | b"Khmr" => FontSlot::ComplexScript,
|
||||
_ => FontSlot::Latin,
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot of `c` on its own: `None` for characters that take their neighbours' slot. CJK
|
||||
/// punctuation and full-width forms are East Asian although their script is Common.
|
||||
pub fn slot_of(c: char) -> Option<FontSlot> {
|
||||
if let Some(s) = script_of(c) {
|
||||
return Some(slot_of_script(s));
|
||||
}
|
||||
let cp = c as u32;
|
||||
if (0x3000..=0x303F).contains(&cp) || (0xFF00..=0xFFEF).contains(&cp) || (0x3200..=0x33FF).contains(&cp) {
|
||||
return Some(FontSlot::EastAsian);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A default BCP 47 language for a script (shaping picks `locl` forms by language).
|
||||
pub fn default_language(s: ScriptTag) -> Option<&'static str> {
|
||||
Some(match &s {
|
||||
b"Arab" => "ar",
|
||||
b"Hebr" => "he",
|
||||
b"Syrc" => "syr",
|
||||
b"Thaa" => "dv",
|
||||
b"Thai" => "th",
|
||||
b"Deva" => "hi",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve every character's script and slot, giving Common/Inherited characters the value of the
|
||||
/// preceding character with one (or, at the start, the following one; Latin when there is none).
|
||||
pub fn resolve(chars: &[char]) -> Vec<(Option<ScriptTag>, FontSlot)> {
|
||||
let own: Vec<(Option<ScriptTag>, Option<FontSlot>)> = chars.iter().map(|c| (script_of(*c), slot_of(*c))).collect();
|
||||
let first = own.iter().find_map(|(s, sl)| sl.map(|sl| (*s, sl))).unwrap_or((None, FontSlot::Latin));
|
||||
let mut prev = first;
|
||||
own.into_iter()
|
||||
.map(|(s, sl)| {
|
||||
if let Some(sl) = sl {
|
||||
prev = (s, sl);
|
||||
}
|
||||
prev
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Arabic letters that join to the following letter (joining type D, dual-joining): a kashida
|
||||
/// (tatweel) may be inserted after them. Right-joining (alef, dal, reh, waw…) and non-joining
|
||||
/// (hamza) letters are excluded.
|
||||
pub fn joins_following(c: char) -> bool {
|
||||
let cp = c as u32;
|
||||
let letter = matches!(cp, 0x0620..=0x064A | 0x066E..=0x06D3 | 0x06FA..=0x06FC | 0x06FF | 0x0750..=0x077F | 0x08A0..=0x08C9);
|
||||
letter
|
||||
&& !matches!(
|
||||
cp,
|
||||
0x0621..=0x0625 | 0x0627 | 0x0629 | 0x062F..=0x0632 | 0x0648 | 0x0671..=0x0673 | 0x0675..=0x0677 | 0x0688..=0x0699 | 0x06C0
|
||||
| 0x06C3..=0x06CB | 0x06CD | 0x06CF | 0x06D2 | 0x06D3 | 0x0759..=0x075B | 0x076B | 0x076C | 0x0771 | 0x0773 | 0x0774
|
||||
| 0x0778 | 0x0779 | 0x08AA..=0x08AC | 0x08AE | 0x08B1 | 0x08B2 | 0x08B9
|
||||
)
|
||||
}
|
||||
|
||||
/// An Arabic letter that joins to the preceding letter (joining type D or R).
|
||||
pub fn joins_preceding(c: char) -> bool {
|
||||
let cp = c as u32;
|
||||
matches!(cp, 0x0622..=0x064A | 0x066E..=0x06D3 | 0x06FA..=0x06FC | 0x06FF | 0x0750..=0x077F | 0x08A0..=0x08C9) && cp != 0x0621
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scripts_and_slots() {
|
||||
assert_eq!(script_of('س'), Some(ARABIC));
|
||||
assert_eq!(script_of('ש'), Some(HEBREW));
|
||||
assert_eq!(script_of('R'), Some(LATIN));
|
||||
assert_eq!(script_of('2'), None);
|
||||
assert_eq!(script_of('('), None);
|
||||
// Harakat and tatweel are Inherited/Common.
|
||||
assert_eq!(script_of('\u{064E}'), None);
|
||||
assert_eq!(script_of('\u{0640}'), None);
|
||||
assert_eq!(slot_of('س'), Some(FontSlot::ComplexScript));
|
||||
assert_eq!(slot_of('日'), Some(FontSlot::EastAsian));
|
||||
assert_eq!(slot_of('、'), Some(FontSlot::EastAsian));
|
||||
assert_eq!(slot_of('a'), Some(FontSlot::Latin));
|
||||
assert_eq!(slot_of(' '), None);
|
||||
assert_eq!(script_of('\u{10FFFF}'), None);
|
||||
assert_eq!(script_of('\0'), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutrals_take_the_surrounding_slot() {
|
||||
let chars: Vec<char> = "سنة 2024 (Retrait) فقط".chars().collect();
|
||||
let r = resolve(&chars);
|
||||
// "سنة 2024 (" follows Arabic: complex script.
|
||||
for (i, c) in chars.iter().enumerate().take(10) {
|
||||
assert_eq!(r[i].1, FontSlot::ComplexScript, "{c:?}");
|
||||
}
|
||||
// "Retrait)" is Latin; the space after ")" stays Latin; "فقط" is complex script.
|
||||
assert_eq!(r[10].1, FontSlot::Latin);
|
||||
assert_eq!(r[17].1, FontSlot::Latin);
|
||||
assert_eq!(r[19], (Some(ARABIC), FontSlot::ComplexScript));
|
||||
// Leading neutrals take the first strong character's slot.
|
||||
let r = resolve(&['(', 'س']);
|
||||
assert_eq!(r[0].1, FontSlot::ComplexScript);
|
||||
assert!(resolve(&[]).is_empty());
|
||||
assert_eq!(resolve(&['1'])[0], (None, FontSlot::Latin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joining_classes() {
|
||||
assert!(joins_following('س'));
|
||||
assert!(joins_following('ن'));
|
||||
assert!(!joins_following('ا'));
|
||||
assert!(!joins_following('د'));
|
||||
assert!(!joins_following('ء'));
|
||||
assert!(joins_preceding('ا'));
|
||||
assert!(!joins_preceding('ء'));
|
||||
assert!(!joins_following('a'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_are_sorted_and_disjoint() {
|
||||
for w in RANGES.windows(2) {
|
||||
assert!(w[0].1 < w[1].0, "{:x?} {:x?}", w[0], w[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! OpenType shaping with HarfRust.
|
||||
//!
|
||||
//! [`shape_range`] shapes one item of a paragraph (one face, one direction, one script) with the
|
||||
//! rest of the paragraph as pre/post context, so Arabic letters at an item boundary (a colour
|
||||
//! change inside a word, a bold letter) still take their joining forms. Glyphs come back in
|
||||
//! visual order, exactly as the shaper produced them: right-to-left items are not reversed.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use harfrust::{Direction, Feature, Language, Script, ShapeOptions, Tag, UnicodeBuffer};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
|
||||
use crate::FontFace;
|
||||
use crate::script::ScriptTag;
|
||||
|
||||
/// One shaped glyph, in font units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ShapedGlyph {
|
||||
pub gid: u32,
|
||||
/// Byte offset (in the shaped string) of the cluster this glyph belongs to.
|
||||
pub cluster: usize,
|
||||
pub x_advance: i32,
|
||||
pub x_offset: i32,
|
||||
pub y_offset: i32,
|
||||
}
|
||||
|
||||
/// How to shape an item.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ShapeParams<'a> {
|
||||
pub features: &'a [Feature],
|
||||
/// Right-to-left (odd bidi level). Mirrored characters (brackets) take their mirrored glyphs.
|
||||
pub rtl: bool,
|
||||
/// ISO 15924 script (`*b"Arab"`); `None` lets the shaper guess from the text.
|
||||
pub script: Option<ScriptTag>,
|
||||
/// BCP 47 language (`"ar"`, `"ar-MA"`); `None` = the script's default.
|
||||
pub language: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Shape `para[range]` with `face`, the rest of `para` as context. Glyphs are in visual order
|
||||
/// (left to right) and clusters are byte offsets into `para`. `map` substitutes characters (All
|
||||
/// Caps) without moving clusters. An empty or out-of-bounds range shapes nothing.
|
||||
pub fn shape_range(face: &FontFace, para: &str, range: Range<usize>, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
let (Some(pre), Some(text), Some(post)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
buf.set_pre_context(pre);
|
||||
buf.set_post_context(post);
|
||||
for (i, c) in text.char_indices() {
|
||||
buf.add(map(c), (range.start + i) as u32);
|
||||
}
|
||||
buf.guess_segment_properties();
|
||||
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
|
||||
buf.set_script(s);
|
||||
}
|
||||
if let Some(l) = params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
|
||||
{
|
||||
buf.set_language(l);
|
||||
}
|
||||
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
out.push(ShapedGlyph {
|
||||
gid: info.glyph_id,
|
||||
cluster: info.cluster as usize,
|
||||
x_advance: pos.x_advance,
|
||||
x_offset: pos.x_offset,
|
||||
y_offset: pos.y_offset,
|
||||
});
|
||||
}
|
||||
});
|
||||
if shaped.is_none()
|
||||
&& let Some(f) = face.skrifa()
|
||||
{
|
||||
// No shaper for this face: one glyph per character from the cmap, in visual order.
|
||||
let cmap = f.charmap();
|
||||
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
||||
for (i, c) in text.char_indices() {
|
||||
let g = cmap.map(map(c)).unwrap_or_default();
|
||||
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
||||
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
|
||||
}
|
||||
if params.rtl {
|
||||
out.reverse();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{DEFAULT_FAMILY, FontDb};
|
||||
|
||||
#[test]
|
||||
fn rtl_items_come_in_visual_order_and_mirror() {
|
||||
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
||||
let text = "(ab)";
|
||||
let p = ShapeParams { rtl: true, ..Default::default() };
|
||||
let g = shape_range(&face, text, 0..text.len(), &p, |c| c);
|
||||
// Visual order: last cluster first.
|
||||
assert_eq!(g.iter().map(|g| g.cluster).collect::<Vec<_>>(), vec![3, 2, 1, 0]);
|
||||
// L4: in a right-to-left run "(" is drawn with the ")" glyph and vice versa.
|
||||
if face.covers('(') && face.covers(')') {
|
||||
assert_eq!(g[3].gid, face.glyph_for(')'));
|
||||
assert_eq!(g[0].gid, face.glyph_for('('));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clusters_point_into_the_whole_paragraph() {
|
||||
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
||||
let para = "Hello world";
|
||||
let g = shape_range(&face, para, 6..11, &ShapeParams::default(), |c| c);
|
||||
assert_eq!(g.first().map(|g| g.cluster), Some(6));
|
||||
assert!(shape_range(&face, para, 6..99, &ShapeParams::default(), |c| c).is_empty());
|
||||
// Not a char boundary.
|
||||
assert!(shape_range(&face, "سنة", 1..2, &ShapeParams::default(), |c| c).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod morph;
|
||||
pub mod preset;
|
||||
|
||||
pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2};
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
//! Morph between two outlines: one shape turns into another (a circle into a star).
|
||||
//!
|
||||
//! The approach is manim's: both outlines get the same number of contours and each contour pair the
|
||||
//! same number of cubic curves, then every control point moves in a straight line. On top of that,
|
||||
//! closed contours are turned and reversed so that their points travel as short a way as possible
|
||||
//! (a triangle does not twist on its way into a circle), and extra points go to the longest curves.
|
||||
|
||||
use kurbo::{BezPath, PathEl, Point, Rect, Vec2};
|
||||
|
||||
use crate::preset::{FillMode, Geometry, SubPath};
|
||||
|
||||
/// A contour as cubic curves: the start anchor, then three points per curve (handle, handle,
|
||||
/// anchor).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Contour {
|
||||
pub closed: bool,
|
||||
pub pts: Vec<Point>,
|
||||
}
|
||||
|
||||
impl Contour {
|
||||
pub fn curves(&self) -> usize {
|
||||
self.pts.len().saturating_sub(1) / 3
|
||||
}
|
||||
}
|
||||
|
||||
fn line_to(p: &mut Vec<Point>, to: Point) {
|
||||
let Some(&from) = p.last() else { return };
|
||||
p.extend([from.lerp(to, 1.0 / 3.0), from.lerp(to, 2.0 / 3.0), to]);
|
||||
}
|
||||
|
||||
fn close(c: &mut Contour) {
|
||||
if let (Some(&first), Some(&last)) = (c.pts.first(), c.pts.last())
|
||||
&& first != last
|
||||
{
|
||||
line_to(&mut c.pts, first);
|
||||
}
|
||||
c.closed = true;
|
||||
}
|
||||
|
||||
fn pen(cur: &mut Option<Contour>, start: Point) -> &mut Contour {
|
||||
cur.get_or_insert_with(|| Contour { closed: false, pts: vec![start] })
|
||||
}
|
||||
|
||||
fn keep(c: Contour, out: &mut Vec<Contour>) {
|
||||
if c.curves() > 0 && c.pts.iter().all(|p| p.is_finite()) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// The contours of a path, every segment as a cubic curve. Contours without a curve or with
|
||||
/// non-finite points are left out.
|
||||
pub fn contours(path: &BezPath) -> Vec<Contour> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<Contour> = None;
|
||||
let mut start = Point::ZERO;
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if let Some(c) = cur.take() {
|
||||
keep(c, &mut out);
|
||||
}
|
||||
start = p;
|
||||
cur = Some(Contour { closed: false, pts: vec![p] });
|
||||
}
|
||||
PathEl::LineTo(p) => line_to(&mut pen(&mut cur, start).pts, p),
|
||||
PathEl::QuadTo(q, p) => {
|
||||
let c = pen(&mut cur, start);
|
||||
if let Some(&a) = c.pts.last() {
|
||||
c.pts.extend([a.lerp(q, 2.0 / 3.0), p.lerp(q, 2.0 / 3.0), p]);
|
||||
}
|
||||
}
|
||||
PathEl::CurveTo(a, b, p) => pen(&mut cur, start).pts.extend([a, b, p]),
|
||||
PathEl::ClosePath => {
|
||||
if let Some(mut c) = cur.take()
|
||||
&& c.curves() > 0
|
||||
{
|
||||
close(&mut c);
|
||||
keep(c, &mut out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(c) = cur {
|
||||
keep(c, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Contours back to a path.
|
||||
pub fn to_path(cs: &[Contour]) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
for c in cs {
|
||||
let Some(&s) = c.pts.first() else { continue };
|
||||
bp.move_to(s);
|
||||
for [a, b, p] in c.pts.get(1..).unwrap_or(&[]).as_chunks::<3>().0 {
|
||||
bp.curve_to(*a, *b, *p);
|
||||
}
|
||||
if c.closed {
|
||||
bp.close_path();
|
||||
}
|
||||
}
|
||||
bp
|
||||
}
|
||||
|
||||
// --- alignment ---------------------------------------------------------------------------------
|
||||
|
||||
/// Rough curve length: mean of chord and control polygon.
|
||||
fn curve_length(c: &[Point]) -> f64 {
|
||||
let [p0, p1, p2, p3] = c else { return 0.0 };
|
||||
(p0.distance(*p3) + p0.distance(*p1) + p1.distance(*p2) + p2.distance(*p3)) / 2.0
|
||||
}
|
||||
|
||||
/// Appends curve `c` (four points) split into `n` pieces of equal parameter length (de Casteljau),
|
||||
/// without its start anchor.
|
||||
fn split_curve(c: &[Point], n: usize, out: &mut Vec<Point>) {
|
||||
let [a, b, d, e] = c else { return };
|
||||
let (mut p0, mut p1, mut p2, p3) = (*a, *b, *d, *e);
|
||||
for j in (2..=n).rev() {
|
||||
let t = 1.0 / j as f64;
|
||||
let (ab, bc, cd) = (p0.lerp(p1, t), p1.lerp(p2, t), p2.lerp(p3, t));
|
||||
let (abc, bcd) = (ab.lerp(bc, t), bc.lerp(cd, t));
|
||||
let m = abc.lerp(bcd, t);
|
||||
out.extend([ab, abc, m]);
|
||||
(p0, p1, p2) = (m, bcd, cd);
|
||||
}
|
||||
out.extend([p1, p2, p3]);
|
||||
}
|
||||
|
||||
/// Brings a contour to `n` curves. Unlike manim (which splits every curve equally often), the extra
|
||||
/// splits go to the longest pieces, so a long side gets more points than a short one.
|
||||
fn refine(c: &Contour, n: usize) -> Contour {
|
||||
let have = c.curves();
|
||||
if have >= n || have == 0 {
|
||||
return c.clone();
|
||||
}
|
||||
let curves: Vec<&[Point]> = (0..have).filter_map(|k| c.pts.get(3 * k..3 * k + 4)).collect();
|
||||
let len: Vec<f64> = curves.iter().map(|q| curve_length(q) + 1e-9).collect();
|
||||
let mut parts = vec![1usize; curves.len()];
|
||||
for _ in 0..n - have {
|
||||
let share = |k: usize| len.get(k).copied().unwrap_or(0.0) / parts.get(k).copied().unwrap_or(1) as f64;
|
||||
let best = (1..curves.len()).fold(0, |b, k| if share(k) > share(b) { k } else { b });
|
||||
if let Some(p) = parts.get_mut(best) {
|
||||
*p += 1;
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(1 + 3 * n);
|
||||
out.extend(c.pts.first().copied());
|
||||
for (q, p) in curves.iter().zip(&parts) {
|
||||
split_curve(q, *p, &mut out);
|
||||
}
|
||||
Contour { closed: c.closed, pts: out }
|
||||
}
|
||||
|
||||
const MIN_CURVES: usize = 12;
|
||||
const MAX_CURVES: usize = 60;
|
||||
/// Start points tried per closed contour; longer contours are sampled evenly.
|
||||
const MAX_TRIES: usize = 128;
|
||||
|
||||
/// How many curves a contour pair gets: a common multiple of both counts while that stays small
|
||||
/// (then a triangle meets a circle at matching places and the morph stays symmetric), and at least
|
||||
/// [`MIN_CURVES`] for a smooth middle.
|
||||
fn curves_for(na: usize, nb: usize) -> usize {
|
||||
fn gcd(x: usize, y: usize) -> usize {
|
||||
if y == 0 { x } else { gcd(y, x % y) }
|
||||
}
|
||||
let hi = na.max(nb).max(1);
|
||||
let mut n = if na > 0 && nb > 0 { (na / gcd(na, nb)).saturating_mul(nb) } else { hi };
|
||||
if n > MAX_CURVES {
|
||||
n = hi;
|
||||
}
|
||||
n.saturating_mul(MIN_CURVES.div_ceil(n).max(1))
|
||||
}
|
||||
|
||||
/// Box around all points, handles included.
|
||||
fn bounds<'a>(pts: impl IntoIterator<Item = &'a Point>) -> Option<Rect> {
|
||||
pts.into_iter().fold(None, |r: Option<Rect>, p| Some(r.map_or(Rect::from_points(*p, *p), |r| r.union_pt(*p))))
|
||||
}
|
||||
|
||||
fn center(pts: &[Point]) -> Point {
|
||||
bounds(pts).map(|r| r.center()).unwrap_or(Point::ZERO)
|
||||
}
|
||||
|
||||
/// The same contour, starting at anchor `s`, optionally run backwards.
|
||||
fn turn(pts: &[Point], s: usize, back: bool) -> Vec<Point> {
|
||||
let q: Vec<Point> = if back { pts.iter().rev().copied().collect() } else { pts.to_vec() };
|
||||
if s == 0 {
|
||||
return q;
|
||||
}
|
||||
let body = q.get(1..).unwrap_or(&[]);
|
||||
let cut = (3 * s).min(body.len());
|
||||
let mut out = Vec::with_capacity(q.len());
|
||||
out.extend(cut.checked_sub(1).and_then(|i| body.get(i)).copied());
|
||||
out.extend_from_slice(body.get(cut..).unwrap_or(&[]));
|
||||
out.extend_from_slice(body.get(..cut).unwrap_or(&[]));
|
||||
out
|
||||
}
|
||||
|
||||
/// One contour pair of equal curve count: `b` runs backwards if `back` is set, then the closed side
|
||||
/// (`b` if it is closed, else `a`) gets the start that lets the points travel least. Both sides are
|
||||
/// compared around their own centre, so where the shapes are does not matter.
|
||||
fn match_pair(a: &Contour, b: &Contour, back: bool) -> (Contour, Contour, f64) {
|
||||
let bp = if back { turn(&b.pts, 0, true) } else { b.pts.clone() };
|
||||
let turn_a = !b.closed && a.closed;
|
||||
let (fixed, free) = if turn_a { (&bp, &a.pts) } else { (&a.pts, &bp) };
|
||||
let (fc, gc) = (center(fixed), center(free));
|
||||
let tries = if turn_a || b.closed { free.len().saturating_sub(1) / 3 } else { 1 };
|
||||
let mut best = free.clone();
|
||||
let mut best_cost = f64::INFINITY;
|
||||
for s in (0..tries.max(1)).step_by(tries.div_ceil(MAX_TRIES).max(1)) {
|
||||
let q = turn(free, s, false);
|
||||
let mut cost = 0.0;
|
||||
for (p, f) in q.iter().zip(fixed.iter()) {
|
||||
cost += ((*p - gc) - (*f - fc)).hypot2();
|
||||
if cost >= best_cost {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if cost < best_cost {
|
||||
best_cost = cost;
|
||||
best = q;
|
||||
}
|
||||
}
|
||||
let (pa, pb) = if turn_a { (best, bp) } else { (a.pts.clone(), best) };
|
||||
(Contour { closed: a.closed, pts: pa }, Contour { closed: b.closed, pts: pb }, best_cost)
|
||||
}
|
||||
|
||||
/// Where a contour without partner shrinks to (or grows from): the spot at the same place relative
|
||||
/// to the other outline's box, or its own centre without one.
|
||||
fn place(c: &Contour, from: Option<Rect>, to: Option<Rect>) -> Point {
|
||||
let p = center(&c.pts);
|
||||
let (Some(from), Some(to)) = (from, to) else { return p };
|
||||
let fx = if from.width() > 0.0 { (p.x - from.x0) / from.width() } else { 0.5 };
|
||||
let fy = if from.height() > 0.0 { (p.y - from.y0) / from.height() } else { 0.5 };
|
||||
Point::new(to.x0 + to.width() * fx, to.y0 + to.height() * fy)
|
||||
}
|
||||
|
||||
/// Two contour lists brought to the same number of contours and the same number of curves per
|
||||
/// contour pair. Contours pair up in order; one without partner meets a point (see [`place`];
|
||||
/// `ba`/`bb` are the boxes of the whole outlines). Whether `b` runs backwards is decided once for all
|
||||
/// its contours: turning single ones around would change which areas a nonzero fill covers.
|
||||
pub fn align(a: &[Contour], b: &[Contour], ba: Option<Rect>, bb: Option<Rect>) -> (Vec<Contour>, Vec<Contour>) {
|
||||
// (a, b, either side a point).
|
||||
let mut pairs: Vec<(Contour, Contour, bool)> = Vec::new();
|
||||
for k in 0..a.len().max(b.len()) {
|
||||
let (ca, cb) = (a.get(k), b.get(k));
|
||||
let n = curves_for(ca.map_or(0, Contour::curves), cb.map_or(0, Contour::curves));
|
||||
let point = |c: &Contour, from, to, closed| Contour { closed, pts: vec![place(c, from, to); 1 + 3 * n] };
|
||||
match (ca, cb) {
|
||||
(Some(x), Some(y)) => pairs.push((refine(x, n), refine(y, n), false)),
|
||||
(Some(x), None) => pairs.push((refine(x, n), point(x, ba, bb, x.closed), true)),
|
||||
(None, Some(y)) => pairs.push((point(y, bb, ba, y.closed), refine(y, n), true)),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
let mut best: Option<(Vec<Contour>, Vec<Contour>, f64)> = None;
|
||||
for back in [false, true] {
|
||||
let (mut xs, mut ys, mut cost) = (Vec::new(), Vec::new(), 0.0);
|
||||
for (x, y, point) in &pairs {
|
||||
if *point {
|
||||
xs.push(x.clone());
|
||||
ys.push(if back { Contour { closed: y.closed, pts: turn(&y.pts, 0, true) } } else { y.clone() });
|
||||
} else {
|
||||
let (mx, my, c) = match_pair(x, y, back);
|
||||
xs.push(mx);
|
||||
ys.push(my);
|
||||
cost += c;
|
||||
}
|
||||
}
|
||||
if best.as_ref().is_none_or(|b| cost < b.2) {
|
||||
best = Some((xs, ys, cost));
|
||||
}
|
||||
}
|
||||
best.map(|(x, y, _)| (x, y)).unwrap_or_default()
|
||||
}
|
||||
|
||||
// --- whole geometries ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Look {
|
||||
fill: FillMode,
|
||||
stroke: bool,
|
||||
even_odd: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Part {
|
||||
a: Option<Look>,
|
||||
b: Option<Look>,
|
||||
ca: Vec<Contour>,
|
||||
cb: Vec<Contour>,
|
||||
}
|
||||
|
||||
/// Two shape geometries brought to the same structure once, then blended per frame with
|
||||
/// [`GeometryMorph::at`]. Sub-paths pair up in order; one without partner grows out of (or shrinks
|
||||
/// into) a point. When a pair differs in fill mode, outline or fill rule, the two looks cross-fade
|
||||
/// (see [`Fade`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GeometryMorph {
|
||||
parts: Vec<Part>,
|
||||
text: (Rect, Rect),
|
||||
}
|
||||
|
||||
fn look(s: &SubPath) -> Look {
|
||||
Look { fill: s.fill, stroke: s.stroke, even_odd: s.even_odd }
|
||||
}
|
||||
|
||||
/// How strongly a sub-path of a morphed geometry shows: its fill and its outline, 0..1.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Fade {
|
||||
pub fill: f64,
|
||||
pub stroke: f64,
|
||||
}
|
||||
|
||||
/// The layers a pair of looks is drawn with at `t`, bottom first. A fill or an outline only one side
|
||||
/// has fades out (or in). When both fill, the side with the even-odd rule lies opaque underneath and
|
||||
/// the other fades over it, so a donut's hole fills smoothly; with the same rule both cover the same
|
||||
/// area, so this is a plain cross-fade (shading of 3-D faces).
|
||||
fn layers(a: Option<Look>, b: Option<Look>, t: f64) -> Vec<(Look, Fade)> {
|
||||
let (a, b) = match (a, b) {
|
||||
(Some(a), Some(b)) if a != b => (a, b),
|
||||
(Some(x), _) | (None, Some(x)) => return vec![(x, Fade { fill: 1.0, stroke: 1.0 })],
|
||||
(None, None) => return vec![],
|
||||
};
|
||||
let side = |on_a: bool, on_b: bool| match (on_a, on_b) {
|
||||
(true, true) => 1.0,
|
||||
(true, false) => 1.0 - t,
|
||||
(false, true) => t,
|
||||
(false, false) => 0.0,
|
||||
};
|
||||
let (stroke, line) = (a.stroke || b.stroke, side(a.stroke, b.stroke));
|
||||
let (fa, fb) = (a.fill != FillMode::None, b.fill != FillMode::None);
|
||||
if fa && fb {
|
||||
let (under, over, alpha) = if a.even_odd || !b.even_odd { (a, b, t) } else { (b, a, 1.0 - t) };
|
||||
vec![(Look { stroke, ..under }, Fade { fill: 1.0, stroke: line }), (Look { stroke: false, ..over }, Fade { fill: alpha, stroke: 0.0 })]
|
||||
} else {
|
||||
let filled = if fa { a } else { b };
|
||||
vec![(Look { stroke, ..filled }, Fade { fill: side(fa, fb), stroke: line })]
|
||||
}
|
||||
}
|
||||
|
||||
impl GeometryMorph {
|
||||
/// `a` and `b` each in their own shape-local space.
|
||||
pub fn new(a: &Geometry, b: &Geometry) -> Self {
|
||||
let ca: Vec<Vec<Contour>> = a.paths.iter().map(|s| contours(&s.path)).collect();
|
||||
let cb: Vec<Vec<Contour>> = b.paths.iter().map(|s| contours(&s.path)).collect();
|
||||
let ba = bounds(ca.iter().flatten().flat_map(|c| &c.pts));
|
||||
let bb = bounds(cb.iter().flatten().flat_map(|c| &c.pts));
|
||||
let mut parts = Vec::new();
|
||||
for k in 0..a.paths.len().max(b.paths.len()) {
|
||||
let (xa, xb) = (ca.get(k).map(Vec::as_slice).unwrap_or(&[]), cb.get(k).map(Vec::as_slice).unwrap_or(&[]));
|
||||
let (pa, pb) = align(xa, xb, ba, bb);
|
||||
parts.push(Part { a: a.paths.get(k).map(look), b: b.paths.get(k).map(look), ca: pa, cb: pb });
|
||||
}
|
||||
GeometryMorph { parts, text: (a.text_rect, b.text_rect) }
|
||||
}
|
||||
|
||||
/// The geometry at `t` (0..1), with a [`Fade`] for each of its sub-paths. The points of `a` are
|
||||
/// first scaled by `sa` and those of `b` by `sb`, to bring both into the current box.
|
||||
pub fn at(&self, t: f64, sa: Vec2, sb: Vec2) -> (Geometry, Vec<Fade>) {
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let scale = |p: Point, s: Vec2| Point::new(p.x * s.x, p.y * s.y);
|
||||
let (mut paths, mut fades) = (Vec::new(), Vec::new());
|
||||
for part in &self.parts {
|
||||
let cs: Vec<Contour> = part
|
||||
.ca
|
||||
.iter()
|
||||
.zip(&part.cb)
|
||||
.map(|(x, y)| Contour {
|
||||
closed: x.closed && y.closed,
|
||||
pts: x.pts.iter().zip(&y.pts).map(|(p, q)| scale(*p, sa).lerp(scale(*q, sb), t)).collect(),
|
||||
})
|
||||
.collect();
|
||||
let path = to_path(&cs);
|
||||
for (lk, fade) in layers(part.a, part.b, t) {
|
||||
paths.push(SubPath { path: path.clone(), fill: lk.fill, stroke: lk.stroke, even_odd: lk.even_odd });
|
||||
fades.push(fade);
|
||||
}
|
||||
}
|
||||
let r = |r: Rect, s: Vec2| Rect::new(r.x0 * s.x, r.y0 * s.y, r.x1 * s.x, r.y1 * s.y);
|
||||
let (ta, tb) = (r(self.text.0, sa), r(self.text.1, sb));
|
||||
let text_rect = Rect::from_points(ta.origin().lerp(tb.origin(), t), Point::new(ta.x1, ta.y1).lerp(Point::new(tb.x1, tb.y1), t));
|
||||
(Geometry { paths, text_rect, handles: vec![], sites: vec![] }, fades)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::preset::build;
|
||||
use kurbo::Shape as _;
|
||||
|
||||
fn bbox(g: &Geometry) -> Rect {
|
||||
g.outline().bounding_box()
|
||||
}
|
||||
|
||||
fn near(a: Rect, b: Rect) -> bool {
|
||||
(a.x0 - b.x0).abs() < 1e-6 && (a.y0 - b.y0).abs() < 1e-6 && (a.x1 - b.x1).abs() < 1e-6 && (a.y1 - b.y1).abs() < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contours_turn_lines_and_quads_into_cubics() {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.line_to((3.0, 0.0));
|
||||
p.quad_to((3.0, 3.0), (0.0, 3.0));
|
||||
p.close_path();
|
||||
// After a close, drawing goes on from the start point.
|
||||
p.line_to((5.0, 5.0));
|
||||
p.move_to((9.0, 9.0));
|
||||
let cs = contours(&p);
|
||||
assert_eq!(cs.len(), 2, "{cs:?}");
|
||||
assert!(cs[0].closed && !cs[1].closed);
|
||||
// line, quad and the closing line.
|
||||
assert_eq!(cs[0].curves(), 3);
|
||||
assert_eq!(cs[0].pts[1], Point::new(1.0, 0.0));
|
||||
assert_eq!(cs[0].pts.last(), cs[0].pts.first());
|
||||
let end = Point::new(5.0, 5.0);
|
||||
assert_eq!(cs[1].pts, vec![Point::ZERO, Point::ZERO.lerp(end, 1.0 / 3.0), Point::ZERO.lerp(end, 2.0 / 3.0), end]);
|
||||
// The lone move_to at the end makes no contour, and the path comes back the same.
|
||||
assert_eq!(contours(&to_path(&cs)), cs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refine_splits_the_longest_curves_and_keeps_the_shape() {
|
||||
let c = Contour { closed: false, pts: vec![Point::ZERO, Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)] };
|
||||
let mut long = c.clone();
|
||||
line_to(&mut long.pts, Point::new(3.0, 30.0));
|
||||
let r = refine(&long, 5);
|
||||
assert_eq!(r.curves(), 5);
|
||||
// The short first side stays one curve, the long one gets the other four.
|
||||
assert_eq!(r.pts[3], Point::new(3.0, 0.0));
|
||||
assert_eq!(r.pts.last(), Some(&Point::new(3.0, 30.0)));
|
||||
assert!(r.pts.iter().all(|p| (p.x - 3.0).abs() < 1e-9 || p.y == 0.0));
|
||||
assert_eq!(refine(&c, 1), c);
|
||||
assert_eq!(curves_for(3, 4), 12);
|
||||
assert_eq!(curves_for(4, 4), 12);
|
||||
// The common multiple 77 is too many: the larger count, doubled to reach the minimum.
|
||||
assert_eq!(curves_for(7, 11), 22);
|
||||
assert_eq!(curves_for(0, 0), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_turns_into_a_star_and_lands_exactly() {
|
||||
let (a, b) = (build("ellipse", 100.0, 100.0, &[]).unwrap(), build("star5", 200.0, 100.0, &[]).unwrap());
|
||||
let m = GeometryMorph::new(&a, &b);
|
||||
let one = Vec2::new(1.0, 1.0);
|
||||
assert!(near(bbox(&m.at(0.0, one, one).0), bbox(&a)));
|
||||
assert!(near(bbox(&m.at(1.0, one, one).0), bbox(&b)));
|
||||
let mid = m.at(0.5, one, one).0;
|
||||
assert_eq!(mid.paths.len(), 1);
|
||||
assert!(mid.paths[0].path.elements().iter().all(|e| e.end_point().is_none_or(|p| p.is_finite())));
|
||||
// Non-finite progress is the start.
|
||||
assert!(near(bbox(&m.at(f64::NAN, one, one).0), bbox(&a)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangle_does_not_twist_into_a_circle() {
|
||||
let (a, b) = (build("triangle", 100.0, 100.0, &[]).unwrap(), build("ellipse", 100.0, 100.0, &[]).unwrap());
|
||||
let ca = contours(&a.paths[0].path);
|
||||
let cb = contours(&b.paths[0].path);
|
||||
let (x, y) = align(&ca, &cb, None, None);
|
||||
// Every point travels less than the shapes are wide: no point crosses to the other side.
|
||||
let far = x[0].pts.iter().zip(&y[0].pts).map(|(p, q)| p.distance(*q)).fold(0.0, f64::max);
|
||||
assert!(far < 60.0, "{far}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_sub_paths_grow_out_of_a_point() {
|
||||
let (a, b) = (build("rect", 100.0, 100.0, &[]).unwrap(), build("cube", 100.0, 100.0, &[]).unwrap());
|
||||
assert!(b.paths.len() > 1);
|
||||
let m = GeometryMorph::new(&a, &b);
|
||||
let one = Vec2::new(1.0, 1.0);
|
||||
let start = m.at(0.0, one, one).0;
|
||||
assert_eq!(start.paths.len(), b.paths.len());
|
||||
for sp in start.paths.iter().skip(1) {
|
||||
let r = sp.path.bounding_box();
|
||||
assert!(r.width() < 1e-9 && r.height() < 1e-9, "{r:?}");
|
||||
}
|
||||
// Looks switch at the midpoint: the cube's shaded faces show from there on.
|
||||
assert_eq!(m.at(1.0, one, one).0.paths.iter().map(|s| s.fill).collect::<Vec<_>>(), b.paths.iter().map(|s| s.fill).collect::<Vec<_>>());
|
||||
// And the other way round, the faces shrink away.
|
||||
let back = GeometryMorph::new(&b, &a).at(1.0, one, one).0;
|
||||
assert!(back.paths.iter().skip(1).all(|sp| sp.path.bounding_box().area() < 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn differing_looks_cross_fade() {
|
||||
let one = Vec2::new(1.0, 1.0);
|
||||
let fades = |a: &Geometry, b: &Geometry, t: f64| {
|
||||
let (g, f) = GeometryMorph::new(a, b).at(t, one, one);
|
||||
g.paths.iter().zip(f).map(|(s, f)| (s.fill, s.even_odd, s.stroke, f.fill, f.stroke)).collect::<Vec<_>>()
|
||||
};
|
||||
// A filled rectangle into a line: the fill fades out, the outline stays.
|
||||
let (r, line) = (build("rect", 100.0, 100.0, &[]).unwrap(), build("line", 100.0, 0.0, &[]).unwrap());
|
||||
assert_eq!(fades(&r, &line, 0.25), [(FillMode::Norm, false, true, 0.75, 1.0)]);
|
||||
assert_eq!(fades(&line, &r, 0.25), [(FillMode::Norm, false, true, 0.25, 1.0)]);
|
||||
// A donut into a circle: the donut (even-odd) lies underneath, the circle fades in over its hole.
|
||||
let (d, e) = (build("donut", 100.0, 100.0, &[]).unwrap(), build("ellipse", 100.0, 100.0, &[]).unwrap());
|
||||
assert!(d.paths[0].even_odd && !e.paths[0].even_odd);
|
||||
assert_eq!(fades(&d, &e, 0.25), [(FillMode::Norm, true, true, 1.0, 1.0), (FillMode::Norm, false, false, 0.25, 0.0)]);
|
||||
assert_eq!(fades(&e, &d, 0.25), [(FillMode::Norm, true, true, 1.0, 1.0), (FillMode::Norm, false, false, 0.75, 0.0)]);
|
||||
// Same looks: one layer, fully there.
|
||||
assert_eq!(fades(&e, &r, 0.5), [(FillMode::Norm, false, true, 1.0, 1.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scales_bring_both_sides_into_the_current_box() {
|
||||
let (a, b) = (build("rect", 10.0, 10.0, &[]).unwrap(), build("ellipse", 40.0, 20.0, &[]).unwrap());
|
||||
let m = GeometryMorph::new(&a, &b);
|
||||
// Current box 20 x 20: a scaled up twice, b to half the width.
|
||||
let g = m.at(0.0, Vec2::new(2.0, 2.0), Vec2::new(0.5, 1.0)).0;
|
||||
assert!(near(bbox(&g), Rect::new(0.0, 0.0, 20.0, 20.0)));
|
||||
assert!(near(g.text_rect, Rect::new(a.text_rect.x0 * 2.0, a.text_rect.y0 * 2.0, a.text_rect.x1 * 2.0, a.text_rect.y1 * 2.0)));
|
||||
let g = m.at(1.0, Vec2::new(2.0, 2.0), Vec2::new(0.5, 1.0)).0;
|
||||
assert!(near(bbox(&g), Rect::new(0.0, 0.0, 20.0, 20.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_degenerate_geometries_do_not_crash() {
|
||||
let empty = Geometry { paths: vec![], text_rect: Rect::ZERO, handles: vec![], sites: vec![] };
|
||||
let r = build("rect", 10.0, 10.0, &[]).unwrap();
|
||||
let line = build("line", 10.0, 0.0, &[]).unwrap();
|
||||
let mut nan = BezPath::new();
|
||||
nan.move_to((f64::NAN, 0.0));
|
||||
nan.line_to((1.0, 1.0));
|
||||
let bad = Geometry { paths: vec![SubPath { path: nan, fill: FillMode::Norm, stroke: true, even_odd: false }], ..empty.clone() };
|
||||
for (x, y) in [(&empty, &empty), (&empty, &r), (&r, &empty), (&r, &line), (&line, &r), (&bad, &r), (&r, &bad)] {
|
||||
for t in [0.0, 0.5, 1.0, f64::INFINITY] {
|
||||
let g = GeometryMorph::new(x, y).at(t, Vec2::new(1.0, 1.0), Vec2::new(0.0, 1e300)).0;
|
||||
// At most two layers per sub-path pair.
|
||||
assert!(g.paths.len() <= 2 * x.paths.len().max(y.paths.len()));
|
||||
}
|
||||
}
|
||||
// A rectangle into a line: the line is open, so the morph ends open.
|
||||
let g = GeometryMorph::new(&r, &line).at(1.0, Vec2::new(1.0, 1.0), Vec2::new(1.0, 1.0)).0;
|
||||
assert!(!g.paths[0].path.elements().iter().any(|e| matches!(e, PathEl::ClosePath)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_contours_sample_their_start_points() {
|
||||
// A polygon with many corners still aligns (and quickly): the start search is capped.
|
||||
let mut p = BezPath::new();
|
||||
for k in 0..2000 {
|
||||
let a = k as f64 / 2000.0 * std::f64::consts::TAU;
|
||||
let q = (50.0 + 50.0 * a.cos(), 50.0 + 50.0 * a.sin());
|
||||
if k == 0 { p.move_to(q) } else { p.line_to(q) }
|
||||
}
|
||||
p.close_path();
|
||||
let many = contours(&p);
|
||||
let e = contours(&build("ellipse", 100.0, 100.0, &[]).unwrap().paths[0].path);
|
||||
let (x, y) = align(&many, &e, None, None);
|
||||
assert_eq!(x[0].curves(), y[0].curves());
|
||||
assert_eq!(x[0].curves(), 2000);
|
||||
}
|
||||
}
|
||||
+47
-12
@@ -150,7 +150,7 @@ pub struct HrdParameters {
|
||||
}
|
||||
|
||||
fn parse_hrd(r: &mut BitReader) -> Result<HrdParameters> {
|
||||
let cpb_cnt = r.read_ue()? + 1;
|
||||
let cpb_cnt = r.read_ue_plus(1)?;
|
||||
ensure!(cpb_cnt <= 32, "cpb_cnt_minus1 out of range");
|
||||
let mut h = HrdParameters { cpb_cnt, bit_rate_scale: r.read_bits(4)? as u8, cpb_size_scale: r.read_bits(4)? as u8, ..Default::default() };
|
||||
for _ in 0..cpb_cnt {
|
||||
@@ -353,8 +353,8 @@ impl Sps {
|
||||
if chroma_format_idc == 3 {
|
||||
separate_colour_plane = r.read_flag()?;
|
||||
}
|
||||
bit_depth_luma = r.read_ue()? + 8;
|
||||
bit_depth_chroma = r.read_ue()? + 8;
|
||||
bit_depth_luma = r.read_ue_plus(8)?;
|
||||
bit_depth_chroma = r.read_ue_plus(8)?;
|
||||
ensure!(bit_depth_luma <= 14 && bit_depth_chroma <= 14, "bit depth out of range");
|
||||
bypass = r.read_flag()?;
|
||||
seq_scaling_matrix_present = r.read_flag()?;
|
||||
@@ -363,7 +363,7 @@ impl Sps {
|
||||
scaling_lists = parse_scaling_lists(&mut r, count, None)?;
|
||||
}
|
||||
}
|
||||
let log2_max_frame_num = r.read_ue()? + 4;
|
||||
let log2_max_frame_num = r.read_ue_plus(4)?;
|
||||
ensure!(log2_max_frame_num <= 16, "log2_max_frame_num out of range");
|
||||
let pic_order_cnt_type = r.read_ue()?;
|
||||
ensure!(pic_order_cnt_type <= 2, "pic_order_cnt_type out of range");
|
||||
@@ -373,7 +373,7 @@ impl Sps {
|
||||
let mut offset_for_top_to_bottom_field = 0;
|
||||
let mut offset_for_ref_frame = Vec::new();
|
||||
if pic_order_cnt_type == 0 {
|
||||
log2_max_poc_lsb = r.read_ue()? + 4;
|
||||
log2_max_poc_lsb = r.read_ue_plus(4)?;
|
||||
ensure!(log2_max_poc_lsb <= 16, "log2_max_pic_order_cnt_lsb out of range");
|
||||
} else if pic_order_cnt_type == 1 {
|
||||
delta_pic_order_always_zero = r.read_flag()?;
|
||||
@@ -388,8 +388,8 @@ impl Sps {
|
||||
let max_num_ref_frames = r.read_ue()?;
|
||||
ensure!(max_num_ref_frames <= 16, "max_num_ref_frames out of range");
|
||||
let gaps_in_frame_num_allowed = r.read_flag()?;
|
||||
let pic_width_in_mbs = r.read_ue()? + 1;
|
||||
let pic_height_in_map_units = r.read_ue()? + 1;
|
||||
let pic_width_in_mbs = r.read_ue_plus(1)?;
|
||||
let pic_height_in_map_units = r.read_ue_plus(1)?;
|
||||
ensure!(pic_width_in_mbs <= 1024 && pic_height_in_map_units <= 1024, "picture too large");
|
||||
let frame_mbs_only = r.read_flag()?;
|
||||
let mb_adaptive_frame_field = if !frame_mbs_only { r.read_flag()? } else { false };
|
||||
@@ -429,7 +429,8 @@ impl Sps {
|
||||
};
|
||||
if let Some((l, rr, t, b)) = sps.frame_crop {
|
||||
let (cx, cy) = sps.crop_unit();
|
||||
ensure!((l + rr) * cx < sps.width() && (t + b) * cy < sps.height(), "frame cropping exceeds picture size");
|
||||
let crop = |a: u32, b: u32, unit: u32| (u64::from(a) + u64::from(b)) * u64::from(unit);
|
||||
ensure!(crop(l, rr, cx) < u64::from(sps.width()) && crop(t, b, cy) < u64::from(sps.height()), "frame cropping exceeds picture size");
|
||||
}
|
||||
Ok(sps)
|
||||
}
|
||||
@@ -572,7 +573,7 @@ impl Pps {
|
||||
.ok_or_else(|| crate::Error::MissingParameterSet(format!("SPS {sps_id} for PPS {id}")))?;
|
||||
let entropy_coding_mode = r.read_flag()?;
|
||||
let bottom_field_pic_order_in_frame_present = r.read_flag()?;
|
||||
let num_slice_groups = r.read_ue()? + 1;
|
||||
let num_slice_groups = r.read_ue_plus(1)?;
|
||||
ensure!(num_slice_groups <= 8, "num_slice_groups out of range");
|
||||
let mut slice_group_map_type = 0;
|
||||
if num_slice_groups > 1 {
|
||||
@@ -595,7 +596,7 @@ impl Pps {
|
||||
r.read_ue()?;
|
||||
}
|
||||
6 => {
|
||||
let n = r.read_ue()? + 1;
|
||||
let n = r.read_ue_plus(1)?;
|
||||
let bits = 32 - (num_slice_groups - 1).leading_zeros();
|
||||
for _ in 0..n {
|
||||
r.read_bits(bits)?;
|
||||
@@ -604,8 +605,8 @@ impl Pps {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let num_ref_idx_l0_default_active = r.read_ue()? + 1;
|
||||
let num_ref_idx_l1_default_active = r.read_ue()? + 1;
|
||||
let num_ref_idx_l0_default_active = r.read_ue_plus(1)?;
|
||||
let num_ref_idx_l1_default_active = r.read_ue_plus(1)?;
|
||||
ensure!(num_ref_idx_l0_default_active <= 32 && num_ref_idx_l1_default_active <= 32, "num_ref_idx_default_active out of range");
|
||||
let weighted_pred = r.read_flag()?;
|
||||
let weighted_bipred_idc = r.read_bits(2)?;
|
||||
@@ -716,4 +717,38 @@ mod tests {
|
||||
assert_eq!(sps.pic_order_cnt_type, 2);
|
||||
assert_eq!(sps.crop_rect(), (0, 0, 176, 144));
|
||||
}
|
||||
|
||||
/// The baseline SPS of `parse_minimal_baseline_sps` with a chosen width and optional cropping.
|
||||
fn sps_with(width_minus1: u32, crop: Option<[u32; 4]>) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(66, 8);
|
||||
w.write_bits(0xC0, 8);
|
||||
w.write_bits(30, 8);
|
||||
w.write_ue(0);
|
||||
w.write_ue(0);
|
||||
w.write_ue(2);
|
||||
w.write_ue(1);
|
||||
w.write_bit(false);
|
||||
w.write_ue(width_minus1);
|
||||
w.write_ue(8);
|
||||
w.write_bit(true); // frame_mbs_only
|
||||
w.write_bit(true); // direct_8x8
|
||||
w.write_bit(crop.is_some());
|
||||
if let Some(c) = crop {
|
||||
c.iter().for_each(|&v| w.write_ue(v));
|
||||
}
|
||||
w.write_bit(false); // vui
|
||||
w.rbsp_trailing();
|
||||
w.finish()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sps_values_are_errors_not_overflows() {
|
||||
// pic_width_in_mbs_minus1 = u32::MAX: `+ 1` used to overflow (wrapping to 0 in release).
|
||||
assert!(Sps::parse(&sps_with(u32::MAX, None)).is_err());
|
||||
// Crop offsets whose sum or product exceed u32.
|
||||
assert!(Sps::parse(&sps_with(10, Some([u32::MAX - 1, u32::MAX - 1, 0, 0]))).is_err());
|
||||
assert!(Sps::parse(&sps_with(10, Some([0x8000_0000, 0, 0, 0]))).is_err());
|
||||
assert!(Sps::parse(&sps_with(10, Some([1, 1, 1, 1]))).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,9 +199,9 @@ impl SliceHeader {
|
||||
let mut num_ref_idx_active = [pps.num_ref_idx_l0_default_active, pps.num_ref_idx_l1_default_active];
|
||||
if !slice_type.is_intra() {
|
||||
if r.read_flag()? {
|
||||
num_ref_idx_active[0] = r.read_ue()? + 1;
|
||||
num_ref_idx_active[0] = r.read_ue_plus(1)?;
|
||||
if slice_type.is_b() {
|
||||
num_ref_idx_active[1] = r.read_ue()? + 1;
|
||||
num_ref_idx_active[1] = r.read_ue_plus(1)?;
|
||||
}
|
||||
}
|
||||
let max = if field_pic { 32 } else { 16 };
|
||||
|
||||
+92
-25
@@ -97,7 +97,7 @@ pub fn skip_hrd(r: &mut BitReader, common: bool, max_sub_layers_minus1: u32) ->
|
||||
}
|
||||
let mut cpb_cnt = 1;
|
||||
if !low_delay {
|
||||
cpb_cnt = r.read_ue()? + 1;
|
||||
cpb_cnt = r.read_ue_plus(1)?;
|
||||
ensure!(cpb_cnt <= 32, "cpb_cnt_minus1 out of range");
|
||||
}
|
||||
if nal {
|
||||
@@ -259,7 +259,7 @@ impl ScalingList {
|
||||
while m < 6 {
|
||||
let coef_num = if size == 0 { 16 } else { 64 };
|
||||
if !r.read_flag()? {
|
||||
let delta = r.read_ue()? as usize * step;
|
||||
let delta = (r.read_ue()? as usize).saturating_mul(step);
|
||||
ensure!(delta <= m, "scaling_list_pred_matrix_id_delta out of range");
|
||||
if delta == 0 {
|
||||
// default list
|
||||
@@ -349,12 +349,13 @@ impl StRps {
|
||||
pub fn parse(r: &mut BitReader, idx: usize, sets: &[StRps], num_sets: usize) -> Result<StRps> {
|
||||
let inter = if idx != 0 { r.read_flag()? } else { false };
|
||||
if inter {
|
||||
let delta_idx = if idx == num_sets { r.read_ue()? as usize + 1 } else { 1 };
|
||||
let delta_idx = if idx == num_sets { r.read_ue_plus(1)? as usize } else { 1 };
|
||||
ensure!(delta_idx <= idx, "delta_idx_minus1 out of range");
|
||||
let rf = &sets[idx - delta_idx];
|
||||
let sign = r.read_flag()?;
|
||||
let abs = r.read_ue()? as i32 + 1;
|
||||
let abs = r.read_ue_plus(1)?;
|
||||
ensure!(abs <= 1 << 15, "abs_delta_rps_minus1 out of range");
|
||||
let abs = abs as i32;
|
||||
let delta_rps = if sign { -abs } else { abs };
|
||||
let n = rf.num_delta_pocs();
|
||||
let mut used = vec![false; n + 1];
|
||||
@@ -406,15 +407,17 @@ impl StRps {
|
||||
let mut out = StRps::default();
|
||||
let mut poc = 0i32;
|
||||
for _ in 0..nneg {
|
||||
let d = r.read_ue()? as i32 + 1;
|
||||
let d = r.read_ue_plus(1)?;
|
||||
ensure!(d <= 1 << 15, "delta_poc_s0_minus1 out of range");
|
||||
let d = d as i32;
|
||||
poc -= d;
|
||||
out.s0.push((poc, r.read_flag()?));
|
||||
}
|
||||
poc = 0;
|
||||
for _ in 0..npos {
|
||||
let d = r.read_ue()? as i32 + 1;
|
||||
let d = r.read_ue_plus(1)?;
|
||||
ensure!(d <= 1 << 15, "delta_poc_s1_minus1 out of range");
|
||||
let d = d as i32;
|
||||
poc += d;
|
||||
out.s1.push((poc, r.read_flag()?));
|
||||
}
|
||||
@@ -491,25 +494,25 @@ impl Sps {
|
||||
if r.read_flag()? {
|
||||
conf_win = (r.read_ue()?, r.read_ue()?, r.read_ue()?, r.read_ue()?);
|
||||
}
|
||||
let bit_depth_luma = r.read_ue()? + 8;
|
||||
let bit_depth_chroma = r.read_ue()? + 8;
|
||||
let bit_depth_luma = r.read_ue_plus(8)?;
|
||||
let bit_depth_chroma = r.read_ue_plus(8)?;
|
||||
ensure!(bit_depth_luma <= 16 && bit_depth_chroma <= 16, "bit depth out of range");
|
||||
let log2_max_poc_lsb = r.read_ue()? + 4;
|
||||
let log2_max_poc_lsb = r.read_ue_plus(4)?;
|
||||
ensure!(log2_max_poc_lsb <= 16, "log2_max_pic_order_cnt_lsb_minus4 out of range");
|
||||
let ordering_all = r.read_flag()?;
|
||||
let (mut dpb, mut reorder, mut latency) = (1, 0, 0);
|
||||
let start = if ordering_all { 0 } else { max_sub_layers_minus1 };
|
||||
for _ in start..=max_sub_layers_minus1 {
|
||||
// the values of the highest sub-layer are used
|
||||
dpb = r.read_ue()? + 1;
|
||||
dpb = r.read_ue_plus(1)?;
|
||||
reorder = r.read_ue()?;
|
||||
latency = r.read_ue()?;
|
||||
}
|
||||
ensure!(dpb <= 16 && reorder <= 16, "sps_max_dec_pic_buffering out of range");
|
||||
let log2_min_cb = r.read_ue()? + 3;
|
||||
let log2_ctb = log2_min_cb + r.read_ue()?;
|
||||
let log2_min_tb = r.read_ue()? + 2;
|
||||
let log2_max_tb = log2_min_tb + r.read_ue()?;
|
||||
let log2_min_cb = r.read_ue_plus(3)?;
|
||||
let log2_ctb = log2_min_cb.saturating_add(r.read_ue()?);
|
||||
let log2_min_tb = r.read_ue_plus(2)?;
|
||||
let log2_max_tb = log2_min_tb.saturating_add(r.read_ue()?);
|
||||
ensure!((4..=6).contains(&log2_ctb) && log2_min_cb <= log2_ctb, "CTB size out of range");
|
||||
ensure!(log2_min_tb < log2_min_cb && log2_max_tb <= log2_ctb.min(5), "transform block sizes out of range");
|
||||
ensure!(width % (1 << log2_min_cb) == 0 && height % (1 << log2_min_cb) == 0, "picture size not a multiple of MinCbSizeY");
|
||||
@@ -532,8 +535,8 @@ impl Sps {
|
||||
if pcm {
|
||||
pcm_bit_depth_luma = r.read_bits(4)? + 1;
|
||||
pcm_bit_depth_chroma = r.read_bits(4)? + 1;
|
||||
log2_min_pcm = r.read_ue()? + 3;
|
||||
log2_max_pcm = log2_min_pcm + r.read_ue()?;
|
||||
log2_min_pcm = r.read_ue_plus(3)?;
|
||||
log2_max_pcm = log2_min_pcm.saturating_add(r.read_ue()?);
|
||||
pcm_loop_filter_disabled = r.read_flag()?;
|
||||
ensure!(pcm_bit_depth_luma <= bit_depth_luma && pcm_bit_depth_chroma <= bit_depth_chroma, "PCM bit depth out of range");
|
||||
ensure!(log2_max_pcm <= log2_ctb.min(5), "PCM size out of range");
|
||||
@@ -637,10 +640,10 @@ impl Sps {
|
||||
pub fn crop_rect(&self) -> (u32, u32, u32, u32) {
|
||||
let (l, r, t, b) = self.conf_win;
|
||||
let (sw, sh) = (self.sub_width_c(), self.sub_height_c());
|
||||
let x = (l * sw).min(self.width - 1);
|
||||
let y = (t * sh).min(self.height - 1);
|
||||
let w = self.width.saturating_sub(x + r * sw).max(1);
|
||||
let h = self.height.saturating_sub(y + b * sh).max(1);
|
||||
let x = l.saturating_mul(sw).min(self.width - 1);
|
||||
let y = t.saturating_mul(sh).min(self.height - 1);
|
||||
let w = self.width.saturating_sub(x.saturating_add(r.saturating_mul(sw))).max(1);
|
||||
let h = self.height.saturating_sub(y.saturating_add(b.saturating_mul(sh))).max(1);
|
||||
(x, y, w, h)
|
||||
}
|
||||
|
||||
@@ -720,8 +723,8 @@ impl Pps {
|
||||
let num_extra_slice_header_bits = r.read_bits(3)?;
|
||||
let sign_data_hiding = r.read_flag()?;
|
||||
let cabac_init_present = r.read_flag()?;
|
||||
let num_ref_idx_l0_default = r.read_ue()? + 1;
|
||||
let num_ref_idx_l1_default = r.read_ue()? + 1;
|
||||
let num_ref_idx_l0_default = r.read_ue_plus(1)?;
|
||||
let num_ref_idx_l1_default = r.read_ue_plus(1)?;
|
||||
ensure!(num_ref_idx_l0_default <= 15 && num_ref_idx_l1_default <= 15, "num_ref_idx_default_active out of range");
|
||||
let init_qp = 26 + r.read_se()?;
|
||||
let constrained_intra_pred = r.read_flag()?;
|
||||
@@ -742,8 +745,8 @@ impl Pps {
|
||||
let (mut column_widths, mut row_heights) = (Vec::new(), Vec::new());
|
||||
let mut loop_filter_across_tiles = true;
|
||||
if tiles_enabled {
|
||||
num_tile_columns = r.read_ue()? + 1;
|
||||
num_tile_rows = r.read_ue()? + 1;
|
||||
num_tile_columns = r.read_ue_plus(1)?;
|
||||
num_tile_rows = r.read_ue_plus(1)?;
|
||||
ensure!(num_tile_columns <= 64 && num_tile_rows <= 64, "too many tiles");
|
||||
uniform_spacing = r.read_flag()?;
|
||||
if !uniform_spacing {
|
||||
@@ -770,7 +773,7 @@ impl Pps {
|
||||
}
|
||||
let scaling_list = if r.read_flag()? { Some(ScalingList::parse(&mut r)?) } else { None };
|
||||
let lists_modification_present = r.read_flag()?;
|
||||
let log2_parallel_merge_level = r.read_ue()? + 2;
|
||||
let log2_parallel_merge_level = r.read_ue_plus(2)?;
|
||||
ensure!(log2_parallel_merge_level <= 6, "log2_parallel_merge_level out of range");
|
||||
let slice_header_extension_present = r.read_flag()?;
|
||||
let mut range_extension = false;
|
||||
@@ -933,3 +936,67 @@ impl Layout {
|
||||
self.tile_id[self.rs_to_ts[rs as usize] as usize]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_bitstream::BitWriter;
|
||||
|
||||
/// A 64x64 Main-profile SPS (CTB 64, no VUI) with a chosen conformance window and CTB-size delta.
|
||||
fn sps_bits(conf_win: Option<[u32; 4]>, log2_diff_cb: u32) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(0, 4); // vps id
|
||||
w.write_bits(0, 3); // max_sub_layers_minus1
|
||||
w.write_bit(true); // temporal_id_nesting
|
||||
w.write_bits(0, 2); // profile_space
|
||||
w.write_bit(false); // tier
|
||||
w.write_bits(1, 5); // profile_idc
|
||||
w.write_bits(0x6000_0000, 32); // compatibility
|
||||
w.write_bits(0b1001, 4); // progressive, interlaced, non_packed, frame_only
|
||||
w.write_bits(0, 32);
|
||||
w.write_bits(0, 12); // 43 constraint bits + 1 reserved
|
||||
w.write_bits(93, 8); // level_idc
|
||||
[0, 1, 64, 64].iter().for_each(|&v| w.write_ue(v)); // sps id, chroma 4:2:0, width, height
|
||||
w.write_bit(conf_win.is_some());
|
||||
conf_win.iter().flatten().for_each(|&v| w.write_ue(v));
|
||||
[0, 0, 4].iter().for_each(|&v| w.write_ue(v)); // bit depths, log2_max_poc_lsb_minus4
|
||||
w.write_bit(true); // sub_layer_ordering_info_present
|
||||
[0, 0, 0].iter().for_each(|&v| w.write_ue(v)); // dpb, reorder, latency
|
||||
w.write_ue(0); // log2_min_cb_minus3
|
||||
w.write_ue(log2_diff_cb);
|
||||
w.write_ue(0); // log2_min_tb_minus2
|
||||
w.write_ue(3); // log2_diff_max_min_tb
|
||||
w.write_ue(1); // max_transform_hierarchy_depth_inter
|
||||
w.write_ue(1); // ... intra
|
||||
[false; 4].iter().for_each(|&b| w.write_bit(b)); // scaling list, amp, sao, pcm
|
||||
w.write_ue(0); // num_short_term_ref_pic_sets
|
||||
[false; 5].iter().for_each(|&b| w.write_bit(b)); // long term, temporal mvp, strong intra, vui, extension
|
||||
w.rbsp_trailing();
|
||||
w.finish()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sps_values_are_errors_not_overflows() {
|
||||
assert!(Sps::parse(&sps_bits(None, 3)).is_ok());
|
||||
// log2_diff_max_min_luma_coding_block_size = u32::MAX: `log2_min_cb + ue` used to overflow.
|
||||
assert!(Sps::parse(&sps_bits(None, u32::MAX)).is_err());
|
||||
// Conformance window offsets whose product with SubWidthC / SubHeightC exceeds u32.
|
||||
let sps = Sps::parse(&sps_bits(Some([u32::MAX; 4]), 3)).unwrap();
|
||||
let (x, y, w, h) = sps.crop_rect();
|
||||
assert!(x < 64 && y < 64 && w >= 1 && h >= 1, "{:?}", (x, y, w, h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_st_rps_deltas_are_errors_not_overflows() {
|
||||
for ue in [0x7FFF_FFFF, 0x8000_0000, u32::MAX] {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_ue(1); // num_negative_pics
|
||||
w.write_ue(0); // num_positive_pics
|
||||
w.write_ue(ue); // delta_poc_s0_minus1
|
||||
w.write_bit(true);
|
||||
w.rbsp_trailing();
|
||||
let b = w.finish();
|
||||
assert!(StRps::parse(&mut BitReader::new(&b), 0, &[], 1).is_err(), "ue {ue:#x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,9 +293,9 @@ impl SliceHeader {
|
||||
if sh.slice_type != SliceType::I {
|
||||
sh.num_ref_idx = [pps.num_ref_idx_l0_default, if sh.is_b() { pps.num_ref_idx_l1_default } else { 0 }];
|
||||
if r.read_flag()? {
|
||||
sh.num_ref_idx[0] = r.read_ue()? + 1;
|
||||
sh.num_ref_idx[0] = r.read_ue_plus(1)?;
|
||||
if sh.is_b() {
|
||||
sh.num_ref_idx[1] = r.read_ue()? + 1;
|
||||
sh.num_ref_idx[1] = r.read_ue_plus(1)?;
|
||||
}
|
||||
}
|
||||
ensure!(sh.num_ref_idx[0] <= 15 && sh.num_ref_idx[1] <= 15, "num_ref_idx_active out of range");
|
||||
@@ -365,7 +365,7 @@ impl SliceHeader {
|
||||
let n = r.read_ue()?;
|
||||
ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range");
|
||||
if n > 0 {
|
||||
let len = r.read_ue()? + 1;
|
||||
let len = r.read_ue_plus(1)?;
|
||||
ensure!(len <= 32, "offset_len_minus1 out of range");
|
||||
for _ in 0..n {
|
||||
sh.entry_points.push(r.read_bits(len)? + 1);
|
||||
|
||||
@@ -89,3 +89,31 @@ fn errors_are_reported_not_panics() {
|
||||
let r = call(&mut s, 3, "tools/call", json!({"name": "add_shape", "arguments": {"preset": "rect"}}));
|
||||
assert_eq!(r["result"]["isError"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_tool_lists_pdf_and_its_options() {
|
||||
let mut s = Server::new(Box::new(Headless::new()));
|
||||
let tools = call(&mut s, 1, "tools/list", json!({}));
|
||||
let export = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "export").unwrap().clone();
|
||||
assert!(export["description"].as_str().unwrap().contains("pdf"), "{export}");
|
||||
let props = &export["inputSchema"]["properties"];
|
||||
let formats = props["format"]["enum"].as_array().unwrap();
|
||||
for f in ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf"] {
|
||||
assert!(formats.iter().any(|x| x == f), "format {f}: {export}");
|
||||
}
|
||||
assert_eq!(props["layout"]["enum"], json!(["slides", "notes", "handouts"]));
|
||||
assert_eq!(props["perPage"]["enum"], json!([1, 2, 3, 4, 6, 9]));
|
||||
for p in ["dpi", "slides", "includeHidden", "textLayer", "frame"] {
|
||||
assert!(props.get(p).is_some(), "{p}: {export}");
|
||||
}
|
||||
// What the schema describes is accepted.
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-mcp-export-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
tool(&mut s, "new_presentation", json!({}));
|
||||
for layout in ["slides", "notes", "handouts"] {
|
||||
let path = dir.join(format!("{layout}.pdf"));
|
||||
tool(&mut s, "export", json!({"path": path.to_string_lossy(), "format": "pdf", "layout": layout, "perPage": 3}));
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF"), "{layout}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
+15
-2
@@ -115,9 +115,22 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"export",
|
||||
"Export",
|
||||
"Export slides: png/jpeg (one slide, or all with all:true), pptx, outline text.",
|
||||
"Export slides: png/jpeg (one slide, or all with all:true), pptx, deckcraft, outline text, or pdf (slides, notes pages or handouts).",
|
||||
obj(
|
||||
json!({"path": string("Output path"), "format": string("png|jpeg|pptx|deckcraft|outline"), "slide": int("Slide index for images"), "all": boolean("All slides (images)"), "scale": num("Pixels per point for images (default 2)")}),
|
||||
json!({
|
||||
"path": string("Output path"),
|
||||
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf"], "description": "Output format (default: from the path's extension)"},
|
||||
"slide": int("Slide index for images"),
|
||||
"all": boolean("All slides (images)"),
|
||||
"scale": num("Pixels per point for images (default 2)"),
|
||||
"layout": {"type": "string", "enum": ["slides", "notes", "handouts"], "description": "PDF page layout (default slides)"},
|
||||
"perPage": {"type": "integer", "enum": [1, 2, 3, 4, 6, 9], "description": "PDF handouts: slides per page (default 6)"},
|
||||
"dpi": num("PDF: raster resolution of slide artwork, 36-600 (default 200)"),
|
||||
"slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF: slide indices to export (default all)"},
|
||||
"includeHidden": boolean("PDF: include hidden slides (default false)"),
|
||||
"textLayer": boolean("PDF: invisible text layer for search, select and copy (default true)"),
|
||||
"frame": boolean("PDF notes/handouts: thin frame around each slide (default true)")
|
||||
}),
|
||||
&["path"],
|
||||
),
|
||||
false,
|
||||
|
||||
@@ -243,6 +243,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
background: None,
|
||||
show_master_shapes: true,
|
||||
preserve: true,
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
});
|
||||
};
|
||||
|
||||
+35
-15
@@ -488,6 +488,10 @@ pub struct Slide {
|
||||
/// Speaker notes.
|
||||
pub notes: TextBody,
|
||||
pub comments: Vec<Comment>,
|
||||
/// `clrMapOvr`: a colour map replacing the layout's or master's (`bg1`→`dk1`…); None follows
|
||||
/// the layout.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color_map: Option<Vec<(String, String)>>,
|
||||
/// Unmodelled XML kept for round-trip (custom data, tags).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_ext: Option<String>,
|
||||
@@ -507,6 +511,7 @@ impl Default for Slide {
|
||||
animations: vec![],
|
||||
notes: TextBody::default(),
|
||||
comments: vec![],
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
}
|
||||
}
|
||||
@@ -596,6 +601,10 @@ pub struct Layout {
|
||||
pub background: Option<Background>,
|
||||
pub show_master_shapes: bool,
|
||||
pub preserve: bool,
|
||||
/// `clrMapOvr`: a colour map replacing the master's for this layout and the slides using it
|
||||
/// (dark "section" layouts map `bg1` to `dk1`); None follows the master.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color_map: Option<Vec<(String, String)>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_ext: Option<String>,
|
||||
}
|
||||
@@ -641,24 +650,17 @@ impl Master {
|
||||
}
|
||||
/// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map.
|
||||
pub fn map_slot(&self, slot: SchemeSlot) -> SchemeSlot {
|
||||
let key = match slot {
|
||||
SchemeSlot::Bg1 => "bg1",
|
||||
SchemeSlot::Tx1 => "tx1",
|
||||
SchemeSlot::Bg2 => "bg2",
|
||||
SchemeSlot::Tx2 => "tx2",
|
||||
other => return other,
|
||||
};
|
||||
self.color_map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot {
|
||||
SchemeSlot::Bg1 => SchemeSlot::Lt1,
|
||||
SchemeSlot::Tx1 => SchemeSlot::Dk1,
|
||||
SchemeSlot::Bg2 => SchemeSlot::Lt2,
|
||||
_ => SchemeSlot::Dk2,
|
||||
})
|
||||
map_slot(&self.color_map, slot)
|
||||
}
|
||||
/// The theme's colour scheme with the colour map applied (`bg1`… resolve to mapped colours).
|
||||
pub fn scheme(&self) -> ColorScheme {
|
||||
self.scheme_with(&self.color_map)
|
||||
}
|
||||
/// The theme's colour scheme with `map` (a layout's or slide's override, or the master's own)
|
||||
/// applied.
|
||||
pub fn scheme_with(&self, map: &[(String, String)]) -> ColorScheme {
|
||||
let mut s = self.theme.colors.clone();
|
||||
if self.color_map.is_empty() {
|
||||
if map.is_empty() {
|
||||
return s;
|
||||
}
|
||||
let base = self.theme.colors.clone();
|
||||
@@ -668,12 +670,30 @@ impl Master {
|
||||
(SchemeSlot::Bg2, SchemeSlot::Lt2),
|
||||
(SchemeSlot::Tx2, SchemeSlot::Dk2),
|
||||
] {
|
||||
s.set(slot, base.get(self.map_slot(alias)));
|
||||
s.set(slot, base.get(map_slot(map, alias)));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map `map` (the default mapping when it has
|
||||
/// no entry).
|
||||
pub fn map_slot(map: &[(String, String)], slot: SchemeSlot) -> SchemeSlot {
|
||||
let key = match slot {
|
||||
SchemeSlot::Bg1 => "bg1",
|
||||
SchemeSlot::Tx1 => "tx1",
|
||||
SchemeSlot::Bg2 => "bg2",
|
||||
SchemeSlot::Tx2 => "tx2",
|
||||
other => return other,
|
||||
};
|
||||
map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot {
|
||||
SchemeSlot::Bg1 => SchemeSlot::Lt1,
|
||||
SchemeSlot::Tx1 => SchemeSlot::Dk1,
|
||||
SchemeSlot::Bg2 => SchemeSlot::Lt2,
|
||||
_ => SchemeSlot::Dk2,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Section {
|
||||
|
||||
@@ -22,23 +22,27 @@ pub struct Ctx<'a> {
|
||||
pub layout: Option<&'a Layout>,
|
||||
pub owner: Owner,
|
||||
pub scheme: ColorScheme,
|
||||
/// The colour map in force: the slide's override, else its layout's, else the master's.
|
||||
pub color_map: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
pub fn for_slide(pres: &'a Presentation, slide: &Slide) -> Option<Ctx<'a>> {
|
||||
let (master, layout) = pres.master_for(slide)?;
|
||||
Some(Ctx { pres, master, layout, owner: Owner::Slide, scheme: master.scheme() })
|
||||
let map = slide.color_map.as_ref().or(layout.and_then(|l| l.color_map.as_ref())).unwrap_or(&master.color_map).clone();
|
||||
Some(Ctx { pres, master, layout, owner: Owner::Slide, scheme: master.scheme_with(&map), color_map: map })
|
||||
}
|
||||
pub fn for_layout(pres: &'a Presentation, master: &'a Master, layout: &'a Layout) -> Ctx<'a> {
|
||||
Ctx { pres, master, layout: Some(layout), owner: Owner::Layout, scheme: master.scheme() }
|
||||
let map = layout.color_map.as_ref().unwrap_or(&master.color_map).clone();
|
||||
Ctx { pres, master, layout: Some(layout), owner: Owner::Layout, scheme: master.scheme_with(&map), color_map: map }
|
||||
}
|
||||
pub fn for_master(pres: &'a Presentation, master: &'a Master) -> Ctx<'a> {
|
||||
Ctx { pres, master, layout: None, owner: Owner::Master, scheme: master.scheme() }
|
||||
Ctx { pres, master, layout: None, owner: Owner::Master, scheme: master.scheme(), color_map: master.color_map.clone() }
|
||||
}
|
||||
pub fn color(&self, c: &ColorRef, ph: Option<Rgba>) -> Rgba {
|
||||
let c = match &c.base {
|
||||
crate::ColorBase::Scheme { slot } if matches!(slot, SchemeSlot::Bg1 | SchemeSlot::Tx1 | SchemeSlot::Bg2 | SchemeSlot::Tx2) => {
|
||||
ColorRef { base: crate::ColorBase::Scheme { slot: self.master.map_slot(*slot) }, mods: c.mods.clone() }
|
||||
ColorRef { base: crate::ColorBase::Scheme { slot: crate::map_slot(&self.color_map, *slot) }, mods: c.mods.clone() }
|
||||
}
|
||||
_ => c.clone(),
|
||||
};
|
||||
@@ -266,6 +270,17 @@ pub fn run(ctx: &Ctx, shape: &Shape, para: &crate::text::Paragraph, run: &RunPro
|
||||
let mut r = run.clone();
|
||||
let lvl = level_style(ctx, shape, para.level);
|
||||
r.inherit(&lvl.run);
|
||||
// Hyperlinked text is drawn in the theme's hyperlink colour and underlined, as in PowerPoint,
|
||||
// unless the run itself sets a colour (honoured since PowerPoint 2019) or an underline
|
||||
// (`u="none"` removes it). Colours and underlines from list styles don't count.
|
||||
if r.link.is_some() {
|
||||
if run.fill.is_none() {
|
||||
r.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink)));
|
||||
}
|
||||
if run.underline.is_none() {
|
||||
r.underline = Some("sng".into());
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
|
||||
@@ -132,3 +132,58 @@ fn four_by_three_master_scales() {
|
||||
let x = title.xfrm.unwrap();
|
||||
assert!(x.x + x.w <= 720.0 + 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlinked_runs_take_the_theme_hyperlink_look() {
|
||||
use crate::text::{Action, Hyperlink, Paragraph, RunProps};
|
||||
let p = Presentation::default();
|
||||
let s = &p.slides[0];
|
||||
let ctx = Ctx::for_slide(&p, s).unwrap();
|
||||
let title = &s.shapes[0];
|
||||
let para = Paragraph::default();
|
||||
let link = Some(Hyperlink { action: Action::Url { url: "https://example.org".into() }, tooltip: String::new(), highlight_click: false });
|
||||
let hlink = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink)));
|
||||
// The title's master style colours its text tx1; a link is hlink and underlined instead.
|
||||
assert_eq!(resolve::run(&ctx, title, ¶, &RunProps::default()).fill, Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))));
|
||||
let r = resolve::run(&ctx, title, ¶, &RunProps { link: link.clone(), ..Default::default() });
|
||||
assert_eq!((r.fill, r.underline.as_deref()), (hlink.clone(), Some("sng")));
|
||||
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Hlink), None), p.masters[0].theme.colors.get(SchemeSlot::Hlink));
|
||||
// A slide link looks the same.
|
||||
let slide = Some(Hyperlink { action: Action::Slide { slide: s.id }, tooltip: String::new(), highlight_click: false });
|
||||
assert_eq!(resolve::run(&ctx, title, ¶, &RunProps { link: slide, ..Default::default() }).fill, hlink);
|
||||
// The run's own colour and underline win; u="none" removes the underline.
|
||||
let red = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
|
||||
let own = RunProps { link: link.clone(), fill: red.clone(), underline: Some("none".into()), ..Default::default() };
|
||||
let r = resolve::run(&ctx, title, ¶, &own);
|
||||
assert_eq!((r.fill, r.underline.as_deref()), (red, Some("none")));
|
||||
let dbl = resolve::run(&ctx, title, ¶, &RunProps { link, underline: Some("dbl".into()), ..Default::default() });
|
||||
assert_eq!((dbl.fill, dbl.underline.as_deref()), (hlink, Some("dbl")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layout_colour_map_override_applies_to_its_slides() {
|
||||
// A dark "section" layout maps bg1→dk1 and tx1→lt1; a slide on it that keeps the master
|
||||
// mapping (`masterClrMapping`) follows the layout, as PowerPoint draws it.
|
||||
let mut p = Presentation::default();
|
||||
let dark: Vec<(String, String)> =
|
||||
[("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
|
||||
let layout = p.slides[0].layout;
|
||||
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||||
let colors = m.theme.colors.clone();
|
||||
m.layouts.iter_mut().find(|l| l.id == layout).unwrap().color_map = Some(dark.clone());
|
||||
let s: Slide = (*p.slides[0]).clone();
|
||||
let ctx = Ctx::for_slide(&p, &s).unwrap();
|
||||
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1));
|
||||
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Tx2), None), colors.get(SchemeSlot::Lt2));
|
||||
assert_eq!(ctx.scheme.get(SchemeSlot::Lt1), colors.get(SchemeSlot::Dk1));
|
||||
// The slide's own override wins over the layout's.
|
||||
let mut own = s.clone();
|
||||
own.color_map = Some([("bg1", "lt1"), ("tx1", "dk1")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect());
|
||||
let ctx = Ctx::for_slide(&p, &own).unwrap();
|
||||
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1));
|
||||
// The layout itself draws with its override; the master keeps its own map.
|
||||
let m = &p.masters[0];
|
||||
let l = m.layout(layout).unwrap();
|
||||
assert_eq!(Ctx::for_layout(&p, m, l).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1));
|
||||
assert_eq!(Ctx::for_master(&p, m).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1));
|
||||
}
|
||||
|
||||
@@ -132,6 +132,8 @@ pub enum Align {
|
||||
Center,
|
||||
Right,
|
||||
Justify,
|
||||
/// Justified with kashida (tatweel) elongation for Arabic (`justLow`).
|
||||
JustLow,
|
||||
Distributed,
|
||||
}
|
||||
|
||||
@@ -142,6 +144,7 @@ impl Align {
|
||||
Align::Center => "ctr",
|
||||
Align::Right => "r",
|
||||
Align::Justify => "just",
|
||||
Align::JustLow => "justLow",
|
||||
Align::Distributed => "dist",
|
||||
}
|
||||
}
|
||||
@@ -150,7 +153,8 @@ impl Align {
|
||||
"l" => Align::Left,
|
||||
"ctr" => Align::Center,
|
||||
"r" => Align::Right,
|
||||
"just" | "justLow" | "thaiDist" => Align::Justify,
|
||||
"just" | "thaiDist" => Align::Justify,
|
||||
"justLow" => Align::JustLow,
|
||||
"dist" => Align::Distributed,
|
||||
_ => return None,
|
||||
})
|
||||
@@ -219,6 +223,10 @@ pub struct ParaProps {
|
||||
pub rtl: Option<bool>,
|
||||
pub font_align: Option<String>,
|
||||
pub east_asian_line_break: Option<bool>,
|
||||
/// `latinLnBrk`: Latin words may break inside (after `/`, `\`); off by default.
|
||||
pub latin_line_break: Option<bool>,
|
||||
/// `hangingPunct`: closing East Asian punctuation may hang past the end of a line.
|
||||
pub hanging_punct: Option<bool>,
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
@@ -249,6 +257,8 @@ impl ParaProps {
|
||||
self.font_align.clone_from(&p.font_align);
|
||||
}
|
||||
self.east_asian_line_break = self.east_asian_line_break.or(p.east_asian_line_break);
|
||||
self.latin_line_break = self.latin_line_break.or(p.latin_line_break);
|
||||
self.hanging_punct = self.hanging_punct.or(p.hanging_punct);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,24 @@ pub struct FontSet {
|
||||
pub latin: String,
|
||||
pub ea: String,
|
||||
pub cs: String,
|
||||
/// Per-script typefaces (`a:font script="Arab" typeface="…"`), in document order.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub scripts: Vec<ScriptFont>,
|
||||
}
|
||||
|
||||
/// A theme font for one script (ISO 15924 tag, e.g. `Arab`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ScriptFont {
|
||||
pub script: String,
|
||||
pub typeface: String,
|
||||
}
|
||||
|
||||
impl FontSet {
|
||||
/// The typeface the theme gives `script` (`"Arab"`), if any.
|
||||
pub fn script(&self, script: &str) -> Option<&str> {
|
||||
self.scripts.iter().find(|f| f.script.eq_ignore_ascii_case(script) && !f.typeface.is_empty()).map(|f| f.typeface.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -34,8 +34,14 @@ pub enum PptxError {
|
||||
}
|
||||
|
||||
/// Does this look like an OOXML package (zip with `[Content_Types].xml`)?
|
||||
///
|
||||
/// The part is looked up in the zip's central directory, not in the leading bytes: writers may
|
||||
/// store it anywhere in the archive (recent PowerPoint builds put it last, megabytes in).
|
||||
pub fn sniff(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"PK") && bytes.windows(19).take(4096).any(|w| w == b"[Content_Types].xml")
|
||||
if !bytes.starts_with(b"PK") {
|
||||
return false;
|
||||
}
|
||||
zip::ZipArchive::new(std::io::Cursor::new(bytes)).map(|z| z.index_for_name("[Content_Types].xml").is_some()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Read a `.pptx` / `.potx` / `.ppsx` package.
|
||||
|
||||
@@ -77,6 +77,12 @@ pub fn fill_el(imp: &mut Imp, part: &Part, f: &El) -> Option<Fill> {
|
||||
"solidFill" => Fill::Solid { color: color(f).unwrap_or(ColorRef::rgb(Rgba::BLACK)) },
|
||||
"gradFill" => Fill::Gradient(gradient(f)),
|
||||
"blipFill" => match blip_fill(imp, part, f) {
|
||||
// A picture filling a shape or background with neither `a:stretch` nor `a:tile` is
|
||||
// tiled at its own size, as PowerPoint draws it.
|
||||
Some(mut p) if f.child("stretch").is_none() && f.child("tile").is_none() => {
|
||||
p.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
|
||||
Fill::Picture(p)
|
||||
}
|
||||
Some(p) => Fill::Picture(p),
|
||||
None => Fill::None,
|
||||
},
|
||||
@@ -339,6 +345,8 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option<u8>, ParaProps, Option
|
||||
rtl: p.bool("rtl"),
|
||||
font_align: p.attr("fontAlgn").map(String::from),
|
||||
east_asian_line_break: p.bool("eaLnBrk"),
|
||||
latin_line_break: p.bool("latinLnBrk"),
|
||||
hanging_punct: p.bool("hangingPunct"),
|
||||
default_tab: pt(p, "defTabSz"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::sync::Arc;
|
||||
use deckcraft_geom::{Size, emu_to_pt};
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{
|
||||
Background, Comment, CustomShow, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation,
|
||||
Background, Comment, CustomShow, Geom, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation,
|
||||
Properties, Section, ShowSettings, Slide, SlideId, defaults,
|
||||
};
|
||||
|
||||
@@ -387,6 +387,7 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
p.props.company = d.root.child("Company").map(|c| c.text()).unwrap_or_default();
|
||||
}
|
||||
|
||||
inherit_placeholder_geometry(&mut p);
|
||||
p.header_footer = header_footer(&p);
|
||||
p.media = std::mem::take(&mut imp.media);
|
||||
p.next_id = imp.next;
|
||||
@@ -419,6 +420,91 @@ pub fn color_map(cm: &El) -> Vec<(String, String)> {
|
||||
cm.attrs.iter().filter(|(k, _)| !k.starts_with("xmlns")).map(|(k, v)| (k.clone(), v.clone())).collect()
|
||||
}
|
||||
|
||||
/// Placeholders read without a geometry (an empty custom geometry, see `shapes::sp`) take the
|
||||
/// geometry of the layout or master placeholder they inherit from, as PowerPoint draws them: a
|
||||
/// layout's picture placeholder cut into a shape keeps that shape on the slide. Anything left
|
||||
/// is a rectangle.
|
||||
fn inherit_placeholder_geometry(p: &mut Presentation) {
|
||||
let unset = |g: &Geom| matches!(g, Geom::Custom { paths } if paths.is_empty());
|
||||
// Masters first, then layouts (from their masters), then slides (from layouts and masters).
|
||||
for m in &mut p.masters {
|
||||
let m = Arc::make_mut(m);
|
||||
walk_shapes_mut(&mut m.shapes, &mut |s| {
|
||||
if unset(&s.geom) {
|
||||
s.geom = Geom::default();
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut layout_geoms: Vec<(LayoutId, usize, Geom)> = vec![];
|
||||
for m in &p.masters {
|
||||
for l in &m.layouts {
|
||||
let ctx = deckcraft_model::resolve::Ctx::for_layout(p, m, l);
|
||||
for (i, s) in l.shapes.iter().enumerate() {
|
||||
if unset(&s.geom) {
|
||||
let (_, mp) = deckcraft_model::resolve::parents(&ctx, s);
|
||||
layout_geoms.push((l.id, i, mp.map(|m| m.geom.clone()).filter(|g| !unset(g)).unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for m in &mut p.masters {
|
||||
let m = Arc::make_mut(m);
|
||||
for l in &mut m.layouts {
|
||||
for (id, i, g) in layout_geoms.iter().filter(|(id, _, _)| *id == l.id) {
|
||||
let _ = id;
|
||||
if let Some(s) = l.shapes.get_mut(*i) {
|
||||
s.geom = g.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut slide_geoms: Vec<(usize, usize, Geom)> = vec![];
|
||||
for (si, sl) in p.slides.iter().enumerate() {
|
||||
let Some(ctx) = deckcraft_model::resolve::Ctx::for_slide(p, sl) else { continue };
|
||||
for (i, s) in sl.shapes.iter().enumerate() {
|
||||
if unset(&s.geom) {
|
||||
let (lp, mp) = deckcraft_model::resolve::parents(&ctx, s);
|
||||
let g = lp.map(|l| l.geom.clone()).filter(|g| !unset(g)).or_else(|| mp.map(|m| m.geom.clone()).filter(|g| !unset(g)));
|
||||
slide_geoms.push((si, i, g.unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (si, i, g) in slide_geoms {
|
||||
if let Some(sl) = p.slides.get_mut(si)
|
||||
&& let Some(s) = Arc::make_mut(sl).shapes.get_mut(i)
|
||||
{
|
||||
s.geom = g;
|
||||
}
|
||||
}
|
||||
// Placeholders inside groups (rare) and anything still unset: rectangles.
|
||||
for sl in &mut p.slides {
|
||||
walk_shapes_mut(&mut Arc::make_mut(sl).shapes, &mut |s| {
|
||||
if unset(&s.geom) {
|
||||
s.geom = Geom::default();
|
||||
}
|
||||
});
|
||||
}
|
||||
for m in &mut p.masters {
|
||||
for l in &mut Arc::make_mut(m).layouts {
|
||||
walk_shapes_mut(&mut l.shapes, &mut |s| {
|
||||
if unset(&s.geom) {
|
||||
s.geom = Geom::default();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every shape, groups included.
|
||||
fn walk_shapes_mut(shapes: &mut [deckcraft_model::Shape], f: &mut dyn FnMut(&mut deckcraft_model::Shape)) {
|
||||
for s in shapes {
|
||||
f(s);
|
||||
if let deckcraft_model::ShapeKind::Group { children, .. } = &mut s.kind {
|
||||
walk_shapes_mut(children, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn background(imp: &mut Imp, part: &Part, csld: &El) -> Option<Background> {
|
||||
let bg = csld.child("bg")?;
|
||||
if let Some(pr) = bg.child("bgPr") {
|
||||
@@ -523,12 +609,18 @@ fn read_layout(imp: &mut Imp, part: &Part, doc: &Doc) -> Layout {
|
||||
l.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0);
|
||||
}
|
||||
}
|
||||
l.color_map = color_map_override(root);
|
||||
if let Some(ext) = root.child("extLst").filter(|e| !has_rel_refs(e)) {
|
||||
l.raw_ext = Some(ext.elements().map(|e| part.keep(e)).collect());
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// A layout's or slide's `clrMapOvr/overrideClrMapping` (None for `masterClrMapping` or none).
|
||||
fn color_map_override(root: &El) -> Option<Vec<(String, String)>> {
|
||||
root.child("clrMapOvr")?.child("overrideClrMapping").map(color_map)
|
||||
}
|
||||
|
||||
fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: LayoutId) -> Slide {
|
||||
let root = &doc.root;
|
||||
let mut s = Slide {
|
||||
@@ -536,6 +628,7 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout
|
||||
layout,
|
||||
hidden: root.bool("show") == Some(false),
|
||||
show_master_shapes: root.bool("showMasterSp").unwrap_or(true),
|
||||
color_map: color_map_override(root),
|
||||
..Default::default()
|
||||
};
|
||||
let mut ctx = shapes::IdCtx::default();
|
||||
|
||||
@@ -180,6 +180,15 @@ fn preset_geom(pg: &El) -> Geom {
|
||||
Geom::Preset { name, adj }
|
||||
}
|
||||
|
||||
/// A placeholder whose `spPr` names no geometry takes its layout's (or master's) placeholder's:
|
||||
/// marked here with an empty custom geometry, filled in once the layouts are read
|
||||
/// (`inherit_placeholder_geometry`).
|
||||
fn mark_inherited_geometry(s: &mut Shape, pr: Option<&El>) {
|
||||
if s.ph.is_some() && pr.is_none_or(|pr| pr.child("prstGeom").is_none() && pr.child("custGeom").is_none()) {
|
||||
s.geom = Geom::Custom { paths: vec![] };
|
||||
}
|
||||
}
|
||||
|
||||
/// `spPr` → transform, geometry, fill, line, effects.
|
||||
fn sp_pr(imp: &mut Imp, part: &Part, s: &mut Shape, pr: &El) {
|
||||
if let Some(x) = pr.child("xfrm") {
|
||||
@@ -229,6 +238,7 @@ fn sp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Shape {
|
||||
if let Some(pr) = e.child("spPr") {
|
||||
sp_pr(imp, part, &mut s, pr);
|
||||
}
|
||||
mark_inherited_geometry(&mut s, e.child("spPr"));
|
||||
if e.bool("useBgFill") == Some(true) {
|
||||
s.fill = Some(Fill::Background);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,22 @@
|
||||
|
||||
use deckcraft_color::{ColorScheme, Rgba, SchemeSlot};
|
||||
use deckcraft_model::style::{ColorBase, Effects};
|
||||
use deckcraft_model::theme::{FontScheme, FontSet, FormatScheme, Theme, default_format};
|
||||
use deckcraft_model::theme::{FontScheme, FontSet, FormatScheme, ScriptFont, Theme, default_format};
|
||||
|
||||
use super::{Imp, Part, dml};
|
||||
use crate::xml::El;
|
||||
|
||||
fn font_set(e: Option<&El>) -> FontSet {
|
||||
let tf = |n: &str| e.and_then(|e| e.child(n)).and_then(|c| c.attr("typeface")).unwrap_or("").to_string();
|
||||
FontSet { latin: tf("latin"), ea: tf("ea"), cs: tf("cs") }
|
||||
let scripts = e
|
||||
.map(|e| {
|
||||
e.children_named("font")
|
||||
.filter_map(|f| Some(ScriptFont { script: f.attr("script")?.to_string(), typeface: f.attr("typeface").unwrap_or("").to_string() }))
|
||||
.take(256)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
FontSet { latin: tf("latin"), ea: tf("ea"), cs: tf("cs"), scripts }
|
||||
}
|
||||
|
||||
pub fn read_theme(imp: &mut Imp, part: &Part, root: &El) -> Theme {
|
||||
|
||||
@@ -432,6 +432,8 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
|
||||
.o("defTabSz", p.default_tab.map(|v| emu(v).max(0)))
|
||||
.b("rtl", p.rtl)
|
||||
.b("eaLnBrk", p.east_asian_line_break)
|
||||
.b("latinLnBrk", p.latin_line_break)
|
||||
.b("hangingPunct", p.hanging_punct)
|
||||
.o("fontAlgn", p.font_align.as_deref().filter(|f| matches!(*f, "auto" | "t" | "ctr" | "base" | "b")));
|
||||
let has_kids = p.line_spacing.is_some()
|
||||
|| p.space_before.is_some()
|
||||
|
||||
@@ -227,6 +227,21 @@ const CLR_KEYS: [(&str, &str); 12] = [
|
||||
];
|
||||
|
||||
fn clr_map(w: &mut W, map: &[(String, String)]) {
|
||||
w.empty("p:clrMap", clr_map_attrs(map));
|
||||
}
|
||||
|
||||
/// `p:clrMapOvr`: the layout's or slide's own colour map, else `a:masterClrMapping`.
|
||||
fn clr_map_ovr(w: &mut W, map: Option<&[(String, String)]>) {
|
||||
w.open0("p:clrMapOvr");
|
||||
match map {
|
||||
Some(m) => w.empty("a:overrideClrMapping", clr_map_attrs(m)),
|
||||
None => w.empty0("a:masterClrMapping"),
|
||||
}
|
||||
w.close("p:clrMapOvr");
|
||||
}
|
||||
|
||||
/// The twelve colour map attributes, each a theme slot (the default for anything else).
|
||||
fn clr_map_attrs(map: &[(String, String)]) -> A {
|
||||
let mut a = A::new();
|
||||
for (k, d) in CLR_KEYS {
|
||||
let v = map
|
||||
@@ -237,7 +252,7 @@ fn clr_map(w: &mut W, map: &[(String, String)]) {
|
||||
.unwrap_or(d);
|
||||
a = a.a(k, v);
|
||||
}
|
||||
w.empty("p:clrMap", a);
|
||||
a
|
||||
}
|
||||
|
||||
fn ext_list(w: &mut W, raw: Option<&str>) {
|
||||
@@ -522,9 +537,7 @@ fn layout_xml(x: &mut Exp, o: &mut Out, l: &Layout) -> Vec<u8> {
|
||||
A::new().o("showMasterSp", (!l.show_master_shapes).then_some("0")).a("type", l.kind.xml()).t("preserve", l.preserve),
|
||||
);
|
||||
csld(&mut w, x, o, &l.name, l.background.as_ref(), &l.shapes);
|
||||
w.open0("p:clrMapOvr");
|
||||
w.empty0("a:masterClrMapping");
|
||||
w.close("p:clrMapOvr");
|
||||
clr_map_ovr(&mut w, l.color_map.as_deref());
|
||||
ext_list(&mut w, ext_children(l.raw_ext.as_deref()).as_deref());
|
||||
w.close("p:sldLayout");
|
||||
w.finish()
|
||||
@@ -556,9 +569,7 @@ fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
root_open(&mut w, "p:sld", A::new().o("showMasterSp", (!s.show_master_shapes).then_some("0")).o("show", s.hidden.then_some("0")));
|
||||
let ids = csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes);
|
||||
w.open0("p:clrMapOvr");
|
||||
w.empty0("a:masterClrMapping");
|
||||
w.close("p:clrMapOvr");
|
||||
clr_map_ovr(&mut w, s.color_map.as_deref());
|
||||
if let Some(t) = &s.transition {
|
||||
timing::transition(&mut w, x, o, t);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ fn font_set(w: &mut W, tag: &str, f: &FontSet) {
|
||||
w.empty("a:latin", A::new().a("typeface", if f.latin.is_empty() { "Arial" } else { &f.latin }));
|
||||
w.empty("a:ea", A::new().a("typeface", &f.ea));
|
||||
w.empty("a:cs", A::new().a("typeface", &f.cs));
|
||||
for sf in &f.scripts {
|
||||
w.empty("a:font", A::new().a("script", &sf.script).a("typeface", &sf.typeface));
|
||||
}
|
||||
w.close(tag);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Fills read from decks written by other tools.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::style::{Fill, PictureFill, PictureMode};
|
||||
use deckcraft_model::{Shape, ShapeId, defaults};
|
||||
|
||||
/// `bytes` with `edit` applied to every slide part.
|
||||
fn edit_slides(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).expect("read");
|
||||
let name = f.name().to_string();
|
||||
if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
|
||||
d = edit(String::from_utf8(d).expect("utf8")).into_bytes();
|
||||
}
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut out = Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30])))
|
||||
.write_to(&mut out, image::ImageFormat::Png)
|
||||
.expect("png");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
/// A deck with one shape filled with a stretched picture, exported.
|
||||
fn deck() -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let media = p.add_media("tile.png", "image/png", png());
|
||||
let id = ShapeId(p.alloc_id());
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Textured".into(),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
|
||||
fill: Some(Fill::Picture(PictureFill { media, ..Default::default() })),
|
||||
..Default::default()
|
||||
});
|
||||
deckcraft_pptx::export(&p).expect("export")
|
||||
}
|
||||
|
||||
fn textured_mode(bytes: &[u8]) -> PictureMode {
|
||||
let q = deckcraft_pptx::import(bytes).expect("import");
|
||||
let s = q.slides[0].shapes.iter().find(|s| s.name == "Textured").expect("shape");
|
||||
match &s.fill {
|
||||
Some(Fill::Picture(pf)) => pf.mode.clone(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_picture_fill_without_stretch_or_tile_tiles() {
|
||||
// tdf#128596: `<a:blipFill><a:blip/></a:blipFill>` on a shape, as some tools write it;
|
||||
// PowerPoint tiles the picture at its own size.
|
||||
let bare = edit_slides(&deck(), |s| {
|
||||
let s = regex_free_strip(&s, "<a:stretch>", "</a:stretch>");
|
||||
assert!(!s.contains("<a:stretch"), "the test deck lost its stretch");
|
||||
s
|
||||
});
|
||||
assert!(matches!(textured_mode(&bare), PictureMode::Tile { sx, sy, .. } if sx == 1.0 && sy == 1.0));
|
||||
// An explicit stretch is still a stretch.
|
||||
assert!(matches!(textured_mode(&deck()), PictureMode::Stretch { .. }));
|
||||
}
|
||||
|
||||
/// `s` without the first `open`…`close` span inside a `blipFill`.
|
||||
fn regex_free_strip(s: &str, open: &str, close: &str) -> String {
|
||||
let Some(b) = s.find("<a:blipFill") else { return s.to_string() };
|
||||
let Some(i) = s[b..].find(open).map(|i| i + b) else { return s.to_string() };
|
||||
let Some(j) = s[i..].find(close).map(|j| j + i + close.len()) else { return s.to_string() };
|
||||
format!("{}{}", &s[..i], &s[j..])
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Placeholder inheritance in decks written by PowerPoint.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_model::{Geom, defaults};
|
||||
|
||||
/// `bytes` with `edit` applied to every slide part.
|
||||
fn edit_slides(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).expect("read");
|
||||
let name = f.name().to_string();
|
||||
if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
|
||||
d = edit(String::from_utf8(d).expect("utf8")).into_bytes();
|
||||
}
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placeholder_without_geometry_takes_its_layouts() {
|
||||
// PowerPoint leaves a slide placeholder's spPr empty and draws it with the layout
|
||||
// placeholder's geometry (a picture placeholder cut into a shape keeps the shape).
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let layout = p.slides[0].layout;
|
||||
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||||
let l = m.layouts.iter_mut().find(|l| l.id == layout).expect("layout");
|
||||
for s in &mut l.shapes {
|
||||
if s.ph.is_some() {
|
||||
s.geom = Geom::Preset { name: "ellipse".into(), adj: vec![] };
|
||||
}
|
||||
}
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
// Strip the slide's own geometry, as PowerPoint writes inheriting placeholders.
|
||||
let bare = edit_slides(&bytes, |s| {
|
||||
let mut s = s;
|
||||
while let Some(i) = s.find("<a:prstGeom") {
|
||||
let Some(j) = s[i..].find("</a:prstGeom>").map(|j| j + i + "</a:prstGeom>".len()) else { break };
|
||||
s.replace_range(i..j, "");
|
||||
}
|
||||
s
|
||||
});
|
||||
let q = deckcraft_pptx::import(&bare).expect("import");
|
||||
let phs: Vec<_> = q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).collect();
|
||||
assert!(!phs.is_empty());
|
||||
for s in phs {
|
||||
assert!(matches!(&s.geom, Geom::Preset { name, .. } if name == "ellipse"), "{:?}", s.geom);
|
||||
}
|
||||
// A placeholder with its own geometry keeps it.
|
||||
let first = p.slides[0].id;
|
||||
for s in &mut p.slide_mut(first).expect("slide").shapes {
|
||||
if s.ph.is_some() {
|
||||
s.geom = Geom::Preset { name: "triangle".into(), adj: vec![] };
|
||||
}
|
||||
}
|
||||
let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
|
||||
assert!(q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).all(|s| matches!(&s.geom, Geom::Preset { name, .. } if name == "triangle")));
|
||||
}
|
||||
@@ -686,3 +686,97 @@ fn bisect_slide_variants() {
|
||||
std::fs::write(format!("{dir}/v-only{k}.pptx"), one(s)).expect("write");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_paragraphs_script_fonts_and_just_low_round_trip() {
|
||||
let mut p = rich_deck();
|
||||
let fonts = &mut Arc::make_mut(&mut p.masters[0]).theme.fonts;
|
||||
fonts.minor.cs = "Noto Naskh Arabic".into();
|
||||
fonts.minor.scripts = vec![
|
||||
deckcraft_model::theme::ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() },
|
||||
deckcraft_model::theme::ScriptFont { script: "Hebr".into(), typeface: "Noto Sans Hebrew".into() },
|
||||
];
|
||||
let slide = Arc::make_mut(&mut p.slides[0]);
|
||||
let sh = slide.shapes.iter_mut().find(|s| s.text.is_some()).expect("a text shape");
|
||||
let body = sh.text.as_mut().expect("text");
|
||||
body.paragraphs = vec![Paragraph { runs: vec![Run::new("سنة 2024 (Retrait) فقط")], ..Default::default() }];
|
||||
body.paragraphs[0].props.rtl = Some(true);
|
||||
body.paragraphs[0].props.align = Some(Align::JustLow);
|
||||
let name = sh.name.clone();
|
||||
let q = round(&p);
|
||||
assert_eq!(q.masters[0].theme.fonts.minor.scripts, p.masters[0].theme.fonts.minor.scripts);
|
||||
assert_eq!(q.masters[0].theme.fonts.minor.script("arab"), Some("Noto Naskh Arabic"));
|
||||
let para = &by_name(&q.slides[0], &name).text.as_ref().expect("text").paragraphs[0];
|
||||
assert_eq!(para.props.rtl, Some(true));
|
||||
assert_eq!(para.props.align, Some(Align::JustLow));
|
||||
assert_eq!(para.runs[0].text, "سنة 2024 (Retrait) فقط");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_fields_keep_their_type_and_saved_text() {
|
||||
// An automatic date is a field (its saved text is what readers without a clock show); a fixed
|
||||
// date is plain text.
|
||||
let mut p = Presentation::default();
|
||||
let mut auto = shape(&mut p, "Auto date", 10.0, 10.0, 200.0, 40.0);
|
||||
let mut body = TextBody::from_text("");
|
||||
body.paragraphs[0].runs =
|
||||
vec![Run { text: "March 3, 2025".into(), props: RunProps::default(), kind: RunKind::Field { field: "datetime4".into() } }];
|
||||
auto.text = Some(body);
|
||||
let mut fixed = shape(&mut p, "Fixed date", 10.0, 60.0, 200.0, 40.0);
|
||||
fixed.text = Some(TextBody::from_text("Launch day"));
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = vec![auto, fixed];
|
||||
let q = round(&p);
|
||||
for name in ["Auto date", "Fixed date"] {
|
||||
assert_eq!(by_name(&q.slides[0], name).text, by_name(&p.slides[0], name).text, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_map_overrides_round_trip() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let dark: Vec<(String, String)> =
|
||||
[("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
|
||||
let layout = p.slides[0].layout;
|
||||
std::sync::Arc::make_mut(&mut p.masters[0]).layouts.iter_mut().find(|l| l.id == layout).expect("layout").color_map = Some(dark.clone());
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").color_map = Some(dark.clone());
|
||||
let q = round(&p);
|
||||
let get = |m: &Option<Vec<(String, String)>>, k: &str| m.as_ref().and_then(|m| m.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone()));
|
||||
let ql = q.masters[0].layouts.iter().find(|l| l.color_map.is_some()).expect("the layout keeps its override");
|
||||
assert_eq!(get(&ql.color_map, "bg1").as_deref(), Some("dk1"));
|
||||
assert_eq!(get(&q.slides[0].color_map, "tx1").as_deref(), Some("lt1"));
|
||||
// Layouts and slides without one keep following the master.
|
||||
assert!(q.masters[0].layouts.iter().filter(|l| l.color_map.is_none()).count() >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_duotone_round_trips() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let img = p.add_media("image1.png", "image/png", png(4, 4, [128, 128, 128]));
|
||||
let mut pic = shape(&mut p, "Duotone", 10.0, 10.0, 100.0, 100.0);
|
||||
let mut pf = PictureFill { media: img, ..Default::default() };
|
||||
pf.adjust.duotone = Some((ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Shade(30000)), ColorRef::rgb(Rgba::rgb(250, 240, 200))));
|
||||
pic.kind = deckcraft_model::ShapeKind::Picture { fill: pf };
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").shapes.push(pic);
|
||||
let q = round(&p);
|
||||
let sh = by_name(&q.slides[0], "Duotone");
|
||||
let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind else { panic!("picture") };
|
||||
let (d, l) = fill.adjust.duotone.as_ref().expect("duotone kept");
|
||||
assert!(matches!(d.base, deckcraft_model::ColorBase::Scheme { slot: SchemeSlot::Accent1 }));
|
||||
assert!(matches!(l.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(250, 240, 200)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_break_options_round_trip() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let first = p.slides[0].id;
|
||||
let s = p.slide_mut(first).expect("slide");
|
||||
let t = s.shapes[0].text.insert(TextBody::from_text("x"));
|
||||
t.paragraphs[0].props.latin_line_break = Some(true);
|
||||
t.paragraphs[0].props.hanging_punct = Some(false);
|
||||
let q = round(&p);
|
||||
let props = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].props;
|
||||
assert_eq!(props.latin_line_break, Some(true));
|
||||
assert_eq!(props.hanging_punct, Some(false));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! `sniff` must recognise a package wherever the writer put `[Content_Types].xml`: recent
|
||||
//! PowerPoint builds store it as the last zip entry, well past the first few kilobytes.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
fn sample_pptx() -> Vec<u8> {
|
||||
let mut s = Session::new();
|
||||
deckcraft_engine::sample::open_sample(&mut s).expect("sample");
|
||||
let p = s.doc().expect("doc").doc.as_ref().clone();
|
||||
deckcraft_pptx::export(&p).expect("export")
|
||||
}
|
||||
|
||||
/// Re-pack `bytes` with `[Content_Types].xml` moved to the end and a padding part in front, so
|
||||
/// the content-types name first appears far beyond any fixed-size prefix.
|
||||
fn content_types_last(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
out.start_file("ppt/media/padding.bin", stored).expect("start");
|
||||
out.write_all(&vec![0u8; 64 * 1024]).expect("padding");
|
||||
let names: Vec<String> = (0..src.len()).map(|i| src.by_index(i).expect("entry").name().to_string()).collect();
|
||||
for name in names.iter().filter(|n| *n != "[Content_Types].xml").chain(std::iter::once(&"[Content_Types].xml".to_string())) {
|
||||
let mut data = Vec::new();
|
||||
src.by_name(name).expect("by_name").read_to_end(&mut data).expect("read");
|
||||
out.start_file(name, SimpleFileOptions::default()).expect("start");
|
||||
out.write_all(&data).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_accepts_content_types_anywhere_in_the_archive() {
|
||||
let bytes = sample_pptx();
|
||||
assert!(deckcraft_pptx::sniff(&bytes));
|
||||
|
||||
let moved = content_types_last(&bytes);
|
||||
let first_4k = &moved[..4096.min(moved.len())];
|
||||
assert!(!first_4k.windows(19).any(|w| w == b"[Content_Types].xml"), "test setup: name must not be in the prefix");
|
||||
assert!(deckcraft_pptx::sniff(&moved));
|
||||
let p = deckcraft_pptx::import(&moved).expect("import");
|
||||
assert!(!p.slides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_rejects_non_packages() {
|
||||
assert!(!deckcraft_pptx::sniff(b""));
|
||||
assert!(!deckcraft_pptx::sniff(b"PK"));
|
||||
assert!(!deckcraft_pptx::sniff(b"%PDF-1.7 [Content_Types].xml"));
|
||||
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
out.start_file("hello.txt", SimpleFileOptions::default()).expect("start");
|
||||
out.write_all(b"not an office package").expect("write");
|
||||
let plain_zip = out.finish().expect("finish").into_inner();
|
||||
assert!(!deckcraft_pptx::sniff(&plain_zip));
|
||||
}
|
||||
@@ -19,6 +19,7 @@ vello_common = { workspace = true }
|
||||
image = { workspace = true }
|
||||
log = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
vello_cpu = { workspace = true, features = ["multithreading"] }
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Colour glyphs (OpenType `COLR` v0 and v1, colours from `CPAL`), as colour emoji fonts draw
|
||||
//! them: layers of glyph outlines filled with solid colours or gradients.
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_fonts::FontFace;
|
||||
use kurbo::{Affine, BezPath, Point, Rect};
|
||||
use skrifa::color::{Brush, ColorPainter, ColorStop, CompositeMode, Extend, Transform};
|
||||
use skrifa::raw::types::BoundingBox;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
use vello_cpu::{RenderContext, peniko};
|
||||
|
||||
/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale`
|
||||
/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all.
|
||||
/// False when the glyph has no colour version, so the caller draws the plain outline.
|
||||
pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, scale: f64, fg: Rgba, alpha: f64) -> bool {
|
||||
let Some(font) = face.skrifa() else { return false };
|
||||
let glyphs = font.color_glyphs();
|
||||
let Some(glyph) = glyphs.get(GlyphId::new(gid)) else { return false };
|
||||
let palette: Vec<Rgba> = font
|
||||
.color_palettes()
|
||||
.get(0)
|
||||
.map(|p| p.colors().iter().map(|c| Rgba { r: c.red, g: c.green, b: c.blue, a: c.alpha }).collect())
|
||||
.unwrap_or_default();
|
||||
// Font units are y up; the slide is y down.
|
||||
let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0);
|
||||
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() };
|
||||
p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None);
|
||||
let ok = glyph.paint(face.location(), &mut p).is_ok();
|
||||
// A malformed glyph may leave layers open: close them all.
|
||||
while let Some(open) = p.opened.pop() {
|
||||
if open {
|
||||
p.ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
p.ctx.pop_layer();
|
||||
ok
|
||||
}
|
||||
|
||||
struct Painter<'a> {
|
||||
ctx: &'a mut RenderContext,
|
||||
face: &'a FontFace,
|
||||
base: Affine,
|
||||
/// Transforms pushed by the glyph (font units), innermost last.
|
||||
stack: Vec<Affine>,
|
||||
palette: Vec<Rgba>,
|
||||
fg: Rgba,
|
||||
/// Clips and composite groups pushed by the glyph, innermost last: true when a layer was
|
||||
/// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too).
|
||||
opened: Vec<bool>,
|
||||
}
|
||||
|
||||
fn affine(t: &Transform) -> Affine {
|
||||
Affine::new([t.xx as f64, t.yx as f64, t.xy as f64, t.yy as f64, t.dx as f64, t.dy as f64])
|
||||
}
|
||||
|
||||
fn pt(p: skrifa::raw::types::Point<f32>) -> Point {
|
||||
Point::new(p.x as f64, p.y as f64)
|
||||
}
|
||||
|
||||
fn extend(e: Extend) -> peniko::Extend {
|
||||
match e {
|
||||
Extend::Repeat => peniko::Extend::Repeat,
|
||||
Extend::Reflect => peniko::Extend::Reflect,
|
||||
_ => peniko::Extend::Pad,
|
||||
}
|
||||
}
|
||||
|
||||
fn blend(m: CompositeMode) -> peniko::BlendMode {
|
||||
use peniko::{Compose, Mix};
|
||||
let (mix, compose) = match m {
|
||||
CompositeMode::Clear => (Mix::Normal, Compose::Clear),
|
||||
CompositeMode::Src => (Mix::Normal, Compose::Copy),
|
||||
CompositeMode::Dest => (Mix::Normal, Compose::Dest),
|
||||
CompositeMode::DestOver => (Mix::Normal, Compose::DestOver),
|
||||
CompositeMode::SrcIn => (Mix::Normal, Compose::SrcIn),
|
||||
CompositeMode::DestIn => (Mix::Normal, Compose::DestIn),
|
||||
CompositeMode::SrcOut => (Mix::Normal, Compose::SrcOut),
|
||||
CompositeMode::DestOut => (Mix::Normal, Compose::DestOut),
|
||||
CompositeMode::SrcAtop => (Mix::Normal, Compose::SrcAtop),
|
||||
CompositeMode::DestAtop => (Mix::Normal, Compose::DestAtop),
|
||||
CompositeMode::Xor => (Mix::Normal, Compose::Xor),
|
||||
CompositeMode::Plus => (Mix::Normal, Compose::Plus),
|
||||
CompositeMode::Screen => (Mix::Screen, Compose::SrcOver),
|
||||
CompositeMode::Overlay => (Mix::Overlay, Compose::SrcOver),
|
||||
CompositeMode::Darken => (Mix::Darken, Compose::SrcOver),
|
||||
CompositeMode::Lighten => (Mix::Lighten, Compose::SrcOver),
|
||||
CompositeMode::ColorDodge => (Mix::ColorDodge, Compose::SrcOver),
|
||||
CompositeMode::ColorBurn => (Mix::ColorBurn, Compose::SrcOver),
|
||||
CompositeMode::HardLight => (Mix::HardLight, Compose::SrcOver),
|
||||
CompositeMode::SoftLight => (Mix::SoftLight, Compose::SrcOver),
|
||||
CompositeMode::Difference => (Mix::Difference, Compose::SrcOver),
|
||||
CompositeMode::Exclusion => (Mix::Exclusion, Compose::SrcOver),
|
||||
CompositeMode::Multiply => (Mix::Multiply, Compose::SrcOver),
|
||||
CompositeMode::HslHue => (Mix::Hue, Compose::SrcOver),
|
||||
CompositeMode::HslSaturation => (Mix::Saturation, Compose::SrcOver),
|
||||
CompositeMode::HslColor => (Mix::Color, Compose::SrcOver),
|
||||
CompositeMode::HslLuminosity => (Mix::Luminosity, Compose::SrcOver),
|
||||
_ => (Mix::Normal, Compose::SrcOver),
|
||||
};
|
||||
peniko::BlendMode::new(mix, compose)
|
||||
}
|
||||
|
||||
impl Painter<'_> {
|
||||
fn current(&self) -> Affine {
|
||||
self.base * self.stack.last().copied().unwrap_or(Affine::IDENTITY)
|
||||
}
|
||||
|
||||
fn color(&self, index: u16, alpha: f32) -> peniko::Color {
|
||||
let c = if index == 0xFFFF { self.fg } else { self.palette.get(index as usize).copied().unwrap_or(self.fg) };
|
||||
let a = (c.a as f64 * (alpha as f64).clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8;
|
||||
peniko::Color::from_rgba8(c.r, c.g, c.b, a)
|
||||
}
|
||||
|
||||
fn stops(&self, stops: &[ColorStop]) -> Vec<peniko::ColorStop> {
|
||||
stops.iter().map(|s| peniko::ColorStop { offset: s.offset.clamp(0.0, 1.0), color: self.color(s.palette_index, s.alpha).into() }).collect()
|
||||
}
|
||||
|
||||
/// The glyph outline in font units, y up.
|
||||
fn outline(&self, gid: GlyphId) -> BezPath {
|
||||
let p = deckcraft_fonts::FontDb::global().outline(self.face, gid.to_u32());
|
||||
Affine::scale_non_uniform(1.0, -1.0) * (*p).clone()
|
||||
}
|
||||
|
||||
fn push_clip(&mut self, path: &BezPath) {
|
||||
let open = self.opened.len() < MAX_DEPTH;
|
||||
if open {
|
||||
self.ctx.set_transform(self.current());
|
||||
self.ctx.push_clip_layer(path);
|
||||
}
|
||||
self.opened.push(open);
|
||||
}
|
||||
|
||||
fn pop(&mut self) {
|
||||
if self.opened.pop() == Some(true) {
|
||||
self.ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill the open clip area (a large box in the current transform) with `paint`.
|
||||
fn cover(&mut self, paint: impl Into<vello_cpu::PaintType>) {
|
||||
self.ctx.set_transform(self.current());
|
||||
self.ctx.set_paint(paint);
|
||||
self.ctx.fill_rect(&Rect::new(-1.0e5, -1.0e5, 1.0e5, 1.0e5));
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorPainter for Painter<'_> {
|
||||
fn push_transform(&mut self, t: Transform) {
|
||||
let next = self.stack.last().copied().unwrap_or(Affine::IDENTITY) * affine(&t);
|
||||
self.stack.push(next);
|
||||
}
|
||||
|
||||
fn pop_transform(&mut self) {
|
||||
if self.stack.len() > 1 {
|
||||
self.stack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn push_clip_glyph(&mut self, gid: GlyphId) {
|
||||
let path = self.outline(gid);
|
||||
self.push_clip(&path);
|
||||
}
|
||||
|
||||
fn push_clip_box(&mut self, b: BoundingBox<f32>) {
|
||||
let r = Rect::new(b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64);
|
||||
self.push_clip(&kurbo::Shape::to_path(&r, 0.1));
|
||||
}
|
||||
|
||||
fn pop_clip(&mut self) {
|
||||
self.pop();
|
||||
}
|
||||
|
||||
fn fill(&mut self, brush: Brush<'_>) {
|
||||
match brush {
|
||||
Brush::Solid { palette_index, alpha } => {
|
||||
let c = self.color(palette_index, alpha);
|
||||
self.cover(c);
|
||||
}
|
||||
Brush::LinearGradient { p0, p1, color_stops, extend: e } => {
|
||||
let g = peniko::Gradient::new_linear(pt(p0), pt(p1)).with_stops(self.stops(color_stops).as_slice()).with_extend(extend(e));
|
||||
self.cover(g);
|
||||
}
|
||||
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => {
|
||||
let g = peniko::Gradient::new_two_point_radial(pt(c0), r0.max(0.0), pt(c1), r1.max(0.0))
|
||||
.with_stops(self.stops(color_stops).as_slice())
|
||||
.with_extend(extend(e));
|
||||
self.cover(g);
|
||||
}
|
||||
Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => {
|
||||
let g = peniko::Gradient::new_sweep(pt(c0), start_angle.to_radians(), end_angle.to_radians())
|
||||
.with_stops(self.stops(color_stops).as_slice())
|
||||
.with_extend(extend(e));
|
||||
self.cover(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_layer(&mut self, mode: CompositeMode) {
|
||||
let open = self.opened.len() < MAX_DEPTH;
|
||||
if open {
|
||||
self.ctx.push_blend_layer(blend(mode));
|
||||
}
|
||||
self.opened.push(open);
|
||||
}
|
||||
|
||||
fn pop_layer(&mut self) {
|
||||
self.pop();
|
||||
}
|
||||
}
|
||||
+183
-12
@@ -21,6 +21,41 @@ struct Cache {
|
||||
bytes: usize,
|
||||
clock: u64,
|
||||
bad: HashMap<usize, Arc<Vec<u8>>>,
|
||||
/// GIFs by bytes address and adjustments (`None`: not animated); the bytes stay alive with them.
|
||||
anims: HashMap<(usize, u64), (Arc<Vec<u8>>, Option<Arc<Anim>>)>,
|
||||
}
|
||||
|
||||
/// Longest side, total decoded size and frame count kept for one animated GIF; the largest canvas
|
||||
/// decoded at all; and the decoded size of all cached GIFs together. Hostile GIFs (millions of
|
||||
/// tiny frames, a huge canvas) stop at these instead of exhausting memory.
|
||||
const GIF_SIDE: u32 = 1024;
|
||||
const GIF_BUDGET: usize = 64 << 20;
|
||||
const GIF_MAX_FRAMES: usize = 1000;
|
||||
const GIF_MAX_CANVAS: u64 = 4096 * 4096;
|
||||
const GIF_CACHE_BUDGET: usize = 192 << 20;
|
||||
|
||||
/// The frames of an animated GIF and when each one ends, in seconds from the start of a loop.
|
||||
struct Anim {
|
||||
frames: Vec<Arc<Pixmap>>,
|
||||
ends: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Anim {
|
||||
/// Decoded bytes held by the frames.
|
||||
fn bytes(&self) -> usize {
|
||||
self.frames.iter().map(|f| f.width() as usize * f.height() as usize * 4).sum()
|
||||
}
|
||||
|
||||
/// The frame showing `t` seconds into the (looping) animation and the seconds until the next.
|
||||
fn at(&self, t: f64) -> (usize, f64) {
|
||||
let total = self.ends.last().copied().unwrap_or(0.0);
|
||||
if total <= 0.0 || !t.is_finite() {
|
||||
return (0, 1.0);
|
||||
}
|
||||
let local = t.max(0.0) % total;
|
||||
let i = self.ends.partition_point(|e| *e <= local).min(self.ends.len().saturating_sub(1));
|
||||
(i, (self.ends.get(i).copied().unwrap_or(total) - local).max(0.001))
|
||||
}
|
||||
}
|
||||
|
||||
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
|
||||
@@ -44,9 +79,26 @@ fn adjust_key(a: &PictureAdjust) -> u64 {
|
||||
if let Some(c) = a.clear_color {
|
||||
mix(((c.r as u64) << 16) | ((c.g as u64) << 8) | c.b as u64 | 1 << 40);
|
||||
}
|
||||
if let Some((d, l)) = duotone_rgb(a) {
|
||||
for c in [d, l] {
|
||||
mix(((c.r as u64) << 24) | ((c.g as u64) << 16) | ((c.b as u64) << 8) | c.a as u64 | 1 << 41);
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// The duotone's dark and light colours. The painter resolves them to plain RGB first (see
|
||||
/// `paint::resolved`); a colour that is still a scheme reference falls back to black and white.
|
||||
fn duotone_rgb(a: &PictureAdjust) -> Option<(deckcraft_color::Rgba, deckcraft_color::Rgba)> {
|
||||
use deckcraft_model::style::ColorBase;
|
||||
let (d, l) = a.duotone.as_ref()?;
|
||||
let rgb = |c: &deckcraft_model::style::ColorRef, or: deckcraft_color::Rgba| match &c.base {
|
||||
ColorBase::Rgb { rgb } if c.mods.is_empty() => *rgb,
|
||||
_ => or,
|
||||
};
|
||||
Some((rgb(d, deckcraft_color::Rgba::BLACK), rgb(l, deckcraft_color::Rgba::WHITE)))
|
||||
}
|
||||
|
||||
/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF) to straight RGBA.
|
||||
pub fn decode(bytes: &[u8]) -> Option<image::RgbaImage> {
|
||||
if bytes.len() > 512 << 20 {
|
||||
@@ -94,18 +146,7 @@ pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Optio
|
||||
}
|
||||
apply_adjust(&mut img, adj);
|
||||
let (w, h) = img.dimensions();
|
||||
if w > u16::MAX as u32 || h > u16::MAX as u32 {
|
||||
return None;
|
||||
}
|
||||
let data: Vec<vello_cpu::color::PremulRgba8> = img
|
||||
.pixels()
|
||||
.map(|p| {
|
||||
let a = p[3] as u16;
|
||||
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
|
||||
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
|
||||
})
|
||||
.collect();
|
||||
let pm = Arc::new(Pixmap::from_parts(data, w as u16, h as u16));
|
||||
let pm = pixmap(&img)?;
|
||||
with(|c| {
|
||||
c.clock += 1;
|
||||
let n = w as usize * h as usize * 4;
|
||||
@@ -121,11 +162,95 @@ pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Optio
|
||||
Some(pm)
|
||||
}
|
||||
|
||||
/// Straight RGBA to a premultiplied pixmap.
|
||||
fn pixmap(img: &image::RgbaImage) -> Option<Arc<Pixmap>> {
|
||||
let (w, h) = img.dimensions();
|
||||
if w > u16::MAX as u32 || h > u16::MAX as u32 {
|
||||
return None;
|
||||
}
|
||||
let data: Vec<vello_cpu::color::PremulRgba8> = img
|
||||
.pixels()
|
||||
.map(|p| {
|
||||
let a = p[3] as u16;
|
||||
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
|
||||
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
|
||||
})
|
||||
.collect();
|
||||
Some(Arc::new(Pixmap::from_parts(data, w as u16, h as u16)))
|
||||
}
|
||||
|
||||
/// Decode every frame of an animated GIF (`None` for a still or broken one).
|
||||
fn decode_anim(bytes: &[u8], adj: &PictureAdjust) -> Option<Anim> {
|
||||
use image::{AnimationDecoder, ImageDecoder};
|
||||
let mut dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?;
|
||||
dec.set_limits(image::Limits::default()).ok()?;
|
||||
let (w, h) = dec.dimensions();
|
||||
if w == 0 || h == 0 || (w as u64) * (h as u64) > GIF_MAX_CANVAS {
|
||||
return None;
|
||||
}
|
||||
let k = (GIF_SIDE as f64 / w.max(h) as f64).min(1.0);
|
||||
let (nw, nh) = (((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1));
|
||||
let size = nw as usize * nh as usize * 4;
|
||||
let (mut frames, mut ends, mut t) = (Vec::new(), Vec::new(), 0.0);
|
||||
for f in dec.into_frames() {
|
||||
let Ok(f) = f else { break };
|
||||
if frames.len() >= GIF_MAX_FRAMES || (frames.len() + 1) * size > GIF_BUDGET {
|
||||
log::info!("animated GIF: keeping its first {} frames", frames.len());
|
||||
break;
|
||||
}
|
||||
let (n, d) = f.delay().numer_denom_ms();
|
||||
let ms = if d == 0 { 0.0 } else { n as f64 / d as f64 };
|
||||
// Like browsers: frames of 10 ms or less show for 100 ms.
|
||||
t += if ms <= 10.0 { 0.1 } else { ms / 1000.0 };
|
||||
let mut img = f.into_buffer();
|
||||
if img.dimensions() != (nw, nh) {
|
||||
img = image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle);
|
||||
}
|
||||
apply_adjust(&mut img, adj);
|
||||
frames.push(pixmap(&img)?);
|
||||
ends.push(t);
|
||||
}
|
||||
(frames.len() > 1).then_some(Anim { frames, ends })
|
||||
}
|
||||
|
||||
/// The decoded animation of GIF bytes, once per bytes and adjustments.
|
||||
fn anim(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust) -> Option<Arc<Anim>> {
|
||||
if !bytes.starts_with(b"GIF8") {
|
||||
return None;
|
||||
}
|
||||
let key = (Arc::as_ptr(bytes) as usize, adjust_key(adj));
|
||||
if let Some(a) = with(|c| c.anims.get(&key).map(|e| e.1.clone())) {
|
||||
return a;
|
||||
}
|
||||
let a = decode_anim(bytes, adj).map(Arc::new);
|
||||
with(|c| {
|
||||
let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum();
|
||||
if held + a.as_ref().map_or(0, |a| a.bytes()) > GIF_CACHE_BUDGET || c.anims.len() >= 64 {
|
||||
c.anims.clear();
|
||||
}
|
||||
c.anims.insert(key, (bytes.clone(), a.clone()));
|
||||
});
|
||||
a
|
||||
}
|
||||
|
||||
/// For an animated GIF: the frame showing `t` seconds into its (looping) play and the seconds until
|
||||
/// the next frame. `None` for anything else.
|
||||
pub fn gif_frame(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust, t: f64) -> Option<(usize, f64)> {
|
||||
anim(bytes, adj).map(|a| a.at(t))
|
||||
}
|
||||
|
||||
/// The frame of an animated GIF at `t` seconds.
|
||||
pub(crate) fn gif_pixmap(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust, t: f64) -> Option<Arc<Pixmap>> {
|
||||
let a = anim(bytes, adj)?;
|
||||
a.frames.get(a.at(t).0).cloned()
|
||||
}
|
||||
|
||||
fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
|
||||
let sat = a.saturation.unwrap_or(1.0);
|
||||
if a.brightness == 0.0 && a.contrast == 0.0 && sat == 1.0 && !a.grayscale && a.clear_color.is_none() && a.duotone.is_none() {
|
||||
return;
|
||||
}
|
||||
let duo = duotone_rgb(a);
|
||||
let b = a.brightness.clamp(-1.0, 1.0) * 255.0;
|
||||
let c = a.contrast.clamp(-1.0, 1.0);
|
||||
let cf = if c >= 0.0 { 1.0 / (1.0 - c * 0.99) } else { 1.0 + c };
|
||||
@@ -145,6 +270,12 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
|
||||
*v = y + (*v - y) * s;
|
||||
*v = (*v - 128.0) * cf + 128.0 + b;
|
||||
}
|
||||
// Duotone: the pixel's luminance picks a colour between the dark and the light one.
|
||||
if let Some((d, l)) = duo {
|
||||
let t = ((0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255.0).clamp(0.0, 1.0);
|
||||
rgb =
|
||||
[d.r as f64 + (l.r as f64 - d.r as f64) * t, d.g as f64 + (l.g as f64 - d.g as f64) * t, d.b as f64 + (l.b as f64 - d.b as f64) * t];
|
||||
}
|
||||
for (i, v) in rgb.iter().enumerate() {
|
||||
if let Some(ch) = p.0.get_mut(i) {
|
||||
*ch = v.round().clamp(0.0, 255.0) as u8;
|
||||
@@ -152,3 +283,43 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duotone_maps_luminance_between_its_two_colours() {
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_model::style::ColorRef;
|
||||
let mut img = image::RgbaImage::from_fn(3, 1, |x, _| image::Rgba([[0, 0, 0, 255], [128, 128, 128, 200], [255, 255, 255, 255]][x as usize]));
|
||||
let adj =
|
||||
PictureAdjust { duotone: Some((ColorRef::rgb(Rgba::rgb(100, 40, 0)), ColorRef::rgb(Rgba::rgb(250, 240, 200)))), ..Default::default() };
|
||||
apply_adjust(&mut img, &adj);
|
||||
assert_eq!(img.get_pixel(0, 0).0, [100, 40, 0, 255], "black takes the dark colour");
|
||||
assert_eq!(img.get_pixel(2, 0).0, [250, 240, 200, 255], "white takes the light colour");
|
||||
let mid = img.get_pixel(1, 0).0;
|
||||
assert!((mid[0] as i32 - 175).abs() <= 1 && (mid[1] as i32 - 140).abs() <= 1 && (mid[2] as i32 - 100).abs() <= 1, "{mid:?}");
|
||||
assert_eq!(mid[3], 200, "alpha is kept");
|
||||
// Different duotones are cached separately.
|
||||
let other = PictureAdjust { duotone: Some((ColorRef::rgb(Rgba::rgb(0, 0, 0)), ColorRef::rgb(Rgba::rgb(255, 0, 0)))), ..Default::default() };
|
||||
assert_ne!(adjust_key(&adj), adjust_key(&other));
|
||||
assert_ne!(adjust_key(&adj), adjust_key(&PictureAdjust::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gif_of_many_tiny_frames_keeps_only_the_frame_cap() {
|
||||
use image::{Delay, Frame, Rgba, RgbaImage};
|
||||
let frames = (0..GIF_MAX_FRAMES + 200).map(|i| {
|
||||
let c = if i % 2 == 0 { [255, 0, 0, 255] } else { [0, 0, 255, 255] };
|
||||
Frame::from_parts(RgbaImage::from_pixel(1, 1, Rgba(c)), 0, 0, Delay::from_numer_denom_ms(20, 1))
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
image::codecs::gif::GifEncoder::new(&mut out).encode_frames(frames).expect("gif");
|
||||
let a = decode_anim(&out, &PictureAdjust::default()).expect("animated");
|
||||
assert_eq!(a.frames.len(), GIF_MAX_FRAMES);
|
||||
assert_eq!(a.ends.len(), GIF_MAX_FRAMES);
|
||||
}
|
||||
}
|
||||
|
||||
+369
-76
@@ -8,9 +8,13 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod chart;
|
||||
mod colr;
|
||||
mod images;
|
||||
mod morph_path;
|
||||
mod morph_text;
|
||||
mod paint;
|
||||
mod placed;
|
||||
mod ramp;
|
||||
mod table;
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -23,13 +27,17 @@ use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::style::{Effects, Fill, Line};
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{Geom, PhType, Presentation, Shape, ShapeId, ShapeKind, Slide};
|
||||
use deckcraft_text::datetime::{self, DateTime};
|
||||
use deckcraft_text::{Fields, Opts};
|
||||
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2};
|
||||
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
|
||||
use vello_cpu::{RenderContext, Resources, peniko};
|
||||
|
||||
pub use images::decode as decode_image;
|
||||
pub use images::{decode as decode_image, gif_frame};
|
||||
pub use morph_path::PathMorph;
|
||||
pub use morph_text::TextMorph;
|
||||
pub use placed::{Placed, PlacedLink, PlacedText, place_slide};
|
||||
pub use table::row_heights as table_row_heights;
|
||||
|
||||
/// Largest pixmap side we render (vello_cpu uses u16 sizes).
|
||||
pub const MAX_SIDE: u32 = 16_000;
|
||||
@@ -140,6 +148,8 @@ pub struct RenderOpts<'a> {
|
||||
pub skip: &'a [ShapeId],
|
||||
/// Hide the text of this shape (the UI draws it while editing).
|
||||
pub skip_text: Option<ShapeId>,
|
||||
/// Animated GIF pictures and how far into their play they are (seconds); others show frame 1.
|
||||
pub gif_times: &'a [(ShapeId, f64)],
|
||||
/// Worker threads (0 = this thread; required when effects use filters).
|
||||
pub threads: u16,
|
||||
/// Translate the slide within the output (pixels) and output size override.
|
||||
@@ -147,7 +157,10 @@ pub struct RenderOpts<'a> {
|
||||
pub size: Option<(u32, u32)>,
|
||||
/// Draw a background colour outside the slide (output larger than slide).
|
||||
pub clear: Option<Rgba>,
|
||||
/// Show this text in every date field instead of the date.
|
||||
pub date_text: Option<String>,
|
||||
/// The local date and time date fields show (`None`: the system clock).
|
||||
pub now: Option<DateTime>,
|
||||
}
|
||||
|
||||
impl Default for RenderOpts<'_> {
|
||||
@@ -159,28 +172,45 @@ impl Default for RenderOpts<'_> {
|
||||
state: None,
|
||||
skip: &[],
|
||||
skip_text: None,
|
||||
gif_times: &[],
|
||||
threads: 0,
|
||||
offset: (0.0, 0.0),
|
||||
size: None,
|
||||
clear: None,
|
||||
date_text: None,
|
||||
now: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SlideFields {
|
||||
num: u32,
|
||||
date: String,
|
||||
/// Text for every date field instead of the date ([`RenderOpts::date_text`]).
|
||||
date: Option<String>,
|
||||
footer: String,
|
||||
/// The local date and time date fields show; `None`: they keep their saved text.
|
||||
now: Option<DateTime>,
|
||||
}
|
||||
|
||||
impl SlideFields {
|
||||
fn new(num: u32, footer: String, opts: &RenderOpts) -> Self {
|
||||
SlideFields { num, date: opts.date_text.clone(), footer, now: opts.now.or_else(DateTime::now_local) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Fields for SlideFields {
|
||||
fn field(&self, kind: &str) -> Option<String> {
|
||||
self.field_in(kind, None)
|
||||
}
|
||||
|
||||
fn field_in(&self, kind: &str, lang: Option<&str>) -> Option<String> {
|
||||
if kind == "slidenum" {
|
||||
return Some(self.num.to_string());
|
||||
}
|
||||
if kind.starts_with("datetime") {
|
||||
return Some(self.date.clone());
|
||||
if datetime::is_date_field(kind) {
|
||||
// An automatic date: today's, in the format of its type. (A fixed date is plain text
|
||||
// in the slide's placeholder, not a field.)
|
||||
return self.date.clone().or_else(|| datetime::field_text(kind, lang, self.now?));
|
||||
}
|
||||
if kind == "footer" {
|
||||
return Some(self.footer.clone());
|
||||
@@ -191,11 +221,16 @@ impl Fields for SlideFields {
|
||||
|
||||
pub struct Renderer {
|
||||
resources: Resources,
|
||||
/// Outlines morphing in the frame being drawn by `blend`.
|
||||
paths: Vec<PathMorph>,
|
||||
/// The background of the slide (layout, master) being drawn, its placeholder colour and the
|
||||
/// slide's view transform: shapes filled with the background (`useBgFill`) show it through.
|
||||
bg: Option<(Fill, Option<Rgba>, Affine)>,
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
fn default() -> Self {
|
||||
Renderer { resources: Resources::new() }
|
||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +248,7 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
|
||||
return;
|
||||
}
|
||||
let (e, _) = resolve::effects(&rctx, s);
|
||||
if e.is_some_and(|e| !e.is_empty()) {
|
||||
if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) {
|
||||
any = true;
|
||||
}
|
||||
});
|
||||
@@ -226,6 +261,20 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
|
||||
any
|
||||
}
|
||||
|
||||
/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's
|
||||
/// text styles it inherits)? Such text is blurred, which needs filters.
|
||||
fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
|
||||
use deckcraft_model::text::{ListStyle, RunProps};
|
||||
let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0);
|
||||
let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run));
|
||||
let Some(t) = &s.text else { return false };
|
||||
t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props))
|
||||
|| styled(&t.list_style)
|
||||
|| styled(&rctx.master.title_style)
|
||||
|| styled(&rctx.master.body_style)
|
||||
|| styled(&rctx.master.other_style)
|
||||
}
|
||||
|
||||
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
|
||||
/// blank image (logged), never a crash.
|
||||
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
|
||||
@@ -241,6 +290,36 @@ pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Ima
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the
|
||||
/// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by
|
||||
/// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`:
|
||||
/// `old`). Pairs of shapes in `text` draw their boxes as usual but their texts morph by words or
|
||||
/// characters ([`TextMorph`]), on top of the later shape of the pair. Shapes listed in `paths` draw
|
||||
/// an outline morphing from another geometry into their own ([`PathMorph`]). `opts.state` applies
|
||||
/// to `shapes` only.
|
||||
pub fn render_blend(
|
||||
pres: &Presentation,
|
||||
old: usize,
|
||||
new: usize,
|
||||
mix: f64,
|
||||
shapes: &[(Shape, bool)],
|
||||
text: &[TextMorph],
|
||||
paths: &[PathMorph],
|
||||
opts: &RenderOpts,
|
||||
) -> Image {
|
||||
let (Some(a), Some(b)) = (pres.slides.get(old), pres.slides.get(new)) else { return Image::default() };
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
RENDERER.with(|r| r.borrow_mut().blend(pres, (a, old), (b, new), mix, shapes, text, paths, opts))
|
||||
})) {
|
||||
Ok(img) => img,
|
||||
Err(_) => {
|
||||
log::error!("rendering a frame between slides {old} and {new} panicked; showing the new slide");
|
||||
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
|
||||
render_slide(pres, new, opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a layout (Slide Master view thumbnail and canvas).
|
||||
pub fn render_layout(pres: &Presentation, master: usize, layout: Option<usize>, opts: &RenderOpts) -> Image {
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)))) {
|
||||
@@ -371,46 +450,52 @@ struct Frame<'a> {
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
|
||||
fn canvas(pres: &Presentation, opts: &RenderOpts, filters: bool) -> (RenderContext, u16, u16, Affine) {
|
||||
let (w, h) = output_size(pres, opts);
|
||||
let mut ctx = RenderContext::new_with(
|
||||
w,
|
||||
h,
|
||||
vello_cpu::RenderSettings {
|
||||
num_threads: if cfg!(target_arch = "wasm32") || needs_filters(pres, slide) { 0 } else { opts.threads },
|
||||
..Default::default()
|
||||
},
|
||||
vello_cpu::RenderSettings { num_threads: if cfg!(target_arch = "wasm32") || filters { 0 } else { opts.threads }, ..Default::default() },
|
||||
);
|
||||
if let Some(c) = opts.clear {
|
||||
ctx.set_paint(color(c, 1.0));
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
}
|
||||
let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale);
|
||||
let fields = SlideFields {
|
||||
num: pres.slide_number(index),
|
||||
date: opts.date_text.clone().unwrap_or_else(|| pres.header_footer.date_text.clone()),
|
||||
footer: pres.header_footer.footer_text.clone(),
|
||||
};
|
||||
let f = Frame { pres, opts, fields };
|
||||
(ctx, w, h, view)
|
||||
}
|
||||
|
||||
fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields {
|
||||
SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts)
|
||||
}
|
||||
|
||||
/// Background plus the master and layout graphics the slide shows.
|
||||
fn backdrop(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: &Slide, view: Affine) {
|
||||
self.background(ctx, f, rctx, Some(slide), view);
|
||||
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
|
||||
if show_master {
|
||||
let mctx = Ctx::for_master(f.pres, rctx.master);
|
||||
for s in &rctx.master.shapes {
|
||||
if s.ph.is_none() {
|
||||
self.shape(ctx, f, &mctx, s, view, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if show_layout && let Some(l) = rctx.layout {
|
||||
let lctx = Ctx::for_layout(f.pres, rctx.master, l);
|
||||
for s in &l.shapes {
|
||||
if s.ph.is_none() {
|
||||
self.shape(ctx, f, &lctx, s, view, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
|
||||
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
|
||||
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
|
||||
if let Some(rctx) = Ctx::for_slide(pres, slide) {
|
||||
self.background(&mut ctx, &f, &rctx, Some(slide), view);
|
||||
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
|
||||
if show_master {
|
||||
let mctx = Ctx::for_master(pres, rctx.master);
|
||||
for s in &rctx.master.shapes {
|
||||
if s.ph.is_none() {
|
||||
self.shape(&mut ctx, &f, &mctx, s, view, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if show_layout && let Some(l) = rctx.layout {
|
||||
let lctx = Ctx::for_layout(pres, rctx.master, l);
|
||||
for s in &l.shapes {
|
||||
if s.ph.is_none() {
|
||||
self.shape(&mut ctx, &f, &lctx, s, view, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.backdrop(&mut ctx, &f, &rctx, slide, view);
|
||||
for s in &slide.shapes {
|
||||
self.shape(&mut ctx, &f, &rctx, s, view, 0);
|
||||
}
|
||||
@@ -418,11 +503,69 @@ impl Renderer {
|
||||
self.finish(ctx, w, h)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn blend(
|
||||
&mut self,
|
||||
pres: &Presentation,
|
||||
old: (&Slide, usize),
|
||||
new: (&Slide, usize),
|
||||
mix: f64,
|
||||
shapes: &[(Shape, bool)],
|
||||
text: &[TextMorph],
|
||||
paths: &[PathMorph],
|
||||
opts: &RenderOpts,
|
||||
) -> Image {
|
||||
let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 };
|
||||
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0));
|
||||
let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) };
|
||||
// Backdrop graphics never take shape states: their ids may clash with the frame's shapes.
|
||||
let bopts = RenderOpts { state: None, date_text: opts.date_text.clone(), ..*opts };
|
||||
if mix < 1.0 {
|
||||
let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, old.1, opts) };
|
||||
self.backdrop(&mut ctx, &f, &co, old.0, view);
|
||||
}
|
||||
if mix > 0.0 {
|
||||
let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, new.1, opts) };
|
||||
let layered = mix < 1.0;
|
||||
if layered {
|
||||
ctx.push_layer(None, None, Some(mix as f32), None, None);
|
||||
}
|
||||
self.backdrop(&mut ctx, &f, &cn, new.0, view);
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
let fo = Frame { pres, opts, fields: Self::fields(pres, old.1, opts) };
|
||||
let fnew = Frame { pres, opts, fields: Self::fields(pres, new.1, opts) };
|
||||
let mut drawn: Vec<ShapeId> = Vec::new();
|
||||
// Set only now: the backdrop's ids may clash with the frame's.
|
||||
self.paths = paths.to_vec();
|
||||
for (s, from_old) in shapes {
|
||||
let (f, c) = if *from_old { (&fo, &co) } else { (&fnew, &cn) };
|
||||
let Some(tm) = text.iter().find(|m| m.old == s.id || m.new == s.id) else {
|
||||
self.shape(&mut ctx, f, c, s, view, 0);
|
||||
continue;
|
||||
};
|
||||
self.shape(&mut ctx, f, c, &Shape { text: None, ..s.clone() }, view, 0);
|
||||
drawn.push(s.id);
|
||||
if !(drawn.contains(&tm.old) && drawn.contains(&tm.new)) {
|
||||
continue;
|
||||
}
|
||||
let find = |id: ShapeId| shapes.iter().map(|(s, _)| s).find(|s| s.id == id);
|
||||
if let (Some(a), Some(b)) = (find(tm.old), find(tm.new)) {
|
||||
let now = b.xfrm.unwrap_or_else(|| resolve::xfrm(&cn, b));
|
||||
morph_text::draw(&mut ctx, (&co, a, &fo.fields), (&cn, b, &fnew.fields), tm, now, view);
|
||||
}
|
||||
}
|
||||
self.paths.clear();
|
||||
self.finish(ctx, w, h)
|
||||
}
|
||||
|
||||
fn layout(&mut self, pres: &Presentation, mi: usize, li: Option<usize>, opts: &RenderOpts) -> Image {
|
||||
let (w, h) = output_size(pres, opts);
|
||||
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale);
|
||||
let f = Frame { pres, opts, fields: SlideFields { num: 1, date: String::new(), footer: String::new() } };
|
||||
let f = Frame { pres, opts, fields: SlideFields::new(1, String::new(), opts) };
|
||||
if let Some(m) = pres.masters.get(mi) {
|
||||
match li.and_then(|i| m.layouts.get(i)) {
|
||||
Some(l) => {
|
||||
@@ -464,7 +607,12 @@ impl Renderer {
|
||||
let r = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
|
||||
ctx.set_transform(view);
|
||||
let path = r.to_path(0.1);
|
||||
// PowerPoint lays every background over white: a picture stretched into part of the
|
||||
// slide, or a see-through colour, leaves white around and under it.
|
||||
ctx.set_paint(color(Rgba::WHITE, 1.0));
|
||||
ctx.fill_path(&path);
|
||||
paint::fill_path(ctx, rctx, &fill, ph, &path, r, view, FillMode::Norm, 1.0, f.pres);
|
||||
self.bg = Some((fill, ph, view));
|
||||
}
|
||||
|
||||
fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
|
||||
@@ -529,7 +677,11 @@ impl Renderer {
|
||||
}
|
||||
|
||||
fn geometry_shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState) {
|
||||
let geo = shape_geometry(s, w, h);
|
||||
let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) {
|
||||
Some(pm) => morph_path::geometry(pm, s, w, h),
|
||||
None => (shape_geometry(s, w, h), Vec::new()),
|
||||
};
|
||||
let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 });
|
||||
let (fill, fill_ph) = resolve::fill(rctx, s);
|
||||
let (line, line_ph) = resolve::line(rctx, s);
|
||||
let (effects, fx_ph) = resolve::effects(rctx, s);
|
||||
@@ -554,7 +706,8 @@ impl Renderer {
|
||||
ctx.set_transform(m);
|
||||
let bounds = Rect::new(0.0, 0.0, w, h);
|
||||
ctx.push_clip_layer(&outline);
|
||||
paint::picture(ctx, f.pres, pf, bounds, m);
|
||||
let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1);
|
||||
paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
ShapeKind::Media(mc) => {
|
||||
@@ -562,7 +715,7 @@ impl Renderer {
|
||||
let bounds = Rect::new(0.0, 0.0, w, h);
|
||||
if let Some(p) = mc.poster {
|
||||
let pf = deckcraft_model::style::PictureFill { media: p, ..Default::default() };
|
||||
paint::picture(ctx, f.pres, &pf, bounds, m);
|
||||
paint::picture(ctx, f.pres, &pf, bounds, m, None);
|
||||
} else if mc.video {
|
||||
ctx.set_paint(peniko::Color::from_rgba8(20, 20, 24, 255));
|
||||
ctx.fill_rect(&bounds);
|
||||
@@ -575,13 +728,22 @@ impl Renderer {
|
||||
if let Some(fl) = &fill
|
||||
&& !(empty_ph && f.opts.edit && s.fill.is_none())
|
||||
{
|
||||
for sp in &geo.paths {
|
||||
if sp.fill == FillMode::None {
|
||||
for (i, sp) in geo.paths.iter().enumerate() {
|
||||
if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, 1.0, f.pres);
|
||||
match (fl, &self.bg) {
|
||||
// `useBgFill`: the slide's background, laid out on the slide (not the
|
||||
// shape), shows through the shape.
|
||||
(Fill::Background, Some((bg, ph, view))) => {
|
||||
let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
|
||||
let path = (view.inverse() * m) * sp.path.clone();
|
||||
paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
|
||||
}
|
||||
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
}
|
||||
}
|
||||
@@ -590,8 +752,8 @@ impl Renderer {
|
||||
// Outline.
|
||||
if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
|
||||
let lw = line.width.unwrap_or(0.75).max(0.0);
|
||||
for sp in geo.paths.iter().filter(|p| p.stroke) {
|
||||
stroke(ctx, rctx, &line, line_ph, &sp.path, m, lw);
|
||||
for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) {
|
||||
stroke(ctx, rctx, &line, line_ph, &sp.path, m, lw, fade(i).stroke);
|
||||
}
|
||||
if s.is_line() || geo.is_open() {
|
||||
arrowheads(ctx, rctx, &line, line_ph, &geo, m, lw);
|
||||
@@ -746,7 +908,7 @@ fn blur(sigma: f64) -> Filter {
|
||||
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.clamp(0.0, 500.0) as f32, edge_mode: EdgeMode::None })
|
||||
}
|
||||
|
||||
fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, path: &BezPath, m: Affine, lw: f64) {
|
||||
fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, path: &BezPath, m: Affine, lw: f64, alpha: f64) {
|
||||
use deckcraft_model::style::{LineCap, LineJoin};
|
||||
let cap = match line.cap.unwrap_or_default() {
|
||||
LineCap::Flat => kurbo::Cap::Butt,
|
||||
@@ -770,13 +932,13 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, pa
|
||||
ctx.set_transform(m);
|
||||
match &line.fill {
|
||||
Some(Fill::Solid { color: c }) => {
|
||||
ctx.set_paint(color(rctx.color(c, ph), 1.0));
|
||||
ctx.set_paint(color(rctx.color(c, ph), alpha));
|
||||
}
|
||||
Some(Fill::Gradient(g)) => {
|
||||
let c = g.stops.first().map(|s| rctx.color(&s.color, ph)).unwrap_or(Rgba::BLACK);
|
||||
ctx.set_paint(color(c, 1.0));
|
||||
ctx.set_paint(color(c, alpha));
|
||||
}
|
||||
_ => ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)),
|
||||
_ => ctx.set_paint(color(Rgba::BLACK, alpha)),
|
||||
}
|
||||
ctx.set_stroke(sk);
|
||||
ctx.stroke_path(path);
|
||||
@@ -936,6 +1098,25 @@ pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody
|
||||
draw_layout(ctx, &l, tr, m);
|
||||
}
|
||||
|
||||
/// Where text laid out in text rect `tr` is drawn, for a shape drawn with `m` (shape-local →
|
||||
/// output): `m`, without mirroring, then the layout's `rotation` about the rect's centre.
|
||||
///
|
||||
/// Flips mirror a shape's geometry, never its text. When `m` reflects (flipH or flipV, the shape's
|
||||
/// own or a group's) the text rect keeps its mirrored place and the text is mirrored back about
|
||||
/// the rect's centre: a flipH shape's text stays readable and a flipV shape's text is turned 180°
|
||||
/// (both flips: 180°), as in PowerPoint. Rendering, hit testing and the caret all use this.
|
||||
pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine {
|
||||
let c = tr.center().to_vec2();
|
||||
let mut t = m;
|
||||
if m.determinant() < 0.0 && c.is_finite() {
|
||||
t = t * Affine::translate(c) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate(-c);
|
||||
}
|
||||
if rotation != 0.0 && rotation.is_finite() && c.is_finite() {
|
||||
t = t * Affine::translate(c) * Affine::rotate(rotation.to_radians()) * Affine::translate(-c);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Draw an already laid-out text block.
|
||||
pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) {
|
||||
draw_layout_with(ctx, l, tr, m, &[]);
|
||||
@@ -944,12 +1125,7 @@ pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr:
|
||||
/// Draw a laid-out text block with per-paragraph animation states.
|
||||
pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) {
|
||||
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
|
||||
let m = if l.rotation != 0.0 {
|
||||
let c = tr.center().to_vec2();
|
||||
m * Affine::translate(c) * Affine::rotate(l.rotation.to_radians()) * Affine::translate(-c)
|
||||
} else {
|
||||
m
|
||||
};
|
||||
let m = text_transform(m, tr, l.rotation);
|
||||
let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| {
|
||||
let ps = para(d.para);
|
||||
if !ps.visible || ps.opacity <= 0.001 {
|
||||
@@ -962,6 +1138,15 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
for d in l.decos.iter().filter(|d| d.behind) {
|
||||
deco(ctx, d);
|
||||
}
|
||||
// Text shadows, then glows, under all the text.
|
||||
for pass in [TextEffect::Shadow, TextEffect::Glow] {
|
||||
for run in &l.runs {
|
||||
let ps = para(run.para);
|
||||
if ps.visible && ps.opacity > 0.001 {
|
||||
text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
for run in &l.runs {
|
||||
if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
|
||||
continue;
|
||||
@@ -971,30 +1156,24 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
continue;
|
||||
}
|
||||
let m = m * Affine::translate(ps.offset);
|
||||
let alpha = run.alpha * ps.opacity;
|
||||
let k = run.size / run.face.upem.max(1.0);
|
||||
ctx.set_paint(color(run.color, alpha));
|
||||
// A gradient fill starts over on every line and spans that line's text (from its top to
|
||||
// its bottom, from its first to its last character), as in PowerPoint.
|
||||
let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new();
|
||||
for (gid, x, y) in &run.glyphs {
|
||||
let path = glyph(&run.face, *gid);
|
||||
if path.elements().is_empty() {
|
||||
let at = m * Affine::translate((*x, *y));
|
||||
let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else {
|
||||
draw_glyph(ctx, run, *gid, at, ps.opacity);
|
||||
continue;
|
||||
};
|
||||
let li = line_at(l, run.para, *y);
|
||||
if !grads.iter().any(|(i, _)| *i == li) {
|
||||
let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] };
|
||||
let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect();
|
||||
let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr);
|
||||
grads.push((li, paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity)));
|
||||
}
|
||||
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let gm = m * Affine::translate((*x, *y)) * skew * Affine::scale(k);
|
||||
ctx.set_transform(gm);
|
||||
if alpha > 0.0 && run.color.a > 0 {
|
||||
ctx.fill_path(&path);
|
||||
if run.fake_bold {
|
||||
ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
if let Some((oc, ow)) = run.outline {
|
||||
ctx.set_paint(color(oc, ps.opacity));
|
||||
ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
ctx.set_paint(color(run.color, alpha));
|
||||
}
|
||||
let fill = grads.iter().find(|(i, _)| *i == li).map(|(_, g)| (g, Affine::translate((-*x, -*y))));
|
||||
draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill);
|
||||
}
|
||||
}
|
||||
for d in l.decos.iter().filter(|d| !d.behind) {
|
||||
@@ -1002,6 +1181,120 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
}
|
||||
}
|
||||
|
||||
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
|
||||
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
|
||||
l.lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, li)| li.para == para)
|
||||
.min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs()))
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The box a line's text covers: its top and bottom, its first and last caret.
|
||||
fn line_bounds(li: &deckcraft_text::LineInfo) -> Option<Rect> {
|
||||
let (x0, x1) = li.caret_x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(*x), b.max(*x)));
|
||||
let r = Rect::new(x0, li.top, x1, li.bottom);
|
||||
(r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum TextEffect {
|
||||
Shadow,
|
||||
Glow,
|
||||
}
|
||||
|
||||
/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`.
|
||||
fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) {
|
||||
let Some(fx) = run.effects else { return };
|
||||
let k = run.size / run.face.upem.max(1.0);
|
||||
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let px = scale_of(m);
|
||||
let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) {
|
||||
(TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0),
|
||||
// Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r
|
||||
// past the glyph's edge and fades out by about two thirds of r.
|
||||
(TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68),
|
||||
_ => return,
|
||||
};
|
||||
let alpha = (c.a as f64 / 255.0) * opacity;
|
||||
if alpha <= 0.0 {
|
||||
return;
|
||||
}
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma)));
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
for (gid, x, y) in &run.glyphs {
|
||||
let path = glyph(&run.face, *gid);
|
||||
if path.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The offset is in slide space: a shadow falls the same way whatever the text's rotation.
|
||||
let at = pre_translate(m * Affine::translate((*x, *y)), offset * px) * skew * Affine::scale(k);
|
||||
ctx.set_transform(at);
|
||||
ctx.fill_path(&path);
|
||||
if grow > 0.0 {
|
||||
ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
ctx.pop_layer();
|
||||
}
|
||||
|
||||
/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`.
|
||||
pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) {
|
||||
draw_glyph_with(ctx, run, gid, at, opacity, None);
|
||||
}
|
||||
|
||||
/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from
|
||||
/// the glyph's origin to that space) instead of the run's colour.
|
||||
fn draw_glyph_with(
|
||||
ctx: &mut RenderContext,
|
||||
run: &deckcraft_text::GlyphRun,
|
||||
gid: u32,
|
||||
at: Affine,
|
||||
opacity: f64,
|
||||
fill: Option<(&peniko::Gradient, Affine)>,
|
||||
) {
|
||||
let alpha = run.alpha * opacity;
|
||||
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
|
||||
return;
|
||||
}
|
||||
let k = run.size / run.face.upem.max(1.0);
|
||||
// Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still
|
||||
// strokes the plain glyph below.
|
||||
if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) {
|
||||
return;
|
||||
}
|
||||
let path = glyph(&run.face, gid);
|
||||
if path.elements().is_empty() {
|
||||
return;
|
||||
}
|
||||
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
ctx.set_transform(at * skew * Affine::scale(k));
|
||||
if let Some((g, to_text)) = fill
|
||||
&& alpha > 0.0
|
||||
{
|
||||
ctx.set_paint(g.clone());
|
||||
ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text);
|
||||
ctx.fill_path(&path);
|
||||
ctx.reset_paint_transform();
|
||||
} else if alpha > 0.0 && run.color.a > 0 {
|
||||
ctx.set_paint(color(run.color, alpha));
|
||||
ctx.fill_path(&path);
|
||||
if run.fake_bold {
|
||||
ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
if let Some((oc, ow)) = run.outline {
|
||||
ctx.set_paint(color(oc, opacity));
|
||||
ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render one shape alone onto a transparent canvas (drag previews, copy as picture).
|
||||
pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option<Image> {
|
||||
let rctx = Ctx::for_slide(pres, slide)?;
|
||||
@@ -1012,7 +1305,7 @@ pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64)
|
||||
let h = (b.height() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16;
|
||||
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
let opts = RenderOpts { scale, ..Default::default() };
|
||||
let f = Frame { pres, opts: &opts, fields: SlideFields { num: 1, date: String::new(), footer: String::new() } };
|
||||
let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) };
|
||||
let view = Affine::scale(scale) * Affine::translate(-b.origin().to_vec2());
|
||||
RENDERER.with(|r| {
|
||||
let mut r = r.borrow_mut();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Morph of an outline: a frame shape whose geometry turns from the old shape's into its own (a
|
||||
//! circle into a star), with the points aligned by `deckcraft_geom::morph`.
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_geom::morph::{Fade, GeometryMorph};
|
||||
use deckcraft_geom::preset::Geometry;
|
||||
use deckcraft_model::{Geom, Shape, ShapeId};
|
||||
use kurbo::Vec2;
|
||||
|
||||
use crate::shape_geometry;
|
||||
|
||||
/// A frame shape (see [`crate::render_blend`]) whose outline morphs from `from` into its own
|
||||
/// geometry.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PathMorph {
|
||||
pub id: ShapeId,
|
||||
/// The old shape's geometry.
|
||||
pub from: Geom,
|
||||
/// The boxes of the old and the new shape on their own slides.
|
||||
pub from_box: Xfrm,
|
||||
pub to_box: Xfrm,
|
||||
/// Progress, 0..1 (already eased).
|
||||
pub t: f64,
|
||||
}
|
||||
|
||||
/// `now / own`, or 1 when that is no usable scale (a line's zero height).
|
||||
fn ratio(now: f64, own: f64) -> f64 {
|
||||
let r = now / own;
|
||||
if own.abs() > 1e-9 && r.is_finite() { r } else { 1.0 }
|
||||
}
|
||||
|
||||
/// The geometry of frame shape `s` at its current size `w` x `h`: both outlines are built at their
|
||||
/// own sizes (so the alignment does not change while the box grows), then scaled into the box.
|
||||
/// With a [`Fade`] per sub-path.
|
||||
pub(crate) fn geometry(pm: &PathMorph, s: &Shape, w: f64, h: f64) -> (Geometry, Vec<Fade>) {
|
||||
let a = shape_geometry(&Shape { geom: pm.from.clone(), ..Shape::default() }, pm.from_box.w, pm.from_box.h);
|
||||
let b = shape_geometry(s, pm.to_box.w, pm.to_box.h);
|
||||
let scale = |x: Xfrm| Vec2::new(ratio(w, x.w), ratio(h, x.h));
|
||||
GeometryMorph::new(&a, &b).at(pm.t, scale(pm.from_box), scale(pm.to_box))
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Morph by words or characters: the words (or characters) two texts share travel from their place
|
||||
//! in the old text to their place in the new one, growing or shrinking to the new size; the rest
|
||||
//! of the old text fades out and the rest of the new one fades in, both riding the moving box.
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::{Shape, ShapeId};
|
||||
use deckcraft_text::{Fields, GlyphRun, Opts, TextLayout};
|
||||
use kurbo::{Affine, Point, Vec2};
|
||||
use vello_cpu::RenderContext;
|
||||
|
||||
use crate::{color, draw_glyph, draw_layout, shape_geometry, text_transform};
|
||||
|
||||
/// The texts of two frame shapes (see [`crate::render_blend`]) morphed by words or characters.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TextMorph {
|
||||
/// The frame shape from the old slide and the one from the new slide.
|
||||
pub old: ShapeId,
|
||||
pub new: ShapeId,
|
||||
/// Their boxes on their own slides: where the shared words start and end.
|
||||
pub from: Xfrm,
|
||||
pub to: Xfrm,
|
||||
/// Characters instead of words.
|
||||
pub chars: bool,
|
||||
/// Progress, 0..1 (already eased).
|
||||
pub t: f64,
|
||||
}
|
||||
|
||||
/// A word or character: its text, its glyphs (run, glyph) and the origin of its first glyph.
|
||||
struct Token {
|
||||
key: String,
|
||||
glyphs: Vec<(usize, usize)>,
|
||||
origin: Point,
|
||||
run: usize,
|
||||
}
|
||||
|
||||
/// Words (or characters) of a layout in reading order, plus glyphs that belong to none (bullets).
|
||||
fn tokens(l: &TextLayout, chars: bool) -> (Vec<Token>, Vec<(usize, usize)>) {
|
||||
let mut cells: Vec<(usize, usize, char, usize, usize)> = Vec::new();
|
||||
let mut loose = Vec::new();
|
||||
for (ri, run) in l.runs.iter().enumerate() {
|
||||
for gi in 0..run.glyphs.len() {
|
||||
match run.cells.get(gi) {
|
||||
Some((ci, ch)) => cells.push((run.para, *ci, *ch, ri, gi)),
|
||||
None => loose.push((ri, gi)),
|
||||
}
|
||||
}
|
||||
}
|
||||
cells.sort_by_key(|c| (c.0, c.1, c.3, c.4));
|
||||
let mut out: Vec<Token> = Vec::new();
|
||||
let mut last: Option<(usize, usize)> = None;
|
||||
for (para, ci, ch, ri, gi) in cells {
|
||||
if ch.is_whitespace() {
|
||||
last = None;
|
||||
continue;
|
||||
}
|
||||
let same_char = last == Some((para, ci));
|
||||
let next_char = last.is_some_and(|(p, c)| p == para && c.checked_add(1) == Some(ci));
|
||||
let extend = same_char || (!chars && next_char);
|
||||
match out.last_mut() {
|
||||
Some(tok) if extend => {
|
||||
if !same_char {
|
||||
tok.key.push(ch);
|
||||
}
|
||||
tok.glyphs.push((ri, gi));
|
||||
}
|
||||
_ => {
|
||||
let origin = l.runs.get(ri).and_then(|r| r.glyphs.get(gi)).map(|g| Point::new(g.1, g.2)).unwrap_or(Point::ZERO);
|
||||
out.push(Token { key: ch.to_string(), glyphs: vec![(ri, gi)], origin, run: ri });
|
||||
}
|
||||
}
|
||||
last = Some((para, ci));
|
||||
}
|
||||
(out, loose)
|
||||
}
|
||||
|
||||
/// For each token of `b`, the token of `a` with the same text, in reading order, each used once.
|
||||
fn matches(a: &[Token], b: &[Token]) -> Vec<Option<usize>> {
|
||||
let mut used = vec![false; a.len()];
|
||||
b.iter()
|
||||
.map(|tb| {
|
||||
let i = a.iter().enumerate().position(|(i, ta)| !used.get(i).copied().unwrap_or(true) && ta.key == tb.key)?;
|
||||
if let Some(u) = used.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
Some(i)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn layout(ctx: &Ctx, s: &Shape, x: Xfrm, fields: &dyn Fields) -> Option<TextLayout> {
|
||||
let body = s.text.as_ref().filter(|b| !b.is_empty())?;
|
||||
let rect = shape_geometry(s, x.w, x.h).text_rect;
|
||||
Some(deckcraft_text::layout(ctx, s, body, &Opts { rect, fields, prompt_color: None, no_shrink: false }))
|
||||
}
|
||||
|
||||
fn glyph_at(l: &TextLayout, (ri, gi): (usize, usize)) -> Option<(&GlyphRun, u32, Point)> {
|
||||
let run = l.runs.get(ri)?;
|
||||
let g = run.glyphs.get(gi)?;
|
||||
Some((run, g.0, Point::new(g.1, g.2)))
|
||||
}
|
||||
|
||||
fn same_look(a: &GlyphRun, b: &GlyphRun) -> bool {
|
||||
a.face.id() == b.face.id() && a.size == b.size && a.color == b.color && a.alpha == b.alpha && a.outline == b.outline
|
||||
}
|
||||
|
||||
fn lerp(a: f64, b: f64, t: f64) -> f64 {
|
||||
a + (b - a) * t
|
||||
}
|
||||
|
||||
/// Draw the morphing text of `a` (old slide, `ca`) and `b` (new slide, `cb`); `now` is the box both
|
||||
/// frame shapes share at this moment, `view` the slide-to-pixels transform.
|
||||
pub(crate) fn draw(
|
||||
ctx: &mut RenderContext,
|
||||
(ca, a, fa): (&Ctx, &Shape, &dyn Fields),
|
||||
(cb, b, fb): (&Ctx, &Shape, &dyn Fields),
|
||||
m: &TextMorph,
|
||||
now: Xfrm,
|
||||
view: Affine,
|
||||
) {
|
||||
let t = if m.t.is_finite() { m.t.clamp(0.0, 1.0) } else { 1.0 };
|
||||
let la = layout(ca, a, m.from, fa);
|
||||
let lb = layout(cb, b, m.to, fb);
|
||||
let mt = view * now.affine();
|
||||
// Rotated (vertical) text: no per-word travel, the two texts cross-fade on the moving box.
|
||||
let rotated = la.as_ref().is_some_and(|l| l.rotation != 0.0) || lb.as_ref().is_some_and(|l| l.rotation != 0.0);
|
||||
if rotated || la.is_none() || lb.is_none() {
|
||||
for (l, s, alpha, x) in [(la, a, 1.0 - t, m.from), (lb, b, t, m.to)] {
|
||||
if let Some(l) = l
|
||||
&& alpha > 0.001
|
||||
{
|
||||
ctx.push_layer(None, None, Some(alpha as f32), None, None);
|
||||
draw_layout(ctx, &l, shape_geometry(s, x.w, x.h).text_rect, mt);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (Some(la), Some(lb)) = (la, lb) else { return };
|
||||
let (ta, loose_a) = tokens(&la, m.chars);
|
||||
let (tb, loose_b) = tokens(&lb, m.chars);
|
||||
let pairs = matches(&ta, &tb);
|
||||
// Flipped boxes keep their text readable, as when drawn (see `text_transform`).
|
||||
let (tra, trb) = (shape_geometry(a, m.from.w, m.from.h).text_rect, shape_geometry(b, m.to.w, m.to.h).text_rect);
|
||||
let (ma, mb) = (text_transform(view * m.from.affine(), tra, 0.0), text_transform(view * m.to.affine(), trb, 0.0));
|
||||
let (mta, mtb) = (text_transform(mt, tra, 0.0), text_transform(mt, trb, 0.0));
|
||||
let c = mta.as_coeffs();
|
||||
let linear = Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]);
|
||||
let mut moved_a = vec![false; ta.len()];
|
||||
// Shared tokens travel.
|
||||
for (j, i) in pairs.iter().enumerate() {
|
||||
let (Some(i), Some(tok_b)) = (*i, tb.get(j)) else { continue };
|
||||
let Some(tok_a) = ta.get(i) else { continue };
|
||||
if let Some(f) = moved_a.get_mut(i) {
|
||||
*f = true;
|
||||
}
|
||||
let (Some(ra), Some(rb)) = (la.runs.get(tok_a.run), lb.runs.get(tok_b.run)) else { continue };
|
||||
let p = (ma * tok_a.origin).lerp(mb * tok_b.origin, t);
|
||||
let ratio = if ra.size > 0.0 && rb.size > 0.0 { rb.size / ra.size } else { 1.0 };
|
||||
let same = same_look(ra, rb);
|
||||
let alpha_a = if same { 1.0 } else { 1.0 - t };
|
||||
let sa = lerp(1.0, ratio, t);
|
||||
for &g in &tok_a.glyphs {
|
||||
if let Some((run, gid, pos)) = glyph_at(&la, g) {
|
||||
let at = Affine::translate(p.to_vec2()) * linear * Affine::scale(sa) * Affine::translate(pos - tok_a.origin);
|
||||
draw_glyph(ctx, run, gid, at, alpha_a);
|
||||
}
|
||||
}
|
||||
if !same {
|
||||
let sb = lerp(1.0 / ratio, 1.0, t);
|
||||
for &g in &tok_b.glyphs {
|
||||
if let Some((run, gid, pos)) = glyph_at(&lb, g) {
|
||||
let at = Affine::translate(p.to_vec2()) * linear * Affine::scale(sb) * Affine::translate(pos - tok_b.origin);
|
||||
draw_glyph(ctx, run, gid, at, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The rest fades while following the shared tokens: they define a scale and shift from the old
|
||||
// text to the new one (F(p) = s·p + c, in pixels), so a line grows, shrinks and rearranges as
|
||||
// one. With nothing shared it rides the moving box.
|
||||
let shared: Vec<(Point, Point, f64)> = pairs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(j, i)| {
|
||||
let (tok_a, tok_b) = (ta.get((*i)?)?, tb.get(j)?);
|
||||
let (ra, rb) = (la.runs.get(tok_a.run)?, lb.runs.get(tok_b.run)?);
|
||||
let ratio = if ra.size > 0.0 && rb.size > 0.0 { rb.size / ra.size } else { 1.0 };
|
||||
Some((ma * tok_a.origin, mb * tok_b.origin, ratio))
|
||||
})
|
||||
.collect();
|
||||
let n = shared.len() as f64;
|
||||
let s = shared.iter().map(|x| x.2).sum::<f64>() / n;
|
||||
let c = shared.iter().fold(Vec2::ZERO, |acc, (pa, pb, _)| acc + (pb.to_vec2() - pa.to_vec2() * s)) / n;
|
||||
let (place_a, place_b) = if !shared.is_empty() && s.is_finite() && s > 0.0 && c.is_finite() {
|
||||
// Old text: p ↦ lerp(p, F(p), t). New text: q ↦ lerp(F⁻¹(q), q, t).
|
||||
let a = Affine::translate(c * t) * Affine::scale(lerp(1.0, s, t));
|
||||
let b = Affine::translate(-c * ((1.0 - t) / s)) * Affine::scale((1.0 - t) / s + t);
|
||||
(a * ma, b * mb)
|
||||
} else {
|
||||
(mta, mtb)
|
||||
};
|
||||
let unmatched_a =
|
||||
ta.iter().enumerate().filter(|(i, _)| !moved_a.get(*i).copied().unwrap_or(false)).flat_map(|(_, tok)| tok.glyphs.iter().copied());
|
||||
for g in unmatched_a.chain(loose_a) {
|
||||
if let Some((run, gid, pos)) = glyph_at(&la, g) {
|
||||
draw_glyph(ctx, run, gid, place_a * Affine::translate(pos.to_vec2()), 1.0 - t);
|
||||
}
|
||||
}
|
||||
let unmatched_b =
|
||||
tb.iter().enumerate().filter(|(j, _)| pairs.get(*j).is_none_or(|p| p.is_none())).flat_map(|(_, tok)| tok.glyphs.iter().copied());
|
||||
for g in unmatched_b.chain(loose_b) {
|
||||
if let Some((run, gid, pos)) = glyph_at(&lb, g) {
|
||||
draw_glyph(ctx, run, gid, place_b * Affine::translate(pos.to_vec2()), t);
|
||||
}
|
||||
}
|
||||
// Underlines, strikethroughs and highlights fade with their text.
|
||||
for (l, alpha, place) in [(&la, 1.0 - t, place_a), (&lb, t, place_b)] {
|
||||
for d in &l.decos {
|
||||
ctx.set_transform(place);
|
||||
ctx.set_paint(color(d.color, alpha));
|
||||
ctx.fill_rect(&d.rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn token_keys(l: &TextLayout, chars: bool) -> Vec<String> {
|
||||
tokens(l, chars).0.into_iter().map(|t| t.key).collect()
|
||||
}
|
||||
+51
-31
@@ -3,7 +3,7 @@
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_geom::preset::FillMode;
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::style::{Fill, GradientShape, PictureFill, PictureMode};
|
||||
use deckcraft_model::style::{ColorRef, Fill, GradientShape, PictureFill, PictureMode};
|
||||
use deckcraft_model::{PhType, Presentation};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Shape as _};
|
||||
use vello_cpu::{RenderContext, peniko};
|
||||
@@ -35,37 +35,13 @@ pub fn fill_path(
|
||||
if g.stops.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut stops: Vec<(f32, peniko::Color)> =
|
||||
g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0) as f32, color(shade(rctx.color(&s.color, ph), mode), alpha))).collect();
|
||||
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let stops: Vec<peniko::ColorStop> = stops.into_iter().map(|(o, c)| peniko::ColorStop { offset: o, color: c.into() }).collect();
|
||||
let grad = match &g.shape {
|
||||
GradientShape::Linear { angle, .. } => {
|
||||
// Angle measured clockwise from left→right; span the bounds along that direction.
|
||||
let a = angle.to_radians();
|
||||
let (dx, dy) = (a.cos(), a.sin());
|
||||
let c = bounds.center();
|
||||
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
|
||||
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
|
||||
.with_stops(stops.as_slice())
|
||||
}
|
||||
GradientShape::Path { focus, .. } => {
|
||||
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
|
||||
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
|
||||
// Reach the farthest corner, so a gradient from a corner covers the whole box.
|
||||
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
|
||||
.iter()
|
||||
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
|
||||
.fold(1e-6, f64::max);
|
||||
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
|
||||
}
|
||||
};
|
||||
ctx.set_paint(grad);
|
||||
let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect();
|
||||
ctx.set_paint(gradient(&g.shape, stops, bounds, alpha));
|
||||
ctx.fill_path(path);
|
||||
}
|
||||
Fill::Picture(pf) => {
|
||||
ctx.push_clip_layer(path);
|
||||
picture(ctx, pres, pf, bounds, m);
|
||||
picture(ctx, pres, &resolved(rctx, pf, ph), bounds, m, None);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
Fill::Pattern(p) => {
|
||||
@@ -120,15 +96,59 @@ fn p_rect(x: f64, y: f64, w: f64, h: f64) -> BezPath {
|
||||
}
|
||||
fn p_add(_a: &mut BezPath, _b: &mut BezPath) {}
|
||||
|
||||
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes.
|
||||
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine) {
|
||||
/// `pf` with its duotone colours resolved to plain RGB against the slide's colours and the
|
||||
/// placeholder colour `ph` (theme background pictures recolour with `phClr`), so the decoded-image
|
||||
/// cache can apply and key them without a colour scheme.
|
||||
pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option<Rgba>) -> std::borrow::Cow<'a, PictureFill> {
|
||||
let Some((d, l)) = &pf.adjust.duotone else { return std::borrow::Cow::Borrowed(pf) };
|
||||
let mut out = pf.clone();
|
||||
out.adjust.duotone = Some((ColorRef::rgb(rctx.color(d, ph)), ColorRef::rgb(rctx.color(l, ph))));
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// The paint of a gradient of `shape` with `stops` (position, colour) spanning `bounds`, faded
|
||||
/// by `alpha`, ramping between stops as PowerPoint does (see `ramp`).
|
||||
pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> peniko::Gradient {
|
||||
for s in &mut stops {
|
||||
s.0 = s.0.clamp(0.0, 1.0);
|
||||
}
|
||||
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let stops: Vec<peniko::ColorStop> =
|
||||
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
|
||||
match shape {
|
||||
GradientShape::Linear { angle, .. } => {
|
||||
// Angle measured clockwise from left→right; span the bounds along that direction.
|
||||
let a = angle.to_radians();
|
||||
let (dx, dy) = (a.cos(), a.sin());
|
||||
let c = bounds.center();
|
||||
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
|
||||
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
|
||||
.with_stops(stops.as_slice())
|
||||
}
|
||||
GradientShape::Path { focus, .. } => {
|
||||
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
|
||||
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
|
||||
// Reach the farthest corner, so a gradient from a corner covers the whole box.
|
||||
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
|
||||
.iter()
|
||||
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
|
||||
.fold(1e-6, f64::max);
|
||||
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated
|
||||
/// GIF shows its frame at `gif_time` seconds.
|
||||
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option<f64>) {
|
||||
let Some(item) = pres.media(pf.media) else {
|
||||
missing(ctx, bounds);
|
||||
return;
|
||||
};
|
||||
let px_scale = crate::scale_of(m);
|
||||
let want = (bounds.width().max(bounds.height()) * px_scale).max(1.0);
|
||||
let Some(pm) = images::decode_for(&item.data, want, &pf.adjust) else {
|
||||
let frame = gif_time.and_then(|t| images::gif_pixmap(&item.data, &pf.adjust, t));
|
||||
let Some(pm) = frame.or_else(|| images::decode_for(&item.data, want, &pf.adjust)) else {
|
||||
missing(ctx, bounds);
|
||||
return;
|
||||
};
|
||||
|
||||
+31
-23
@@ -7,12 +7,12 @@ use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||
use deckcraft_text::{Opts, TextLayout};
|
||||
use kurbo::{Affine, Rect};
|
||||
|
||||
use crate::{SlideFields, shape_geometry};
|
||||
use crate::{SlideFields, shape_geometry, table};
|
||||
|
||||
/// One shape's text as drawn on the slide.
|
||||
pub struct PlacedText {
|
||||
pub layout: TextLayout,
|
||||
/// Shape-local → slide points (text rotation included).
|
||||
/// Shape-local → slide points (text rotation included, flips not: see [`crate::text_transform`]).
|
||||
pub transform: Affine,
|
||||
pub body: TextBody,
|
||||
}
|
||||
@@ -35,8 +35,7 @@ pub fn place_slide(pres: &Presentation, index: usize) -> Placed {
|
||||
let mut out = Placed::default();
|
||||
let Some(slide) = pres.slides.get(index) else { return out };
|
||||
let Some(rctx) = Ctx::for_slide(pres, slide) else { return out };
|
||||
let fields =
|
||||
SlideFields { num: pres.slide_number(index), date: pres.header_footer.date_text.clone(), footer: pres.header_footer.footer_text.clone() };
|
||||
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default());
|
||||
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
|
||||
if show_master {
|
||||
let mctx = Ctx::for_master(pres, rctx.master);
|
||||
@@ -76,32 +75,41 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
|
||||
place(out, rctx, fields, c, gm, depth + 1);
|
||||
}
|
||||
}
|
||||
ShapeKind::Table(_) | ShapeKind::Chart(_) | ShapeKind::Ink { .. } => {}
|
||||
ShapeKind::Table(t) => {
|
||||
// Cell text, laid out exactly as the table draws it.
|
||||
for (r, c, rect) in table::cell_rects(t, &table::row_heights(rctx, t, fields)) {
|
||||
let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue };
|
||||
let body = table::cell_body(rctx, t, r, c, cell);
|
||||
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
|
||||
place_text(out, rctx, fields, &tmp, &body, rect, m);
|
||||
}
|
||||
}
|
||||
ShapeKind::Chart(_) | ShapeKind::Ink { .. } => {}
|
||||
_ => {
|
||||
let Some(body) = s.text.as_ref().filter(|b| !b.is_empty()) else { return };
|
||||
let tr = shape_geometry(s, x.w, x.h).text_rect;
|
||||
let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false });
|
||||
let m = if layout.rotation != 0.0 {
|
||||
let c = tr.center().to_vec2();
|
||||
m * Affine::translate(c) * Affine::rotate(layout.rotation.to_radians()) * Affine::translate(-c)
|
||||
} else {
|
||||
m
|
||||
};
|
||||
// Text hyperlinks: one box per line segment of a linked run.
|
||||
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
|
||||
let Some(link) = run_link(body, run.para, run.chars.0) else { continue };
|
||||
let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue };
|
||||
let x0 = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
|
||||
let last = run.glyphs.last().map(|g| g.1).unwrap_or(x0);
|
||||
let x1 = line.caret_x.get(run.chars.1.saturating_sub(line.start)).copied().unwrap_or(last).max(last);
|
||||
let r = Rect::new(x0, line.top, x1, line.bottom);
|
||||
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link });
|
||||
}
|
||||
out.texts.push(PlacedText { layout, transform: m, body: body.clone() });
|
||||
place_text(out, rctx, fields, s, body, tr, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out `body` in text rect `tr` of shape `s` (shape-local → slide `m`) and record it and its hyperlinks.
|
||||
fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, body: &TextBody, tr: Rect, m: Affine) {
|
||||
let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false });
|
||||
let m = crate::text_transform(m, tr, layout.rotation);
|
||||
// Text hyperlinks: one box per line segment of a linked run.
|
||||
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
|
||||
let Some(link) = run_link(body, run.para, run.chars.0) else { continue };
|
||||
let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue };
|
||||
// The run's visual extent (right-to-left runs run from right to left).
|
||||
let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
|
||||
let (x0, x1) = line.span(run.chars.0, run.chars.1).unwrap_or((first, first));
|
||||
let r = Rect::new(x0, line.top, x1, line.bottom);
|
||||
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link });
|
||||
}
|
||||
out.texts.push(PlacedText { layout, transform: m, body: body.clone() });
|
||||
}
|
||||
|
||||
/// The hyperlink of the model run containing character `at` of paragraph `para`.
|
||||
fn run_link(body: &TextBody, para: usize, at: usize) -> Option<Hyperlink> {
|
||||
let p = body.paragraphs.get(para)?;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Gradient colour ramps as PowerPoint draws them.
|
||||
//!
|
||||
//! A gradient of exactly two stops, at 0 % and 100 % (what a two-colour gradient is), is not
|
||||
//! blended linearly in sRGB by PowerPoint: it mixes the colours in linear light, and the second
|
||||
//! colour's share follows an S-shaped curve of the position. Black to white is 105 (not 64) a
|
||||
//! quarter of the way, 186 (not 128) half way and 238 (not 191) three quarters of the way, in any
|
||||
//! direction, on shapes and on slide backgrounds. Gradients with three or more stops, or with a
|
||||
//! stop moved off an end, blend linearly in sRGB (measured from PowerPoint's own slide export).
|
||||
//! The renderer interpolates linearly in sRGB, so a two-colour gradient is expanded into short
|
||||
//! pieces whose colours follow PowerPoint's ramp.
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
|
||||
/// The next stop's share of the linear-light mix at sixteenths of a segment (0 at the first stop,
|
||||
/// 1 at the second). Measured, then made symmetric: `w(1 - t) = 1 - w(t)`.
|
||||
const WEIGHT: [f64; 17] =
|
||||
[0.0, 0.0205, 0.0469, 0.0878, 0.1431, 0.2143, 0.2989, 0.3972, 0.5, 0.6028, 0.7011, 0.7857, 0.8569, 0.9122, 0.9531, 0.9795, 1.0];
|
||||
|
||||
/// Pieces each segment between two stops is cut into.
|
||||
const PIECES: usize = 16;
|
||||
|
||||
/// The next stop's share at `t` (0..1) between two stops.
|
||||
pub fn weight(t: f64) -> f64 {
|
||||
if !t.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let x = t.clamp(0.0, 1.0) * (WEIGHT.len() - 1) as f64;
|
||||
let i = (x.floor() as usize).min(WEIGHT.len() - 2);
|
||||
let f = x - i as f64;
|
||||
let (a, b) = (WEIGHT.get(i).copied().unwrap_or(0.0), WEIGHT.get(i + 1).copied().unwrap_or(1.0));
|
||||
a + (b - a) * f
|
||||
}
|
||||
|
||||
fn to_linear(v: u8) -> f64 {
|
||||
let v = v as f64 / 255.0;
|
||||
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
fn to_srgb(v: f64) -> u8 {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
let s = if v <= 0.0031308 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
|
||||
(s * 255.0).round().clamp(0.0, 255.0) as u8
|
||||
}
|
||||
|
||||
/// The colour at `t` between stops `a` and `b`. Alpha mixes by the same share.
|
||||
pub fn mix(a: Rgba, b: Rgba, t: f64) -> Rgba {
|
||||
let w = weight(t);
|
||||
let ch = |x: u8, y: u8| to_srgb(to_linear(x) + (to_linear(y) - to_linear(x)) * w);
|
||||
Rgba { r: ch(a.r, b.r), g: ch(a.g, b.g), b: ch(a.b, b.b), a: (a.a as f64 + (b.a as f64 - a.a as f64) * w).round().clamp(0.0, 255.0) as u8 }
|
||||
}
|
||||
|
||||
/// Sorted stops (position 0..1, colour) as a renderer interpolating linearly in sRGB should get
|
||||
/// them: a two-colour gradient (two stops, at 0 and 1) expanded to follow PowerPoint's ramp,
|
||||
/// anything else unchanged.
|
||||
pub fn expand(stops: &[(f64, Rgba)]) -> Vec<(f64, Rgba)> {
|
||||
let two_colour = matches!(stops, [(p, _), (q, _)] if *p <= 1e-6 && *q >= 1.0 - 1e-6);
|
||||
if !two_colour {
|
||||
return stops.to_vec();
|
||||
}
|
||||
let mut out = Vec::with_capacity(stops.len() * PIECES + 1);
|
||||
for (i, &(p, c)) in stops.iter().enumerate() {
|
||||
out.push((p, c));
|
||||
let Some(&(q, d)) = stops.get(i + 1) else { continue };
|
||||
if q - p <= 1e-6 || c == d {
|
||||
continue;
|
||||
}
|
||||
for k in 1..PIECES {
|
||||
let t = k as f64 / PIECES as f64;
|
||||
out.push((p + (q - p) * t, mix(c, d, t)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn black_to_white_follows_powerpoint() {
|
||||
// PowerPoint's export: 105 at a quarter, 186 half way, 238 at three quarters.
|
||||
let (k, w) = (Rgba::rgb(0, 0, 0), Rgba::rgb(255, 255, 255));
|
||||
for (t, want) in [(0.25, 105), (0.5, 186), (0.75, 238)] {
|
||||
let got = mix(k, w, t).r as i32;
|
||||
assert!((got - want).abs() <= 3, "{t}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_pairs_follow_powerpoint() {
|
||||
// Blue (4472C4) to orange (ED7D31) half way: PowerPoint draws (178, 120, 146).
|
||||
let m = mix(Rgba::rgb(68, 114, 196), Rgba::rgb(237, 125, 49), 0.5);
|
||||
assert!((m.r as i32 - 178).abs() <= 3 && (m.g as i32 - 120).abs() <= 3 && (m.b as i32 - 146).abs() <= 3, "{m:?}");
|
||||
// Red to blue a quarter of the way: (238, 0, 105).
|
||||
let m = mix(Rgba::rgb(255, 0, 0), Rgba::rgb(0, 0, 255), 0.25);
|
||||
assert!((m.r as i32 - 238).abs() <= 3 && (m.b as i32 - 105).abs() <= 3, "{m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ends_are_the_stops_and_the_weight_is_symmetric() {
|
||||
let (a, b) = (Rgba::rgb(10, 200, 30), Rgba::rgb(240, 20, 90));
|
||||
assert_eq!(mix(a, b, 0.0), a);
|
||||
assert_eq!(mix(a, b, 1.0), b);
|
||||
for k in 0..=32 {
|
||||
let t = k as f64 / 32.0;
|
||||
assert!((weight(t) + weight(1.0 - t) - 1.0).abs() < 1e-9);
|
||||
assert!(weight(t) <= weight((t + 0.01).min(1.0)), "monotonic");
|
||||
}
|
||||
assert_eq!(weight(f64::NAN), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_keeps_stops_and_hard_edges() {
|
||||
let (r, g) = (Rgba::rgb(255, 0, 0), Rgba::rgb(0, 255, 0));
|
||||
// Three or more stops, or a stop off an end, blend linearly in sRGB: unchanged.
|
||||
let e = expand(&[(0.0, r), (0.5, r), (0.5, g), (1.0, g)]);
|
||||
assert_eq!(e, vec![(0.0, r), (0.5, r), (0.5, g), (1.0, g)]);
|
||||
assert_eq!(expand(&[(0.2, r), (1.0, g)]), vec![(0.2, r), (1.0, g)]);
|
||||
assert_eq!(expand(&[(0.0, r), (0.5, g), (1.0, r)]).len(), 3);
|
||||
// Two equal colours add nothing.
|
||||
assert_eq!(expand(&[(0.0, r), (1.0, r)]), vec![(0.0, r), (1.0, r)]);
|
||||
let e = expand(&[(0.0, r), (1.0, g)]);
|
||||
assert_eq!(e.len(), PIECES + 1);
|
||||
assert_eq!(e.first(), Some(&(0.0, r)));
|
||||
assert_eq!(e.last(), Some(&(1.0, g)));
|
||||
assert!(e.windows(2).all(|w| w[0].0 <= w[1].0));
|
||||
}
|
||||
}
|
||||
+73
-39
@@ -3,9 +3,10 @@
|
||||
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::style::{ColorRef, Fill};
|
||||
use deckcraft_model::table::Table;
|
||||
use deckcraft_model::text::Anchor;
|
||||
use deckcraft_model::table::{Cell, Table};
|
||||
use deckcraft_model::text::{Anchor, TextBody, TextDir};
|
||||
use deckcraft_model::{Presentation, Shape};
|
||||
use deckcraft_text::Fields;
|
||||
use kurbo::{Affine, BezPath, Rect, Shape as _};
|
||||
use vello_cpu::{RenderContext, peniko};
|
||||
|
||||
@@ -94,21 +95,76 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
}
|
||||
}
|
||||
|
||||
/// Running edges 0, s0, s0+s1, … of a list of sizes (negative sizes count as 0).
|
||||
fn edges(sizes: &[f64]) -> Vec<f64> {
|
||||
std::iter::once(0.0)
|
||||
.chain(sizes.iter().scan(0.0, |a, s| {
|
||||
*a += s.max(0.0);
|
||||
Some(*a)
|
||||
}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A cell's text as drawn: its margins, anchor and direction, plus the table style's text colour and bold.
|
||||
pub(crate) fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody {
|
||||
let lk = look(rctx, t, r, c);
|
||||
let mut body = cell.text.clone();
|
||||
let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]);
|
||||
body.body.inset_l = Some(ml);
|
||||
body.body.inset_r = Some(mr);
|
||||
body.body.inset_t = Some(mt);
|
||||
body.body.inset_b = Some(mb);
|
||||
body.body.anchor = Some(cell.anchor.unwrap_or(Anchor::Top));
|
||||
if cell.vertical.is_some() {
|
||||
body.body.vert = cell.vertical;
|
||||
}
|
||||
for p in &mut body.paragraphs {
|
||||
for run in &mut p.runs {
|
||||
if run.props.fill.is_none()
|
||||
&& let Some(tc) = lk.text
|
||||
{
|
||||
run.props.fill = Some(Fill::solid(ColorRef::rgb(tc)));
|
||||
}
|
||||
if lk.bold && run.props.bold.is_none() {
|
||||
run.props.bold = Some(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// Heights the rows are drawn at. A stored row height (`a:tr/@h`) is a minimum: the row grows so the
|
||||
/// text of each of its cells fits, as in PowerPoint. Files from pandoc or python-pptx store 0.
|
||||
/// Cells spanning several rows and vertical text keep the stored height.
|
||||
pub fn row_heights(rctx: &Ctx, t: &Table, fields: &dyn Fields) -> Vec<f64> {
|
||||
let xs = edges(&t.cols);
|
||||
let mut out = Vec::with_capacity(t.rows.len());
|
||||
for (ri, row) in t.rows.iter().enumerate() {
|
||||
let mut h = row.height.max(0.0);
|
||||
for (ci, cell) in row.cells.iter().enumerate().take(t.cols.len()) {
|
||||
if cell.is_covered() || cell.row_span > 1 || !matches!(cell.vertical, None | Some(TextDir::Horizontal)) {
|
||||
continue;
|
||||
}
|
||||
let c1 = ci.saturating_add(cell.grid_span.max(1) as usize).min(t.cols.len());
|
||||
let (Some(x0), Some(x1)) = (xs.get(ci), xs.get(c1)) else { continue };
|
||||
let body = cell_body(rctx, t, ri, ci, cell);
|
||||
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
|
||||
let need = deckcraft_text::fit_height(rctx, &tmp, &body, Rect::new(*x0, 0.0, *x1, h.max(1.0)), fields);
|
||||
if need.is_finite() {
|
||||
h = h.max(need.min(20_000.0));
|
||||
}
|
||||
}
|
||||
out.push(h);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Cell rectangles in table-local space, honouring merges: (row, col, rect) for visible cells.
|
||||
pub fn cell_rects(t: &Table) -> Vec<(usize, usize, Rect)> {
|
||||
/// `heights` are the drawn row heights ([`row_heights`]).
|
||||
pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
|
||||
let mut out = vec![];
|
||||
let xs: Vec<f64> = std::iter::once(0.0)
|
||||
.chain(t.cols.iter().scan(0.0, |a, w| {
|
||||
*a += w.max(0.0);
|
||||
Some(*a)
|
||||
}))
|
||||
.collect();
|
||||
let ys: Vec<f64> = std::iter::once(0.0)
|
||||
.chain(t.rows.iter().scan(0.0, |a, r| {
|
||||
*a += r.height.max(0.0);
|
||||
Some(*a)
|
||||
}))
|
||||
.collect();
|
||||
let xs = edges(&t.cols);
|
||||
let ys = edges(heights);
|
||||
for (ri, row) in t.rows.iter().enumerate() {
|
||||
for (ci, cell) in row.cells.iter().enumerate().take(t.cols.len()) {
|
||||
if cell.is_covered() {
|
||||
@@ -125,7 +181,7 @@ pub fn cell_rects(t: &Table) -> Vec<(usize, usize, Rect)> {
|
||||
|
||||
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
|
||||
let _ = pres;
|
||||
let rects = cell_rects(t);
|
||||
let rects = cell_rects(t, &row_heights(rctx, t, fields));
|
||||
for (r, c, rect) in &rects {
|
||||
let Some(cell) = t.cell(*r, *c) else { continue };
|
||||
let lk = look(rctx, t, *r, *c);
|
||||
@@ -180,29 +236,7 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
|
||||
if cell.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let lk = look(rctx, t, *r, *c);
|
||||
let mut body = cell.text.clone();
|
||||
let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]);
|
||||
body.body.inset_l = Some(ml);
|
||||
body.body.inset_r = Some(mr);
|
||||
body.body.inset_t = Some(mt);
|
||||
body.body.inset_b = Some(mb);
|
||||
body.body.anchor = Some(cell.anchor.unwrap_or(Anchor::Top));
|
||||
if cell.vertical.is_some() {
|
||||
body.body.vert = cell.vertical;
|
||||
}
|
||||
for p in &mut body.paragraphs {
|
||||
for run in &mut p.runs {
|
||||
if run.props.fill.is_none()
|
||||
&& let Some(tc) = lk.text
|
||||
{
|
||||
run.props.fill = Some(Fill::solid(ColorRef::rgb(tc)));
|
||||
}
|
||||
if lk.bold && run.props.bold.is_none() {
|
||||
run.props.bold = Some(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
let body = cell_body(rctx, t, *r, *c, cell);
|
||||
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
|
||||
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
|
||||
let _ = peniko::Fill::NonZero;
|
||||
|
||||
@@ -157,3 +157,596 @@ fn custom_path_parse() {
|
||||
assert!(p.elements().len() >= 5);
|
||||
assert!(parse_path("").elements().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_rows_of_zero_height_grow_to_fit_text() {
|
||||
// pandoc and python-pptx write `<a:tr h="0">`: the stored row height is only a minimum.
|
||||
let mut t = Table::new(3, 2, 300.0, 0.0);
|
||||
for (r, row) in [["Col A", "Col B"], ["1", "alpha"], ["2", "beta"]].iter().enumerate() {
|
||||
for (c, text) in row.iter().enumerate() {
|
||||
t.cell_mut(r, c).unwrap().text = TextBody::from_text(text);
|
||||
}
|
||||
}
|
||||
let tbl = Shape { id: ShapeId(610), xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 0.0)), kind: ShapeKind::Table(t.clone()), ..Default::default() };
|
||||
let p = deck_with(vec![tbl]);
|
||||
let ctx = deckcraft_model::resolve::Ctx::for_slide(&p, &p.slides[0]).unwrap();
|
||||
let hs = crate::table::row_heights(&ctx, &t, &deckcraft_text::NoFields);
|
||||
assert_eq!(hs.len(), 3);
|
||||
assert!(hs.iter().all(|h| *h > 10.0), "{hs:?}");
|
||||
let tops: Vec<f64> = crate::table::cell_rects(&t, &hs).iter().filter(|(_, c, _)| *c == 0).map(|(_, _, r)| r.y0).collect();
|
||||
assert!(tops.len() == 3 && tops.windows(2).all(|w| w[1] > w[0] + 10.0), "{tops:?}");
|
||||
// A taller stored height is kept.
|
||||
let mut tall = t;
|
||||
tall.rows[1].height = 200.0;
|
||||
assert_eq!(crate::table::row_heights(&ctx, &tall, &deckcraft_text::NoFields)[1], 200.0);
|
||||
// The PDF text layer gets every cell's text, rows one below the other.
|
||||
let placed = place_slide(&p, 0);
|
||||
let top = |s: &str| {
|
||||
let pt = placed.texts.iter().find(|pt| pt.body.text() == s).unwrap_or_else(|| panic!("no placed text {s:?}"));
|
||||
(pt.transform * kurbo::Point::new(0.0, pt.layout.lines[0].top)).y
|
||||
};
|
||||
assert!(top("Col A") < top("1") && top("1") < top("2"));
|
||||
assert!((top("alpha") - top("1")).abs() < 1.0 && (top("beta") - top("2")).abs() < 1.0);
|
||||
assert!(top("Col B") >= 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_ends_match_the_slides_and_mixes_backdrops() {
|
||||
let sh = Shape {
|
||||
id: ShapeId(500),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut p = deck_with(vec![sh.clone()]);
|
||||
let mut dark = (*p.slides[0]).clone();
|
||||
dark.background =
|
||||
Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))) });
|
||||
p.slides.push(std::sync::Arc::new(dark));
|
||||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||||
let shapes = vec![(sh, false)];
|
||||
// At the ends the frame is the slide itself; the shape stays opaque throughout.
|
||||
assert_eq!(render_blend(&p, 0, 1, 1.0, &shapes, &[], &[], &o).pixels, render_slide(&p, 1, &o).pixels);
|
||||
let mid = render_blend(&p, 0, 1, 0.5, &shapes, &[], &[], &o);
|
||||
let px = mid.pixel(5, 5);
|
||||
assert!((px[0] as i32 - 128).abs() <= 3, "{px:?}");
|
||||
assert_eq!(mid.pixel(100, 75), render_slide(&p, 1, &o).pixel(100, 75));
|
||||
// Hostile input: bad indices and NaN never panic.
|
||||
assert_eq!(render_blend(&p, 0, 9, 0.5, &shapes, &[], &[], &o).width, 0);
|
||||
let _ = render_blend(&p, 0, 1, f64::NAN, &shapes, &[], &[], &o);
|
||||
}
|
||||
|
||||
fn text_shape(id: u32, text: &str, x: f64, y: f64) -> Shape {
|
||||
let mut t = TextBody::from_text(text);
|
||||
for p in &mut t.paragraphs {
|
||||
for r in &mut p.runs {
|
||||
r.props.size = Some(40.0);
|
||||
}
|
||||
}
|
||||
Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(x, y, 600.0, 120.0)), text_box: true, text: Some(t), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_text_tokens_are_words_or_characters() {
|
||||
let p = Presentation::default();
|
||||
let rctx = Ctx::for_slide(&p, &p.slides[0]).unwrap();
|
||||
let s = text_shape(1, "Hello big world\nHello", 0.0, 0.0);
|
||||
let l = deckcraft_text::layout(
|
||||
&rctx,
|
||||
&s,
|
||||
s.text.as_ref().unwrap(),
|
||||
&Opts { rect: Rect::new(0.0, 0.0, 600.0, 120.0), fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
|
||||
);
|
||||
assert_eq!(morph_text::token_keys(&l, false), ["Hello", "big", "world", "Hello"]);
|
||||
assert_eq!(morph_text::token_keys(&l, true).concat(), "Hellobigworld".to_string() + "Hello");
|
||||
assert_eq!(morph_text::token_keys(&l, true).len(), 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_text_ends_match_the_slides() {
|
||||
let from = Xfrm::new(100.0, 60.0, 600.0, 120.0);
|
||||
let to = Xfrm::new(200.0, 300.0, 600.0, 120.0);
|
||||
let mut p = deck_with(vec![text_shape(1, "Hello wide world", from.x, from.y)]);
|
||||
let mut b = (*p.slides[0]).clone();
|
||||
b.shapes = vec![text_shape(1, "world says Hello", to.x, to.y)];
|
||||
p.slides.push(std::sync::Arc::new(b));
|
||||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||||
let frame = |t: f64| {
|
||||
let x = Xfrm::new(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t, 600.0, 120.0);
|
||||
let mut sb = text_shape(10, "world says Hello", 0.0, 0.0);
|
||||
let mut sa = text_shape(11, "Hello wide world", 0.0, 0.0);
|
||||
sb.xfrm = Some(x);
|
||||
sa.xfrm = Some(x);
|
||||
let tm = TextMorph { old: ShapeId(11), new: ShapeId(10), from, to, chars: false, t };
|
||||
render_blend(&p, 0, 1, t, &[(sb, false), (sa, true)], &[tm], &[], &o)
|
||||
};
|
||||
// Warm up: faces load lazily (a background scan runs), so the first render may still fall back.
|
||||
let _ = (render_slide(&p, 0, &o), render_slide(&p, 1, &o), frame(0.5));
|
||||
let close = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 8).count();
|
||||
let d0 = close(&frame(0.0), &render_slide(&p, 0, &o));
|
||||
assert!(d0 < 40, "start differs {d0}");
|
||||
let d1 = close(&frame(1.0), &render_slide(&p, 1, &o));
|
||||
assert!(d1 < 40, "end differs {d1}");
|
||||
// Halfway the shared words are on their way: ink between the two texts' rows.
|
||||
let mid = frame(0.5);
|
||||
let ink = (95..140).flat_map(|y| (0..480).map(move |x| (x, y))).filter(|&(x, y)| mid.pixel(x, y)[0] < 128).count();
|
||||
assert!(ink > 50, "ink {ink}");
|
||||
}
|
||||
|
||||
/// A text box holding one run of `kind` (a field type, or plain text for `None`) saved as `saved`.
|
||||
fn field_shape(kind: Option<&str>, saved: &str) -> Shape {
|
||||
let mut sh = text_shape(1, saved, 40.0, 40.0);
|
||||
if let (Some(k), Some(run)) = (kind, sh.text.as_mut().and_then(|t| t.paragraphs[0].runs.first_mut())) {
|
||||
run.kind = deckcraft_model::text::RunKind::Field { field: k.into() };
|
||||
}
|
||||
sh
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_fields_show_the_date_in_their_format() {
|
||||
use deckcraft_text::datetime::DateTime;
|
||||
let fri = DateTime::new(2026, 10, 9, 20, 5, 7);
|
||||
let at = |p: &Presentation, now: DateTime| render_slide(p, 0, &RenderOpts { scale: 0.5, now: Some(now), ..Default::default() });
|
||||
let mut p = deck_with(vec![field_shape(Some("datetime1"), "1/1/2000")]);
|
||||
let _ = at(&p, fri); // faces load lazily
|
||||
// The field shows the date (it changes with the clock), not its saved text.
|
||||
let a = at(&p, fri);
|
||||
assert!(diff_px(&a, &at(&p, DateTime::new(2027, 3, 4, 0, 0, 0))) > 0);
|
||||
let shown = deck_with(vec![field_shape(None, "10/9/2026")]);
|
||||
assert_eq!(diff_px(&a, &at(&shown, fri)), 0, "datetime1 is M/D/YYYY");
|
||||
// Another slide's fixed date (a deck-wide Header & Footer text) doesn't replace it.
|
||||
p.header_footer.date_text = "Fixed on another slide".into();
|
||||
assert_eq!(diff_px(&a, &at(&p, fri)), 0);
|
||||
// Every type has its format; the run's language orders numeric dates.
|
||||
let f = Renderer::fields(&p, 0, &RenderOpts { now: Some(fri), ..Default::default() });
|
||||
assert_eq!(f.field_in("datetime4", Some("en-US")).as_deref(), Some("October 9, 2026"));
|
||||
assert_eq!(f.field_in("datetimeFigureOut", Some("en-GB")).as_deref(), Some("09/10/2026"));
|
||||
assert_eq!(f.field("datetime13").as_deref(), Some("8:05:07 PM"));
|
||||
assert_eq!(f.field("slidenum").as_deref(), Some("1"));
|
||||
// A date the app can't compute (no clock, unknown type, other-language names) keeps the saved text.
|
||||
assert_eq!(SlideFields { now: None, ..f }.field("datetime1"), None);
|
||||
let unknown = deck_with(vec![field_shape(Some("datetime99"), "Saved text")]);
|
||||
let plain = deck_with(vec![field_shape(None, "Saved text")]);
|
||||
assert_eq!(diff_px(&at(&unknown, fri), &at(&plain, fri)), 0);
|
||||
// An explicit override still wins.
|
||||
let o = RenderOpts { date_text: Some("Today".into()), now: Some(fri), ..Default::default() };
|
||||
assert_eq!(Renderer::fields(&p, 0, &o).field("datetime2").as_deref(), Some("Today"));
|
||||
}
|
||||
|
||||
/// Channel values that differ noticeably between two images of the same size.
|
||||
fn diff_px(a: &Image, b: &Image) -> usize {
|
||||
a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count()
|
||||
}
|
||||
|
||||
/// Two 8×8 frames, red then blue, 100 ms each.
|
||||
fn two_frame_gif() -> Vec<u8> {
|
||||
use image::{Delay, Frame, Rgba, RgbaImage};
|
||||
let frame = |c: [u8; 4]| Frame::from_parts(RgbaImage::from_pixel(8, 8, Rgba(c)), 0, 0, Delay::from_numer_denom_ms(100, 1));
|
||||
let mut out = Vec::new();
|
||||
image::codecs::gif::GifEncoder::new(&mut out).encode_frames([frame([255, 0, 0, 255]), frame([0, 0, 255, 255])]).expect("gif");
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animated_gif_shows_the_frame_for_its_time() {
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("a.gif", "image/gif", two_frame_gif());
|
||||
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||||
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
let at = |t: Option<f64>| {
|
||||
let times: Vec<(ShapeId, f64)> = t.map(|t| vec![(ShapeId(600), t)]).unwrap_or_default();
|
||||
render_slide(&p, 0, &RenderOpts { scale: 0.1, gif_times: ×, ..Default::default() }).pixel(48, 27)
|
||||
};
|
||||
let red = |px: [u8; 4]| px[0] > 200 && px[2] < 60;
|
||||
let blue = |px: [u8; 4]| px[2] > 200 && px[0] < 60;
|
||||
assert!(red(at(None)), "no time: the first frame, as exports draw it");
|
||||
assert!(red(at(Some(0.05))));
|
||||
assert!(blue(at(Some(0.15))));
|
||||
assert!(red(at(Some(0.25))), "it loops");
|
||||
let bytes = p.media(media).map(|m| m.data.clone()).expect("media");
|
||||
let adj = Default::default();
|
||||
assert_eq!(gif_frame(&bytes, &adj, 0.15).map(|f| f.0), Some(1));
|
||||
assert_eq!(gif_frame(&bytes, &adj, f64::NAN).map(|f| f.0), Some(0));
|
||||
assert!(gif_frame(&std::sync::Arc::new(b"\x89PNG".to_vec()), &adj, 0.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_path_ends_match_the_slides() {
|
||||
let from = Xfrm::new(100.0, 100.0, 200.0, 200.0);
|
||||
let to = Xfrm::new(500.0, 150.0, 300.0, 150.0);
|
||||
let form = |id: u32, g: &str, x: Xfrm| Shape {
|
||||
id: ShapeId(id),
|
||||
xfrm: Some(x),
|
||||
geom: Geom::preset(g),
|
||||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut p = deck_with(vec![form(1, "ellipse", from)]);
|
||||
let mut b = (*p.slides[0]).clone();
|
||||
b.shapes = vec![form(1, "star5", to)];
|
||||
p.slides.push(std::sync::Arc::new(b));
|
||||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||||
let frame = |t: f64| {
|
||||
let lerp = |a: f64, b: f64| a + (b - a) * t;
|
||||
let x = Xfrm::new(lerp(from.x, to.x), lerp(from.y, to.y), lerp(from.w, to.w), lerp(from.h, to.h));
|
||||
let pm = PathMorph { id: ShapeId(10), from: Geom::preset("ellipse"), from_box: from, to_box: to, t };
|
||||
render_blend(&p, 0, 1, t, &[(form(10, "star5", x), false)], &[], &[pm], &o)
|
||||
};
|
||||
// The morph splits the outlines into more curves, which flatten slightly differently: edge pixels
|
||||
// may differ a little, a different shape differs a lot.
|
||||
let differ = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 60).count();
|
||||
// At the start the star is still the circle, at the end the star.
|
||||
let d0 = differ(&frame(0.0), &render_slide(&p, 0, &o));
|
||||
assert!(d0 < 40, "start differs {d0}");
|
||||
let d1 = differ(&frame(1.0), &render_slide(&p, 1, &o));
|
||||
assert!(d1 < 40, "end differs {d1}");
|
||||
// Without the morph the start would show the star.
|
||||
let star = render_blend(&p, 0, 1, 0.0, &[(form(10, "star5", from), false)], &[], &[], &o);
|
||||
assert!(differ(&star, &render_slide(&p, 0, &o)) > 400);
|
||||
// Halfway the shape is filled around the middle of its box.
|
||||
let mid = frame(0.5);
|
||||
let c = (from.center().to_vec2() + to.center().to_vec2()) * 0.5 * 0.5;
|
||||
assert_ne!(mid.pixel(c.x as u32, c.y as u32), [255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_path_fades_a_fill_only_one_side_has() {
|
||||
let bx = Xfrm::new(100.0, 100.0, 200.0, 200.0);
|
||||
let form = |id: u32, g: &str| Shape {
|
||||
id: ShapeId(id),
|
||||
xfrm: Some(bx),
|
||||
geom: Geom::preset(g),
|
||||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut p = deck_with(vec![form(1, "rect")]);
|
||||
let mut b = (*p.slides[0]).clone();
|
||||
b.shapes = vec![form(1, "line")];
|
||||
p.slides.push(std::sync::Arc::new(b));
|
||||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||||
let at = |t: f64| {
|
||||
let pm = PathMorph { id: ShapeId(10), from: Geom::preset("rect"), from_box: bx, to_box: bx, t };
|
||||
// A point near the line but not on it: inside the shape all the way.
|
||||
render_blend(&p, 0, 1, t, &[(form(10, "line"), false)], &[], &[pm], &o).pixel(110, 90)
|
||||
};
|
||||
let (full, half, none) = (at(0.0), at(0.5), at(1.0));
|
||||
assert_eq!(none, [255, 255, 255, 255]);
|
||||
assert!(full[0] < 128, "{full:?}");
|
||||
// Halfway the fill is half there: between the full colour and the white slide.
|
||||
assert!(half[0] > full[0] + 40 && half[0] < 215, "{half:?} vs {full:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_transform_unmirrors_flips() {
|
||||
// Asymmetric text rect in a 100×50 box: flips move the rect, never mirror the text.
|
||||
let tr = Rect::new(10.0, 0.0, 40.0, 50.0);
|
||||
let at = |x: Xfrm, p: Point| text_transform(x.affine(), tr, 0.0) * p;
|
||||
let near = |a: Point, b: Point| (a - b).hypot() < 1e-9;
|
||||
let base = Xfrm::new(0.0, 0.0, 100.0, 50.0);
|
||||
// flipH: the rect moves to 60..90 and the text still runs left to right.
|
||||
let h = Xfrm { flip_h: true, ..base };
|
||||
assert!(near(at(h, Point::new(10.0, 0.0)), Point::new(60.0, 0.0)));
|
||||
assert!(near(at(h, Point::new(40.0, 50.0)), Point::new(90.0, 50.0)));
|
||||
// flipV: the text is turned 180° in its (vertically mirrored) rect.
|
||||
let v = Xfrm { flip_v: true, ..base };
|
||||
assert!(near(at(v, Point::new(10.0, 0.0)), Point::new(40.0, 50.0)));
|
||||
assert!(near(at(v, Point::new(40.0, 50.0)), Point::new(10.0, 0.0)));
|
||||
// Both flips: the same as a 180° turn.
|
||||
let hv = Xfrm { flip_h: true, flip_v: true, ..base };
|
||||
let turned = Xfrm { rot: 180.0, ..base };
|
||||
for p in [Point::new(10.0, 0.0), Point::new(40.0, 50.0), Point::new(25.0, 10.0)] {
|
||||
assert!(near(at(hv, p), at(turned, p)));
|
||||
}
|
||||
// Unflipped and hostile input: plain transform, nothing NaN from the helper itself.
|
||||
assert_eq!(text_transform(base.affine(), tr, 0.0), base.affine());
|
||||
let _ = text_transform(h.affine(), Rect::new(f64::NAN, 0.0, f64::INFINITY, 1.0), f64::NAN);
|
||||
}
|
||||
|
||||
/// Slide with text box `x` holding left-aligned, asymmetric text.
|
||||
fn flipped_text(x: Xfrm, group: Option<Xfrm>) -> Image {
|
||||
let mut s = text_shape(1, "Fly 42 RJ", 0.0, 0.0);
|
||||
s.xfrm = Some(x);
|
||||
let s = match group {
|
||||
Some(g) => Shape {
|
||||
id: ShapeId(2),
|
||||
xfrm: Some(g),
|
||||
kind: ShapeKind::Group { children: vec![s], child: Xfrm::new(0.0, 0.0, 600.0, 120.0) },
|
||||
..Default::default()
|
||||
},
|
||||
None => s,
|
||||
};
|
||||
render_slide(&deck_with(vec![s]), 0, &RenderOpts { scale: 0.5, ..Default::default() })
|
||||
}
|
||||
|
||||
fn differing(a: &Image, b: &Image) -> usize {
|
||||
a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flipped_shapes_draw_readable_text() {
|
||||
let base = Xfrm::new(180.0, 200.0, 600.0, 120.0);
|
||||
let plain = flipped_text(base, None);
|
||||
// Warm up: faces load lazily, so the first render may still fall back.
|
||||
let plain = if differing(&plain, &flipped_text(base, None)) > 0 { flipped_text(base, None) } else { plain };
|
||||
let turned = flipped_text(Xfrm { rot: 180.0, ..base }, None);
|
||||
assert!(differing(&plain, &turned) > 200, "the comparisons below must mean something");
|
||||
// flipH: text as unflipped (the text box is symmetric). flipV and both: turned 180°.
|
||||
let d = differing(&plain, &flipped_text(Xfrm { flip_h: true, ..base }, None));
|
||||
assert!(d < 40, "flipH mirrored the text: {d}");
|
||||
let d = differing(&turned, &flipped_text(Xfrm { flip_v: true, ..base }, None));
|
||||
assert!(d < 40, "flipV mirrored the text: {d}");
|
||||
let d = differing(&turned, &flipped_text(Xfrm { flip_h: true, flip_v: true, ..base }, None));
|
||||
assert!(d < 40, "flipH+flipV: {d}");
|
||||
// Rotation then flipH: still the rotated text, not its mirror image.
|
||||
let rot = Xfrm { rot: 30.0, ..base };
|
||||
let d = differing(&flipped_text(rot, None), &flipped_text(Xfrm { flip_h: true, ..rot }, None));
|
||||
assert!(d < 40, "rotated flipH: {d}");
|
||||
// Groups: a flipped group's text isn't mirrored; a flipped child in a flipped group neither.
|
||||
let child = Xfrm::new(0.0, 0.0, 600.0, 120.0);
|
||||
let d = differing(&plain, &flipped_text(child, Some(Xfrm { flip_h: true, ..base })));
|
||||
assert!(d < 40, "group flipH: {d}");
|
||||
let d = differing(&plain, &flipped_text(Xfrm { flip_h: true, ..child }, Some(Xfrm { flip_h: true, ..base })));
|
||||
assert!(d < 40, "flipH child in flipH group: {d}");
|
||||
let d = differing(&turned, &flipped_text(child, Some(Xfrm { flip_v: true, ..base })));
|
||||
assert!(d < 40, "group flipV: {d}");
|
||||
}
|
||||
|
||||
/// A text box with one run of `props` (40 pt).
|
||||
fn run_deck(props: deckcraft_model::RunProps) -> Presentation {
|
||||
let mut sh = text_shape(1, "Linked text", 40.0, 40.0);
|
||||
if let Some(run) = sh.text.as_mut().and_then(|t| t.paragraphs[0].runs.first_mut()) {
|
||||
run.props = deckcraft_model::RunProps { size: Some(40.0), ..props };
|
||||
}
|
||||
deck_with(vec![sh])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlinks_draw_in_the_hyperlink_colour_underlined() {
|
||||
use deckcraft_model::RunProps;
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||||
let px = |p: &Presentation| render_slide(p, 0, &o);
|
||||
let diff = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count();
|
||||
let link = Some(Hyperlink { action: Action::Url { url: "https://example.org".into() }, tooltip: String::new(), highlight_click: false });
|
||||
let hlink = Some(Fill::solid(ColorRef::scheme(deckcraft_color::SchemeSlot::Hlink)));
|
||||
let red = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0))));
|
||||
let _ = px(&run_deck(RunProps::default())); // faces load lazily
|
||||
let linked = px(&run_deck(RunProps { link: link.clone(), ..Default::default() }));
|
||||
assert!(diff(&linked, &px(&run_deck(RunProps::default()))) > 100, "a link doesn't look like plain text");
|
||||
// Exactly the theme's hyperlink colour, single underline.
|
||||
let styled = px(&run_deck(RunProps { fill: hlink, underline: Some("sng".into()), ..Default::default() }));
|
||||
assert_eq!(diff(&linked, &styled), 0);
|
||||
// A colour on the run itself wins; u="none" drops the underline.
|
||||
let own = px(&run_deck(RunProps { link: link.clone(), fill: red.clone(), underline: Some("none".into()), ..Default::default() }));
|
||||
assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red.clone(), ..Default::default() }))), 0);
|
||||
let own = px(&run_deck(RunProps { link, fill: red.clone(), ..Default::default() }));
|
||||
assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red, underline: Some("sng".into()), ..Default::default() }))), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linear_gradient_ramps_like_powerpoint() {
|
||||
// Black to white, left to right: PowerPoint draws 105 a quarter of the way and 186 half way
|
||||
// (linear-light mix, S-shaped), not 64 and 128.
|
||||
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
|
||||
let stop = |pos, c| GradientStop { pos, color: ColorRef::rgb(c) };
|
||||
let sh = Shape {
|
||||
id: ShapeId(700),
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
|
||||
fill: Some(Fill::Gradient(Gradient {
|
||||
stops: vec![stop(0.0, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
})),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
for (x, want) in [(200, 105), (400, 186), (600, 238)] {
|
||||
let got = img.pixel(x, 50)[0] as i32;
|
||||
assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_with_a_stop_off_the_end_stays_linear() {
|
||||
// Stops at 20 % and 100 %: PowerPoint blends linearly in sRGB (96 at 50 %, 175 at 75 %).
|
||||
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
|
||||
let stop = |pos, c| GradientStop { pos, color: ColorRef::rgb(c) };
|
||||
let sh = Shape {
|
||||
id: ShapeId(701),
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
|
||||
fill: Some(Fill::Gradient(Gradient {
|
||||
stops: vec![stop(0.2, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
})),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
for (x, want) in [(400, 96), (600, 175)] {
|
||||
let got = img.pixel(x, 50)[0] as i32;
|
||||
assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_filled_with_the_background_shows_the_slide_background() {
|
||||
// `useBgFill`: PowerPoint paints the slide's background into the shape, laid out on the
|
||||
// slide. A white panel covers a black-to-grey background; a shape on it with the background
|
||||
// fill is a window onto that background.
|
||||
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
|
||||
let none = || Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() });
|
||||
let panel = Shape {
|
||||
id: ShapeId(710),
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
|
||||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
|
||||
line: none(),
|
||||
..Default::default()
|
||||
};
|
||||
let window = Shape {
|
||||
id: ShapeId(711),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 760.0, 340.0)),
|
||||
fill: Some(Fill::Background),
|
||||
line: none(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut p = deck_with(vec![panel, window]);
|
||||
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill {
|
||||
fill: Fill::Gradient(Gradient {
|
||||
stops: vec![stop(0.0, 0), stop(0.5, 0), stop(1.0, 200)],
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
}),
|
||||
});
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert_eq!(img.pixel(50, 50)[0], 255, "the panel stays white outside the window");
|
||||
assert!(img.pixel(300, 270)[0] < 8, "the window shows the background's black half: {:?}", img.pixel(300, 270));
|
||||
// Laid out on the slide: three quarters across the slide is half way from black to grey.
|
||||
let v = img.pixel(720, 270)[0] as i32;
|
||||
assert!((v - 100).abs() <= 6, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_is_laid_over_white() {
|
||||
// A background picture stretched into the bottom-right quarter (fillRect l=50% t=50%), and a
|
||||
// half-transparent colour: PowerPoint shows white around and under them, not transparency.
|
||||
use deckcraft_model::style::{Fill, PictureFill, PictureMode};
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([0, 0, 200]))).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
let mut p = deck_with(vec![]);
|
||||
let media = p.add_media("bg.png", "image/png", png.into_inner());
|
||||
let pf = PictureFill { media, mode: PictureMode::Stretch { fill_rect: [0.5, 0.5, 0.0, 0.0] }, ..Default::default() };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::Picture(pf) });
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
|
||||
assert_eq!(img.pixel(20, 20), [255, 255, 255, 255], "white where the picture doesn't reach");
|
||||
let q = img.pixel(400, 230);
|
||||
assert!(q[2] > 190 && q[0] < 10 && q[3] == 255, "{q:?}");
|
||||
let see_through = ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 });
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(see_through) });
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
|
||||
let g = img.pixel(20, 20);
|
||||
assert!(g[3] == 255 && (g[0] as i32 - 127).abs() <= 3, "half-transparent black over white is grey: {g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_glyphs_paint_their_layers() {
|
||||
// A COLRv0 test font (crates/render/tests/fixtures): U+E000 is a red square with a blue
|
||||
// square in its middle, U+E001 a square in the text colour.
|
||||
deckcraft_fonts::FontDb::global().add_font(include_bytes!("../tests/fixtures/colr-squares.ttf").to_vec());
|
||||
let mut sh = Shape { id: ShapeId(720), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), text_box: true, ..Default::default() };
|
||||
let mut t = TextBody::from_text("\u{E000} \u{E001}");
|
||||
let props = &mut t.paragraphs[0].runs[0].props;
|
||||
props.size = Some(72.0);
|
||||
props.font = Some("DeckCraft Colr Squares".into());
|
||||
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 160, 0))));
|
||||
sh.text = Some(t);
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let count = |f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count();
|
||||
let red = count(&|p| p[0] > 230 && p[1] < 30 && p[2] < 30);
|
||||
let blue = count(&|p| p[0] < 30 && p[1] < 30 && p[2] > 230);
|
||||
let green = count(&|p| p[0] < 30 && p[1] > 140 && p[1] < 180 && p[2] < 30);
|
||||
// 72 pt squares: the blue one is 40 % of the side (16 % of the area).
|
||||
assert!(red > 2500 && blue > 500, "red {red} blue {blue}");
|
||||
let share = blue as f64 / (red + blue) as f64;
|
||||
assert!((share - 0.16).abs() < 0.03, "blue share {share}");
|
||||
// The text-colour glyph is as big as the other square.
|
||||
assert!((green as f64 / (red + blue) as f64 - 1.0).abs() < 0.1, "green {green} vs {}", red + blue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_gradient_starts_over_on_every_line() {
|
||||
// PowerPoint fills each line of gradient text with the whole gradient, spanning that line
|
||||
// (black at the top of every line to white at its bottom), not the text box.
|
||||
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
|
||||
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
|
||||
let mut sh = Shape { id: ShapeId(730), xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 500.0)), text_box: true, ..Default::default() };
|
||||
let mut t = TextBody::from_text("HHHH\nHHHH");
|
||||
for p in &mut t.paragraphs {
|
||||
let props = &mut p.runs[0].props;
|
||||
props.size = Some(80.0);
|
||||
props.fill = Some(Fill::Gradient(Gradient {
|
||||
stops: vec![stop(0.0, 0), stop(1.0, 255)],
|
||||
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
}));
|
||||
}
|
||||
sh.text = Some(t);
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
// Ink rows (any pixel not white), grouped into the two lines.
|
||||
let ink = |y: u32| (0..600).any(|x| img.pixel(x, y)[0] < 250);
|
||||
let mut bands: Vec<(u32, u32)> = vec![];
|
||||
for y in 0..500 {
|
||||
if ink(y) {
|
||||
match bands.last_mut() {
|
||||
Some(b) if b.1 + 1 == y => b.1 = y,
|
||||
_ => bands.push((y, y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(bands.len(), 2, "{bands:?}");
|
||||
// The darkest ink in the first rows of each line.
|
||||
let top = |b: (u32, u32)| (b.0..b.0 + 6).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
|
||||
let (a, b) = (top(bands[0]), top(bands[1]));
|
||||
assert!(a < 140 && (a as i32 - b as i32).abs() < 25, "line tops {a} and {b}: the second line starts dark again");
|
||||
// ...and each line ends light.
|
||||
let bottom = |b: (u32, u32)| (b.1 - 5..=b.1).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
|
||||
for band in bands {
|
||||
assert!(bottom(band) > 150, "line {band:?} ends at {}", bottom(band));
|
||||
}
|
||||
}
|
||||
|
||||
/// A text box of big black "IIII" with `set` applied to its run.
|
||||
fn effect_text(set: impl Fn(&mut deckcraft_model::text::RunProps)) -> Presentation {
|
||||
let mut sh = Shape { id: ShapeId(740), xfrm: Some(Xfrm::new(100.0, 100.0, 600.0, 300.0)), text_box: true, ..Default::default() };
|
||||
let mut t = TextBody::from_text("IIII");
|
||||
let props = &mut t.paragraphs[0].runs[0].props;
|
||||
props.size = Some(100.0);
|
||||
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))));
|
||||
set(props);
|
||||
sh.text = Some(t);
|
||||
deck_with(vec![sh])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_glow_and_shadow_are_drawn() {
|
||||
let count = |img: &Image, f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count();
|
||||
let green = |p: &[u8; 4]| p[1] > 150 && p[0] < 120 && p[2] < 120;
|
||||
let plain = effect_text(|_| {});
|
||||
assert!(!needs_filters(&plain, &plain.slides[0]));
|
||||
let img = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert_eq!(count(&img, &green), 0);
|
||||
// Glow: a green halo hugging the letters, needing the blur filter.
|
||||
let glow =
|
||||
effect_text(|p| p.glow = Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 255, 0)), radius: 8.0 }));
|
||||
assert!(needs_filters(&glow, &glow.slides[0]), "text glow blurs");
|
||||
let img = render_slide(&glow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
|
||||
let halo = count(&img, &green);
|
||||
assert!(halo > 300, "green halo pixels {halo}");
|
||||
// Shadow: grey ink below and right of the letters, where the plain text has none.
|
||||
let shadow = effect_text(|p| {
|
||||
p.shadow = Some(deckcraft_model::style::Shadow {
|
||||
color: ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 }),
|
||||
blur: 2.0,
|
||||
dist: 12.0,
|
||||
dir: 45.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
})
|
||||
});
|
||||
let img = render_slide(&shadow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
|
||||
let base = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let grey = |p: &[u8; 4]| p[0] > 60 && p[0] < 230 && p[0] == p[1] && p[1] == p[2];
|
||||
assert!(count(&img, &grey) > count(&base, &grey) + 300, "a grey shadow appears");
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
+51
@@ -0,0 +1,51 @@
|
||||
"""Build the COLR test font: DeckCraft Colr Squares (contributor-original, CC0-1.0).
|
||||
|
||||
Usage: python make_colr_font.py colr-squares.ttf (needs fontTools)
|
||||
|
||||
U+E000: a 1000x1000 red square (palette 0) with a 400x400 blue square (palette 1) in its middle.
|
||||
U+E001: a 1000x1000 square in the text colour (palette index 0xFFFF).
|
||||
Plain outlines of the base glyphs are a single square, as colour fonts carry for renderers
|
||||
without colour support.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from fontTools.fontBuilder import FontBuilder
|
||||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||||
from fontTools.colorLib.builder import buildCOLR, buildCPAL
|
||||
|
||||
|
||||
def square(x0, y0, x1, y1):
|
||||
pen = TTGlyphPen(None)
|
||||
pen.moveTo((x0, y0))
|
||||
pen.lineTo((x0, y1))
|
||||
pen.lineTo((x1, y1))
|
||||
pen.lineTo((x1, y0))
|
||||
pen.closePath()
|
||||
return pen.glyph()
|
||||
|
||||
|
||||
def empty():
|
||||
return TTGlyphPen(None).glyph()
|
||||
|
||||
|
||||
order = [".notdef", "space", "redblue", "ink", "big", "small"]
|
||||
fb = FontBuilder(1000, isTTF=True)
|
||||
fb.setupGlyphOrder(order)
|
||||
fb.setupCharacterMap({0x20: "space", 0xE000: "redblue", 0xE001: "ink"})
|
||||
fb.setupGlyf({
|
||||
".notdef": empty(),
|
||||
"space": empty(),
|
||||
"redblue": square(0, 0, 1000, 1000),
|
||||
"ink": square(0, 0, 1000, 1000),
|
||||
"big": square(0, 0, 1000, 1000),
|
||||
"small": square(300, 300, 700, 700),
|
||||
})
|
||||
fb.setupHorizontalMetrics({g: (1000, 0) for g in order})
|
||||
fb.setupHorizontalHeader(ascent=1000, descent=0)
|
||||
fb.setupNameTable({"familyName": "DeckCraft Colr Squares", "styleName": "Regular"})
|
||||
fb.setupOS2(sTypoAscender=1000, sTypoDescender=0, usWinAscent=1000, usWinDescent=0)
|
||||
fb.setupPost()
|
||||
fb.font["COLR"] = buildCOLR({"redblue": [("big", 0), ("small", 1)], "ink": [("big", 0xFFFF)]}, version=0)
|
||||
fb.font["CPAL"] = buildCPAL([[(1.0, 0.0, 0.0, 1.0), (0.0, 0.0, 1.0, 1.0)]])
|
||||
fb.save(sys.argv[1])
|
||||
print("saved", sys.argv[1])
|
||||
@@ -15,3 +15,8 @@ deckcraft-color = { workspace = true }
|
||||
deckcraft-fonts = { workspace = true }
|
||||
unicode-linebreak = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
|
||||
# The local clock for date fields (no clock on wasm: fields keep their saved text).
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Bidirectional text (UAX #9) for paragraph layout.
|
||||
//!
|
||||
//! The pipeline is the one the algorithm prescribes: embedding levels are resolved once for the
|
||||
//! whole logical paragraph (base level from `a:pPr/@rtl`); shaping and line breaking happen in
|
||||
//! logical order; then each line is reordered on its own ([`line_levels`] applies rule L1,
|
||||
//! [`visual_order`] rule L2). Rule L4 (mirrored brackets) is applied by the shaper, which draws
|
||||
//! mirrorable characters in right-to-left items with their mirrored glyphs.
|
||||
|
||||
use unicode_bidi::BidiClass::{self, AL, AN, B, BN, FSI, LRE, LRI, LRO, PDF, PDI, R, RLE, RLI, RLO, S, WS};
|
||||
use unicode_bidi::{BidiInfo, Level, bidi_class};
|
||||
|
||||
/// Does anything in the text need the full algorithm? (Strong right-to-left, Arabic numbers or
|
||||
/// explicit embeddings.)
|
||||
fn needs_bidi(chars: &[char]) -> bool {
|
||||
chars.iter().any(|c| matches!(bidi_class(*c), R | AL | AN | LRE | RLE | LRO | RLO | PDF | LRI | RLI | FSI | PDI))
|
||||
}
|
||||
|
||||
/// Resolved embedding level of every character of a paragraph (before the line rules), with the
|
||||
/// paragraph level forced to 1 when `rtl`.
|
||||
pub(crate) fn levels(text: &str, chars: &[char], rtl: bool) -> Vec<u8> {
|
||||
let base = u8::from(rtl);
|
||||
if !rtl && !needs_bidi(chars) {
|
||||
return vec![0; chars.len()];
|
||||
}
|
||||
let info = BidiInfo::new(text, Some(if rtl { Level::rtl() } else { Level::ltr() }));
|
||||
// `levels` is per byte; take each character's first byte.
|
||||
text.char_indices().map(|(b, _)| info.levels.get(b).map(|l| l.number()).unwrap_or(base)).collect()
|
||||
}
|
||||
|
||||
/// Whitespace-like for rule L1: reset to the paragraph level when trailing a line or preceding a
|
||||
/// segment/paragraph separator.
|
||||
fn l1_whitespace(c: BidiClass) -> bool {
|
||||
matches!(c, WS | FSI | LRI | RLI | PDI | BN | LRE | RLE | LRO | RLO | PDF)
|
||||
}
|
||||
|
||||
/// Rule L1 for one line: segment and paragraph separators, any whitespace run before them, and
|
||||
/// whitespace at the end of the line take the paragraph level.
|
||||
pub(crate) fn line_levels(chars: &[char], levels: &[u8], para_level: u8) -> Vec<u8> {
|
||||
let mut out: Vec<u8> = levels.to_vec();
|
||||
out.resize(chars.len(), para_level);
|
||||
// Walk backwards: `trailing` is true while every character after this one (up to the line end
|
||||
// or a separator) is whitespace.
|
||||
let mut trailing = true;
|
||||
for (i, c) in chars.iter().enumerate().rev() {
|
||||
let class = bidi_class(*c);
|
||||
if matches!(class, S | B) {
|
||||
if let Some(l) = out.get_mut(i) {
|
||||
*l = para_level;
|
||||
}
|
||||
trailing = true;
|
||||
} else if l1_whitespace(class) {
|
||||
if trailing && let Some(l) = out.get_mut(i) {
|
||||
*l = para_level;
|
||||
}
|
||||
} else {
|
||||
trailing = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rule L2: the visual order of a line's characters (indices into `levels`, left to right). From
|
||||
/// the highest level down to the lowest odd level, every maximal run at that level or higher is
|
||||
/// reversed.
|
||||
pub(crate) fn visual_order(levels: &[u8]) -> Vec<usize> {
|
||||
let mut order: Vec<usize> = (0..levels.len()).collect();
|
||||
let Some(&max) = levels.iter().max() else { return order };
|
||||
let min_odd = levels.iter().copied().filter(|l| l % 2 == 1).min().unwrap_or(max.saturating_add(1));
|
||||
let mut lvl = max;
|
||||
while lvl >= min_odd && lvl > 0 {
|
||||
let mut i = 0;
|
||||
while i < order.len() {
|
||||
let at = |k: usize| order.get(k).and_then(|&j| levels.get(j)).copied().unwrap_or(0);
|
||||
if at(i) >= lvl {
|
||||
let start = i;
|
||||
while i < order.len() && at(i) >= lvl {
|
||||
i += 1;
|
||||
}
|
||||
if let Some(run) = order.get_mut(start..i) {
|
||||
run.reverse();
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
lvl -= 1;
|
||||
}
|
||||
order
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lv(s: &str, rtl: bool) -> Vec<u8> {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
levels(s, &chars, rtl)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_arabic_latin_numbers_and_brackets() {
|
||||
let s = "سنة 2024 (Retrait) فقط";
|
||||
let l = lv(s, true);
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
assert_eq!(l.len(), chars.len());
|
||||
// Arabic letters at the paragraph level 1; European digits at 2; Latin at 2.
|
||||
assert_eq!(l[0], 1);
|
||||
assert_eq!(&l[4..8], &[2, 2, 2, 2]);
|
||||
assert_eq!(&l[10..17], &[2; 7]);
|
||||
assert_eq!(l[19], 1);
|
||||
// Brackets around Latin inside an RTL paragraph resolve to the embedding direction (N0).
|
||||
assert_eq!(l[9] % 2, 1, "( is right-to-left");
|
||||
assert_eq!(l[17] % 2, 1, ") is right-to-left");
|
||||
// Visual order, left to right: the Arabic words reversed, Latin and digits kept, the
|
||||
// bracket characters swapped (their glyphs are mirrored back by the shaper, rule L4).
|
||||
let order = visual_order(&line_levels(&chars, &l, 1));
|
||||
let vis: String = order.iter().map(|&i| chars[i]).collect();
|
||||
assert_eq!(vis, "طقف )Retrait( 2024 ةنس");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ltr_paragraph_fast_path_and_embedded_rtl() {
|
||||
assert_eq!(lv("Hello 123", false), vec![0; 9]);
|
||||
let s = "ab سن cd";
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let l = lv(s, false);
|
||||
assert_eq!(l, vec![0, 0, 0, 1, 1, 0, 0, 0]);
|
||||
let vis: String = visual_order(&l).iter().map(|&i| chars[i]).collect();
|
||||
assert_eq!(vis, "ab نس cd");
|
||||
// An RTL paragraph of Latin text: the text stays left to right inside it.
|
||||
assert_eq!(lv("ab", true), vec![2, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l1_resets_trailing_whitespace_and_tabs() {
|
||||
let chars: Vec<char> = "ab\tcd ".chars().collect();
|
||||
let l = line_levels(&chars, &[2, 2, 2, 2, 2, 2, 2], 1);
|
||||
assert_eq!(l, vec![2, 2, 1, 2, 2, 1, 1]);
|
||||
// Short level slices never panic.
|
||||
assert_eq!(line_levels(&['a', 'b'], &[], 1), vec![1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_order_edge_cases() {
|
||||
assert!(visual_order(&[]).is_empty());
|
||||
assert_eq!(visual_order(&[0, 0]), vec![0, 1]);
|
||||
assert_eq!(visual_order(&[1, 1, 1]), vec![2, 1, 0]);
|
||||
// Numbers (level 2) inside RTL keep their order.
|
||||
assert_eq!(visual_order(&[1, 2, 2, 1]), vec![3, 1, 2, 0]);
|
||||
assert_eq!(visual_order(&[125, 125]), vec![1, 0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Date and time fields (`a:fld type="datetime…"`): the text each field type shows for a moment,
|
||||
//! and the local clock.
|
||||
//!
|
||||
//! The field types and their formats follow ECMA-376 Part 1, §21.1.2.2.4 (`fld`): `datetime1`
|
||||
//! to `datetime13`, written as in PowerPoint's English (United States) Date and Time list.
|
||||
//! `datetime` and `datetimeFigureOut` show the language's short date. Numeric dates follow the
|
||||
//! run's language; formats with month or day names, or AM/PM, are only computed for English, so
|
||||
//! other languages keep the text the file was saved with.
|
||||
|
||||
/// A local date and time: what the clock read, or a fixed moment (tests, deterministic output).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DateTime {
|
||||
pub year: i32,
|
||||
/// 1–12.
|
||||
pub month: u32,
|
||||
/// 1–31.
|
||||
pub day: u32,
|
||||
/// 0–23.
|
||||
pub hour: u32,
|
||||
pub minute: u32,
|
||||
pub second: u32,
|
||||
}
|
||||
|
||||
impl DateTime {
|
||||
pub fn new(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> Self {
|
||||
DateTime { year, month, day, hour, minute, second }
|
||||
}
|
||||
|
||||
/// The local date and time now (the user's time zone); `None` where there is no clock (wasm).
|
||||
pub fn now_local() -> Option<DateTime> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use chrono::{Datelike, Timelike};
|
||||
let n = chrono::Local::now();
|
||||
Some(DateTime::new(n.year(), n.month(), n.day(), n.hour(), n.minute(), n.second()))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A real calendar date and time of day.
|
||||
fn is_valid(&self) -> bool {
|
||||
let leap = self.year % 4 == 0 && (self.year % 100 != 0 || self.year % 400 == 0);
|
||||
let days = match self.month {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if leap => 29,
|
||||
2 => 28,
|
||||
_ => return false,
|
||||
};
|
||||
(1..=9999).contains(&self.year) && (1..=days).contains(&self.day) && self.hour < 24 && self.minute < 60 && self.second < 61
|
||||
}
|
||||
|
||||
/// Day of the week, 0 = Sunday (days since 1970-01-01, Howard Hinnant's algorithm).
|
||||
fn weekday(&self) -> usize {
|
||||
let (y, m, d) = (i64::from(self.year), i64::from(self.month), i64::from(self.day));
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y - era * 400;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
let days = era * 146_097 + doe - 719_468;
|
||||
(days + 4).rem_euclid(7) as usize
|
||||
}
|
||||
}
|
||||
|
||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
/// Is field `kind` a date/time field?
|
||||
pub fn is_date_field(kind: &str) -> bool {
|
||||
kind.starts_with("datetime")
|
||||
}
|
||||
|
||||
/// The text date/time field `kind` shows at `now` in a run of language `lang` (BCP 47, e.g.
|
||||
/// `en-US`). `None` when `kind` isn't a date/time type this knows, `now` isn't a real date, or
|
||||
/// the format needs names in a language other than English: the field keeps its saved text.
|
||||
pub fn field_text(kind: &str, lang: Option<&str>, now: DateTime) -> Option<String> {
|
||||
if !now.is_valid() {
|
||||
return None;
|
||||
}
|
||||
let lang = lang.map(str::trim).filter(|l| !l.is_empty()).unwrap_or("en-US").to_ascii_lowercase();
|
||||
let english = lang == "en" || lang.starts_with("en-");
|
||||
let month = MONTHS.get(now.month.checked_sub(1)? as usize)?;
|
||||
let mon = month.get(..3)?;
|
||||
let day = DAYS.get(now.weekday())?;
|
||||
let (d, y) = (now.day, now.year);
|
||||
let yy = y.rem_euclid(100);
|
||||
let h12 = match now.hour % 12 {
|
||||
0 => 12,
|
||||
h => h,
|
||||
};
|
||||
let ampm = if now.hour < 12 { "AM" } else { "PM" };
|
||||
let (mi, s) = (now.minute, now.second);
|
||||
let short = short_date(&lang, now);
|
||||
let named = |text: String| english.then_some(text);
|
||||
match kind {
|
||||
"datetime" | "datetimeFigureOut" | "datetime1" => Some(short),
|
||||
"datetime2" => named(format!("{day}, {month} {d}, {y}")),
|
||||
"datetime3" => named(format!("{d} {month} {y}")),
|
||||
"datetime4" => named(format!("{month} {d}, {y}")),
|
||||
"datetime5" => named(format!("{d}-{mon}-{yy:02}")),
|
||||
"datetime6" => named(format!("{month} {yy:02}")),
|
||||
"datetime7" => named(format!("{mon}-{yy:02}")),
|
||||
"datetime8" => named(format!("{short} {h12}:{mi:02} {ampm}")),
|
||||
"datetime9" => named(format!("{short} {h12}:{mi:02}:{s:02} {ampm}")),
|
||||
"datetime10" => Some(format!("{}:{mi:02}", now.hour)),
|
||||
"datetime11" => Some(format!("{}:{mi:02}:{s:02}", now.hour)),
|
||||
"datetime12" => named(format!("{h12}:{mi:02} {ampm}")),
|
||||
"datetime13" => named(format!("{h12}:{mi:02}:{s:02} {ampm}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The short (numeric) date of language `lang` (lower case), US order unless the language is
|
||||
/// known to write its dates another way.
|
||||
fn short_date(lang: &str, t: DateTime) -> String {
|
||||
let (y, m, d) = (t.year, t.month, t.day);
|
||||
let primary = lang.split(['-', '_']).next().unwrap_or("");
|
||||
match lang {
|
||||
"en-gb" | "en-au" | "en-nz" | "en-ie" | "en-in" => return format!("{d:02}/{m:02}/{y}"),
|
||||
"en-ca" | "fr-ca" | "en-za" => return format!("{y}-{m:02}-{d:02}"),
|
||||
_ => {}
|
||||
}
|
||||
match primary {
|
||||
"fr" | "es" | "it" | "pt" | "el" | "ca" | "vi" => format!("{d:02}/{m:02}/{y}"),
|
||||
"de" | "ru" | "pl" | "cs" | "sk" | "tr" | "fi" | "nb" | "nn" | "no" | "da" | "uk" | "ro" => format!("{d:02}.{m:02}.{y}"),
|
||||
"nl" => format!("{d}-{m}-{y}"),
|
||||
"ja" | "zh" => format!("{y}/{m}/{d}"),
|
||||
"ko" | "sv" | "lt" => format!("{y}-{m:02}-{d:02}"),
|
||||
_ => format!("{m}/{d}/{y}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FRI: DateTime = DateTime { year: 2026, month: 10, day: 9, hour: 20, minute: 5, second: 7 };
|
||||
|
||||
#[test]
|
||||
fn english_formats_of_every_type() {
|
||||
let f = |k: &str| field_text(k, Some("en-US"), FRI);
|
||||
assert_eq!(f("datetime1").as_deref(), Some("10/9/2026"));
|
||||
assert_eq!(f("datetime2").as_deref(), Some("Friday, October 9, 2026"));
|
||||
assert_eq!(f("datetime3").as_deref(), Some("9 October 2026"));
|
||||
assert_eq!(f("datetime4").as_deref(), Some("October 9, 2026"));
|
||||
assert_eq!(f("datetime5").as_deref(), Some("9-Oct-26"));
|
||||
assert_eq!(f("datetime6").as_deref(), Some("October 26"));
|
||||
assert_eq!(f("datetime7").as_deref(), Some("Oct-26"));
|
||||
assert_eq!(f("datetime8").as_deref(), Some("10/9/2026 8:05 PM"));
|
||||
assert_eq!(f("datetime9").as_deref(), Some("10/9/2026 8:05:07 PM"));
|
||||
assert_eq!(f("datetime10").as_deref(), Some("20:05"));
|
||||
assert_eq!(f("datetime11").as_deref(), Some("20:05:07"));
|
||||
assert_eq!(f("datetime12").as_deref(), Some("8:05 PM"));
|
||||
assert_eq!(f("datetime13").as_deref(), Some("8:05:07 PM"));
|
||||
assert_eq!(f("datetimeFigureOut").as_deref(), Some("10/9/2026"));
|
||||
assert_eq!(f("datetime").as_deref(), Some("10/9/2026"));
|
||||
// No language: US English. Midnight and noon are 12 on the clock.
|
||||
assert_eq!(field_text("datetime12", None, DateTime::new(2024, 2, 29, 0, 0, 0)).as_deref(), Some("12:00 AM"));
|
||||
assert_eq!(field_text("datetime12", None, DateTime::new(2024, 2, 29, 12, 30, 0)).as_deref(), Some("12:30 PM"));
|
||||
assert_eq!(field_text("datetime2", None, DateTime::new(2000, 1, 1, 0, 0, 0)).as_deref(), Some("Saturday, January 1, 2000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_orders_numeric_dates_and_keeps_names_english_only() {
|
||||
assert_eq!(field_text("datetime1", Some("en-GB"), FRI).as_deref(), Some("09/10/2026"));
|
||||
assert_eq!(field_text("datetimeFigureOut", Some("de-DE"), FRI).as_deref(), Some("09.10.2026"));
|
||||
assert_eq!(field_text("datetime1", Some("ja-JP"), FRI).as_deref(), Some("2026/10/9"));
|
||||
assert_eq!(field_text("datetime1", Some("sv-SE"), FRI).as_deref(), Some("2026-10-09"));
|
||||
assert_eq!(field_text("datetime10", Some("fr-FR"), FRI).as_deref(), Some("20:05"));
|
||||
// Month names in another language: not computed (the field keeps its saved text).
|
||||
assert_eq!(field_text("datetime2", Some("de-DE"), FRI), None);
|
||||
assert_eq!(field_text("datetime12", Some("fr-FR"), FRI), None);
|
||||
assert_eq!(field_text("datetime4", Some("en-GB"), FRI).as_deref(), Some("October 9, 2026"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_types_and_impossible_dates_give_nothing() {
|
||||
assert_eq!(field_text("slidenum", None, FRI), None);
|
||||
assert_eq!(field_text("datetime14", None, FRI), None);
|
||||
for bad in [
|
||||
DateTime::new(2026, 0, 9, 0, 0, 0),
|
||||
DateTime::new(2026, 13, 9, 0, 0, 0),
|
||||
DateTime::new(2026, 2, 29, 0, 0, 0),
|
||||
DateTime::new(2026, 4, 31, 0, 0, 0),
|
||||
DateTime::new(2026, 1, 1, 24, 0, 0),
|
||||
DateTime::new(i32::MIN, 1, 1, 0, 0, 0),
|
||||
DateTime::new(i32::MAX, u32::MAX, u32::MAX, u32::MAX, u32::MAX, u32::MAX),
|
||||
] {
|
||||
assert_eq!(field_text("datetime2", None, bad), None, "{bad:?}");
|
||||
}
|
||||
assert!(is_date_field("datetimeFigureOut") && !is_date_field("slidenum"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clock_reads_a_real_date() {
|
||||
if let Some(now) = DateTime::now_local() {
|
||||
assert!(field_text("datetime1", None, now).is_some(), "{now:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+900
-201
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use deckcraft_model::Fill;
|
||||
use deckcraft_model::defaults;
|
||||
use deckcraft_model::text::{Paragraph, Run};
|
||||
use deckcraft_model::{LayoutType, Presentation, Xfrm};
|
||||
@@ -44,6 +45,20 @@ fn bullets_are_drawn_for_body_text() {
|
||||
assert_eq!(l.para_lines.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_first_character_of_a_bullet_is_drawn() {
|
||||
// "••" (seen in the wild) draws one dot in PowerPoint.
|
||||
for (char, glyphs) in [("\u{2022}\u{2022}", 1), ("\u{2022}", 1), ("-->", 1)] {
|
||||
let (p, mut sh) = setup("One", 600.0, 300.0);
|
||||
sh.text.as_mut().unwrap().paragraphs[0].props.bullet = Some(Bullet::Char { char: char.into() });
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run");
|
||||
assert_eq!(bullet.glyphs.len(), glyphs, "{char:?}");
|
||||
}
|
||||
// The first grapheme cluster is kept whole.
|
||||
assert_eq!(map_symbol_bullet("e\u{301}x", None), "e\u{301}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrink_on_overflow() {
|
||||
let text = (0..30).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n");
|
||||
@@ -121,3 +136,469 @@ fn numbered_list_counts() {
|
||||
let l = lay(&p, &sh, 400.0, 300.0);
|
||||
assert!(l.runs.len() >= 6);
|
||||
}
|
||||
|
||||
// ---------- Bidirectional text and Arabic typography ----------
|
||||
|
||||
const MIXED: &str = "سنة 2024 (Retrait) فقط";
|
||||
|
||||
/// A one-paragraph body with no bullet, `rtl` and `align` set.
|
||||
fn bidi_setup(runs: Vec<Run>, w: f64, rtl: bool, align: Align) -> (Presentation, Shape) {
|
||||
let (p, mut sh) = setup("x", w, 300.0);
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
t.paragraphs = vec![Paragraph { runs, ..Default::default() }];
|
||||
let pp = &mut t.paragraphs[0].props;
|
||||
pp.rtl = Some(rtl);
|
||||
pp.align = Some(align);
|
||||
pp.bullet = Some(Bullet::None);
|
||||
pp.margin_left = Some(0.0);
|
||||
pp.indent = Some(0.0);
|
||||
(p, sh)
|
||||
}
|
||||
|
||||
/// Is any face able to draw Arabic here (installed or bundled)? Shaping assertions that need
|
||||
/// real Arabic glyphs are skipped without one.
|
||||
fn arabic_face() -> Option<Arc<FontFace>> {
|
||||
let db = FontDb::global();
|
||||
let f = db.cascade(&['\u{0633}'], &[db.face("Inter", "Regular")], "Regular");
|
||||
f.covers('\u{0633}').then_some(f)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_mixed_string_is_reordered_per_line() {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
assert_eq!(l.lines.len(), 1);
|
||||
let line = &l.lines[0];
|
||||
assert!(line.rtl);
|
||||
let n = MIXED.chars().count();
|
||||
assert_eq!(line.edges.len(), n);
|
||||
assert_eq!(line.caret_x.len(), n + 1);
|
||||
let mid = |k: usize| (line.edges[k].0 + line.edges[k].1) / 2.0;
|
||||
// Visual order right to left: سنة | 2024 | (Retrait) | فقط.
|
||||
assert!(mid(0) > mid(4), "first Arabic word right of the number");
|
||||
assert!(mid(4) > mid(10), "number right of the Latin word");
|
||||
assert!(mid(10) > mid(19), "Latin right of the last Arabic word");
|
||||
// Inside the Arabic words, logical order runs right to left; digits and Latin left to right.
|
||||
assert!(mid(0) > mid(1) && mid(1) > mid(2));
|
||||
assert!(mid(4) < mid(5) && mid(5) < mid(7));
|
||||
assert!(mid(10) < mid(16));
|
||||
// The brackets sit outside "Retrait": "(" (logically first) on its right.
|
||||
assert!(mid(9) > mid(16) && mid(17) < mid(10));
|
||||
// Right-aligned at the start edge S = W - rIns; the downstream caret at 0 is that edge.
|
||||
let right_edge = l.inner.x1;
|
||||
assert!((line.extent.1 - right_edge).abs() < 0.5, "{} vs {right_edge}", line.extent.1);
|
||||
assert!((line.caret_x[0] - line.extent.1).abs() < 0.01);
|
||||
// Levels after L1: Arabic odd, digits and Latin even.
|
||||
assert_eq!(line.levels[0], 1);
|
||||
assert_eq!(line.levels[4], 2);
|
||||
assert_eq!(line.levels[10], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_paragraph_starts_at_the_right_with_the_bullet_outside() {
|
||||
let (p, mut sh) = bidi_setup(vec![Run::new("مرحبا بكم")], 400.0, true, Align::Right);
|
||||
let pp = &mut sh.text.as_mut().unwrap().paragraphs[0].props;
|
||||
pp.bullet = Some(Bullet::Char { char: "•".into() });
|
||||
pp.margin_left = Some(36.0);
|
||||
pp.indent = Some(-36.0);
|
||||
let l = lay(&p, &sh, 400.0, 300.0);
|
||||
let line = &l.lines[0];
|
||||
// Text starts marL from the right edge; the bullet hangs at S - (marL + indent) = S.
|
||||
let s_edge = l.inner.x1;
|
||||
assert!((line.extent.1 - (s_edge - 36.0)).abs() < 0.5, "text right {} expected {}", line.extent.1, s_edge - 36.0);
|
||||
let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run");
|
||||
let bx = bullet.glyphs[0].1;
|
||||
assert!(bx > line.extent.1 - 0.5, "bullet {bx} right of the text {}", line.extent.1);
|
||||
assert!(bx < s_edge, "bullet inside the box");
|
||||
// An LTR paragraph keeps the bullet on the left.
|
||||
sh.text.as_mut().unwrap().paragraphs[0].props.rtl = Some(false);
|
||||
sh.text.as_mut().unwrap().paragraphs[0].props.align = Some(Align::Left);
|
||||
let l = lay(&p, &sh, 400.0, 300.0);
|
||||
let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run");
|
||||
assert!(bullet.glyphs[0].1 < l.lines[0].extent.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_left_and_center_alignment() {
|
||||
let (p, sh) = bidi_setup(vec![Run::new("سلام")], 400.0, true, Align::Left);
|
||||
let l = lay(&p, &sh, 400.0, 300.0);
|
||||
assert!((l.lines[0].extent.0 - l.inner.x0).abs() < 0.5, "algn=l is flush left in RTL too");
|
||||
let (p, sh) = bidi_setup(vec![Run::new("سلام")], 400.0, true, Align::Center);
|
||||
let l = lay(&p, &sh, 400.0, 300.0);
|
||||
let c = (l.lines[0].extent.0 + l.lines[0].extent.1) / 2.0;
|
||||
assert!((c - (l.inner.x0 + l.inner.x1) / 2.0).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bidi_caret_stops_round_trip_through_hit_testing() {
|
||||
for rtl in [true, false] {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, rtl, if rtl { Align::Right } else { Align::Left });
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let line = &l.lines[0];
|
||||
for (x, ch, aff) in line.visual_stops() {
|
||||
let (cx, ..) = l.caret_at(Pos { para: 0, ch }, aff).unwrap();
|
||||
assert!((cx - x).abs() < 0.01, "stop {ch} {aff:?}: caret {cx} vs stop {x}");
|
||||
}
|
||||
// Clicking just inside either side of each grapheme puts the caret on that side.
|
||||
for (a, b) in line.graphemes() {
|
||||
let Some((x0, x1)) = line.grapheme_box(a, b) else { continue };
|
||||
if x1 - x0 < 1.0 {
|
||||
continue;
|
||||
}
|
||||
let y = (line.top + line.bottom) / 2.0;
|
||||
for x in [x0 + 0.2, x1 - 0.2] {
|
||||
let (pos, aff) = l.hit_affinity(Point::new(x, y));
|
||||
let (cx, ..) = l.caret_at(pos, aff).unwrap();
|
||||
let side = if x < (x0 + x1) / 2.0 { x0 } else { x1 };
|
||||
assert!((cx - side).abs() < 0.01, "rtl={rtl} grapheme {a}: hit {x} -> {pos:?} {aff:?} caret {cx}, expected {side}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_movement_walks_the_line_left_to_right() {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let stops = l.lines[0].visual_stops();
|
||||
let (x0, ch0, aff0) = stops[0];
|
||||
let (mut pos, mut aff) = (Pos { para: 0, ch: ch0 }, aff0);
|
||||
let mut xs = vec![x0];
|
||||
for _ in 0..200 {
|
||||
let (np, na) = l.move_caret(pos, aff, true, MoveMode::Visual);
|
||||
if (np, na) == (pos, aff) {
|
||||
break;
|
||||
}
|
||||
(pos, aff) = (np, na);
|
||||
xs.push(l.caret_at(pos, aff).unwrap().0);
|
||||
}
|
||||
assert!(xs.windows(2).all(|w| w[1] > w[0]), "moving right always moves right: {xs:?}");
|
||||
let right = l.lines[0].extent.1;
|
||||
assert!((xs.last().unwrap() - right).abs() < 0.01, "reaches the right edge");
|
||||
// Right arrow at the right edge of an RTL paragraph's only line goes nowhere.
|
||||
assert_eq!(l.move_caret(pos, aff, true, MoveMode::Visual).0, pos);
|
||||
// Moving left from the right edge in an RTL paragraph is logically forward.
|
||||
let (p1, _) = l.move_caret(Pos { para: 0, ch: 0 }, Affinity::Downstream, false, MoveMode::Visual);
|
||||
assert_eq!(p1.ch, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logical_movement_steps_over_harakat() {
|
||||
// beh + fatha + teh: the fatha belongs to the beh's grapheme.
|
||||
let (p, sh) = bidi_setup(vec![Run::new("\u{0628}\u{064E}\u{062A}")], 400.0, true, Align::Right);
|
||||
let l = lay(&p, &sh, 400.0, 300.0);
|
||||
let (n1, _) = l.move_caret(Pos { para: 0, ch: 0 }, Affinity::Downstream, true, MoveMode::Logical);
|
||||
assert_eq!(n1.ch, 2);
|
||||
let (n2, _) = l.move_caret(n1, Affinity::Downstream, false, MoveMode::Logical);
|
||||
assert_eq!(n2.ch, 0);
|
||||
// No caret stop inside a grapheme.
|
||||
assert!(l.lines[0].visual_stops().iter().all(|s| s.1 != 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_is_the_union_of_visual_boxes() {
|
||||
// LTR paragraph with an Arabic word: visually "ab ةنس cd".
|
||||
let (p, sh) = bidi_setup(vec![Run::new("ab سنة cd")], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
// Logical [1, 5) = "b", " ", "س", "ن": two separate boxes on screen.
|
||||
let r = l.selection_rects(Pos { para: 0, ch: 1 }, Pos { para: 0, ch: 5 });
|
||||
assert_eq!(r.len(), 2, "{r:?}");
|
||||
// The whole line is one box.
|
||||
assert_eq!(l.selection_rects(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 9 }).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_only_style_changes_keep_arabic_joining() {
|
||||
let Some(_) = arabic_face() else {
|
||||
eprintln!("skipped: no Arabic face available");
|
||||
return;
|
||||
};
|
||||
let word = "سنة";
|
||||
let glyphs = |runs: Vec<Run>| {
|
||||
let (p, sh) = bidi_setup(runs, 400.0, true, Align::Right);
|
||||
let l = lay(&p, &sh, 400.0, 300.0);
|
||||
let mut g: Vec<(u32, f64)> = l.runs.iter().flat_map(|r| r.glyphs.iter().map(|g| (g.0, g.1))).collect();
|
||||
g.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
g.into_iter().map(|g| g.0).collect::<Vec<_>>()
|
||||
};
|
||||
let whole = glyphs(vec![Run::new(word)]);
|
||||
let mut red = Run::new("سن");
|
||||
red.props.fill = Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(Rgba::from_hex("C00000").unwrap())));
|
||||
let split = glyphs(vec![red, Run::new("ة")]);
|
||||
assert_eq!(whole, split, "a colour change inside a word keeps the joined forms");
|
||||
// And the joined forms differ from the isolated letters.
|
||||
let isolated: Vec<u32> = word.chars().map(|c| arabic_face().unwrap().glyph_for(c)).collect();
|
||||
assert_ne!(whole.iter().rev().copied().collect::<Vec<_>>(), isolated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn just_low_stretches_arabic_with_kashida() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no Arabic face available");
|
||||
return;
|
||||
}
|
||||
// Tatweel glyphs drawn, counted in each run's own face (the one the layout picked).
|
||||
let tatweels = |l: &TextLayout| {
|
||||
l.runs
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let tg = r.face.glyph_for('\u{0640}');
|
||||
if tg == 0 { 0 } else { r.glyphs.iter().filter(|g| g.0 == tg).count() }
|
||||
})
|
||||
.sum::<usize>()
|
||||
};
|
||||
let text = "بسم الله الرحمن الرحيم نستعين به ونستغفره ونتوب إليه";
|
||||
let (p, sh) = bidi_setup(vec![Run::new(text)], 220.0, true, Align::JustLow);
|
||||
let l = lay(&p, &sh, 220.0, 600.0);
|
||||
assert!(l.lines.len() >= 2);
|
||||
let first = &l.lines[0];
|
||||
let w = l.inner.width();
|
||||
assert!((first.extent.1 - first.extent.0 - w).abs() < 0.5, "first line fills the box: {:?} vs {w}", first.extent);
|
||||
if l.runs.iter().all(|r| r.face.glyph_for('\u{0640}') == 0) {
|
||||
eprintln!("skipped: the Arabic face has no tatweel");
|
||||
return;
|
||||
}
|
||||
assert!(tatweels(&l) > 0, "kashida drawn");
|
||||
// The last line isn't stretched and sits at the start (right) edge.
|
||||
let last = l.lines.last().unwrap();
|
||||
assert!((last.extent.1 - l.inner.x1).abs() < 0.5);
|
||||
// Plain justify uses spaces: no tatweel.
|
||||
let (p, sh) = bidi_setup(vec![Run::new(text)], 220.0, true, Align::Justify);
|
||||
let l = lay(&p, &sh, 220.0, 600.0);
|
||||
assert_eq!(tatweels(&l), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_rtl_lines_break_in_logical_order() {
|
||||
let text = "واحد اثنان ثلاثة أربعة خمسة ستة سبعة ثمانية تسعة عشرة";
|
||||
let (p, sh) = bidi_setup(vec![Run::new(text)], 160.0, true, Align::Right);
|
||||
let l = lay(&p, &sh, 160.0, 600.0);
|
||||
assert!(l.lines.len() >= 2);
|
||||
// Lines cover the text in logical order: the first line holds the first words.
|
||||
assert_eq!(l.lines[0].start, 0);
|
||||
for w in l.lines.windows(2) {
|
||||
assert!(w[1].start >= w[0].end);
|
||||
}
|
||||
for line in &l.lines {
|
||||
assert!(line.extent.0 >= l.inner.x0 - 0.5, "line fits: {:?}", line.extent);
|
||||
assert!((line.extent.1 - l.inner.x1).abs() < 0.5, "flush right");
|
||||
}
|
||||
// Upstream affinity at a wrap keeps the caret on the earlier line.
|
||||
let end0 = l.lines[0].end;
|
||||
assert_eq!(l.line_of_affine(Pos { para: 0, ch: end0 }, Affinity::Upstream), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_bidi_input_never_panics() {
|
||||
let nasty = format!("{}\t\u{b}\u{202E}abc\u{2069}{}(]))[ ١٢٣ \u{0640}\u{064E}\u{064E}\u{200D}", "\u{202B}".repeat(200), "\u{2067}".repeat(130));
|
||||
for rtl in [true, false] {
|
||||
for align in [Align::Left, Align::Right, Align::Center, Align::Justify, Align::JustLow, Align::Distributed] {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(nasty.clone()), Run::new(MIXED)], 60.0, rtl, align);
|
||||
let l = lay(&p, &sh, 60.0, 60.0);
|
||||
for line in &l.lines {
|
||||
assert_eq!(line.caret_x.len(), line.end - line.start + 1);
|
||||
assert_eq!(line.edges.len(), line.end - line.start);
|
||||
}
|
||||
let _ = l.hit_affinity(Point::new(-1e9, 1e9));
|
||||
let _ = l.selection_rects(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 9999 });
|
||||
let mut pos = (Pos::default(), Affinity::Downstream);
|
||||
for i in 0..50 {
|
||||
pos = l.move_caret(pos.0, pos.1, i % 3 != 0, if i % 2 == 0 { MoveMode::Visual } else { MoveMode::Logical });
|
||||
}
|
||||
let _ = l.caret_at(Pos { para: 7, ch: 7 }, Affinity::Upstream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of `word` laid out in a run with `baseline` (0 = normal, 0.3 = superscript …).
|
||||
fn run_width(word: &str, baseline: Option<f64>) -> f64 {
|
||||
let mut run = Run::new(word);
|
||||
run.props.size = Some(24.0);
|
||||
run.props.baseline = baseline;
|
||||
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let line = &l.lines[0];
|
||||
line.caret_x.last().unwrap() - line.caret_x.first().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_and_subscript_advance_at_their_drawn_size() {
|
||||
// The glyphs are drawn at two thirds of the size; advancing by the full size spread the
|
||||
// letters apart ("s u p e r").
|
||||
let normal = run_width("superscript", None);
|
||||
for b in [Some(0.3), Some(-0.25)] {
|
||||
let w = run_width("superscript", b);
|
||||
assert!((w / normal - SUB_SUPER).abs() < 0.02, "{b:?}: {w} vs {normal}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_glyphs_sit_inside_their_cells() {
|
||||
let mut run = Run::new("super");
|
||||
run.props.size = Some(24.0);
|
||||
run.props.baseline = Some(0.3);
|
||||
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let line = &l.lines[0];
|
||||
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
|
||||
assert!((r.size - 24.0 * SUB_SUPER).abs() < 1e-6);
|
||||
for (g, (ci, _)) in r.glyphs.iter().zip(&r.cells) {
|
||||
let (x0, x1) = line.edges[*ci - line.start];
|
||||
assert!(g.1 >= x0 - 0.01 && g.1 < x1, "glyph at {} outside its cell {x0}..{x1}", g.1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapping numbered paragraph with `marL` and `indent`; returns (bullet x, first line's text
|
||||
/// x, second line's text x).
|
||||
fn hanging(margin: f64, indent: f64, bullet: Bullet) -> (Option<f64>, f64, f64) {
|
||||
let text = "Wrapping words go on and on so the paragraph needs at least two or three lines here";
|
||||
let (p, mut sh) = setup(text, 300.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(bullet);
|
||||
para.props.margin_left = Some(margin);
|
||||
para.props.indent = Some(indent);
|
||||
let l = lay(&p, &sh, 300.0, 300.0);
|
||||
assert!(l.lines.len() >= 2, "{} lines", l.lines.len());
|
||||
let bx = l.runs.iter().find(|r| r.cells.is_empty() && !r.glyphs.is_empty()).map(|r| r.glyphs[0].1);
|
||||
(bx, l.lines[0].caret_x[0], l.lines[1].caret_x[0])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hang_past_the_box_edge_keeps_its_width_on_the_first_line() {
|
||||
// PowerPoint, marL 0 / indent -36pt: the number sits at the box's edge, the first line's text
|
||||
// 36pt in, and the wrapped lines at the margin (observed with PowerPoint's slide export).
|
||||
let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 };
|
||||
let (bx, first, rest) = hanging(0.0, -36.0, num());
|
||||
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
|
||||
assert!((bx.unwrap() - rest).abs() < 0.5, "bullet {bx:?} rest {rest}");
|
||||
// marL 18 / indent -54: number at the edge, first line at 54, the rest at 18.
|
||||
let (bx, first, rest) = hanging(18.0, -54.0, num());
|
||||
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
|
||||
assert!((rest - bx.unwrap() - 18.0).abs() < 0.5, "bullet {bx:?} rest {rest}");
|
||||
// A character bullet behaves the same.
|
||||
let (_, first, rest) = hanging(0.0, -36.0, Bullet::Char { char: "•".into() });
|
||||
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
|
||||
// Without a bullet the first line just starts at the edge.
|
||||
let (_, first, rest) = hanging(0.0, -36.0, Bullet::None);
|
||||
assert!((first - rest).abs() < 0.5, "first {first} rest {rest}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_hang_still_puts_the_text_at_the_margin() {
|
||||
let (bx, first, rest) = hanging(36.0, -36.0, Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 });
|
||||
assert!((first - rest).abs() < 0.5, "first {first} rest {rest}");
|
||||
assert!((rest - bx.unwrap() - 36.0).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_spaced_lines_are_1_2_times_the_size_apart_whatever_the_font() {
|
||||
// PowerPoint spaces single-spaced lines 1.2 × the font size apart in every font, with the
|
||||
// baseline where the font's Windows ascent/descent split puts it (observed on twenty fonts).
|
||||
for size in [18.0, 40.0] {
|
||||
let mut run = Run::new("HHH");
|
||||
run.props.size = Some(size);
|
||||
let (p, mut sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
t.body.autofit = Some(AutoFit::None);
|
||||
let second = t.paragraphs[0].clone();
|
||||
t.paragraphs.push(second.clone());
|
||||
t.paragraphs.push(second);
|
||||
for para in &mut t.paragraphs {
|
||||
para.props.line_spacing = Some(Spacing::Pct(1.0));
|
||||
para.props.space_before = Some(Spacing::Pts(0.0));
|
||||
para.props.space_after = Some(Spacing::Pts(0.0));
|
||||
}
|
||||
let l = lay(&p, &sh, 2000.0, 600.0);
|
||||
assert_eq!(l.lines.len(), 3);
|
||||
// Each line is 1.2 × the size tall (the face is not compared across lines: another
|
||||
// test's thread can load the family between them, see #252).
|
||||
for line in &l.lines {
|
||||
assert!((line.bottom - line.top - size * LINE_PITCH).abs() < 1e-6, "{size}: {}", line.bottom - line.top);
|
||||
}
|
||||
let face = &l.runs[0].face;
|
||||
let first = &l.lines[0];
|
||||
assert!((first.baseline - first.top - size * LINE_PITCH * face.line_split).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/// The lines of `text` in an Arial-free (whatever face resolves) box `w` wide, as strings.
|
||||
fn wrapped(text: &str, w: f64, set: impl Fn(&mut ParaProps)) -> Vec<String> {
|
||||
let (p, mut sh) = bidi_setup(vec![Run::new(text)], w, false, Align::Left);
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
t.body.inset_l = Some(0.0);
|
||||
t.body.inset_r = Some(0.0);
|
||||
set(&mut t.paragraphs[0].props);
|
||||
let l = lay(&p, &sh, w, 600.0);
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
l.lines.iter().map(|li| chars[li.start..li.end].iter().collect()).collect()
|
||||
}
|
||||
|
||||
/// Width of `text` on one line.
|
||||
fn one_line(text: &str) -> f64 {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(text)], 5000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 5000.0, 300.0);
|
||||
l.lines[0].caret_x.last().unwrap() - l.lines[0].caret_x[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_does_not_break_after_a_slash_or_backslash() {
|
||||
// PowerPoint (latinLnBrk off, its default) keeps "alpha/beta/gamma" whole and only breaks it
|
||||
// where it no longer fits; Unicode line breaking would allow a break after each slash.
|
||||
for sep in ["/", "\\"] {
|
||||
let text = ["alpha", "beta", "gamma", "delta", "epsilon"].join(sep);
|
||||
let w = one_line(&text) * 0.6;
|
||||
let lines = wrapped(&text, w, |_| {});
|
||||
assert!(lines.len() >= 2, "{lines:?}");
|
||||
assert!(lines[0].chars().count() > "alpha/beta".len(), "filled to the edge, not cut at a separator: {lines:?}");
|
||||
}
|
||||
// latinLnBrk on: Unicode's break after a slash is allowed again.
|
||||
let text = "alpha/beta/gamma/delta/epsilon";
|
||||
let lines = wrapped(text, one_line(text) * 0.6, |p| p.latin_line_break = Some(true));
|
||||
assert!(lines[0].ends_with('/'), "{lines:?}");
|
||||
// A slash followed by a space still breaks, and hyphens still break.
|
||||
let lines = wrapped("alpha-beta-gamma-delta-epsilon", one_line("alpha-beta-gamma-") + 1.0, |_| {});
|
||||
assert_eq!(lines[0], "alpha-beta-gamma-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_wraps_between_words() {
|
||||
let text = "한국어 안녕하세요 반갑습니다 감사합니다";
|
||||
let w = one_line("한국어 안녕하세요 반") + 0.5;
|
||||
let lines = wrapped(text, w, |_| {});
|
||||
assert_eq!(lines.iter().map(|l| l.trim_end()).collect::<Vec<_>>(), ["한국어 안녕하세요", "반갑습니다", "감사합니다"], "{lines:?}");
|
||||
// Japanese and Chinese still break between characters.
|
||||
let lines = wrapped("日本語の文章は空白なしで続きます", one_line("日本語の文章") + 0.5, |_| {});
|
||||
assert_eq!(lines[0], "日本語の文章");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_punctuation_hangs_past_the_end_with_hanging_punct() {
|
||||
// PowerPoint (hangingPunct on, as its default text styles set it) lets "。" hang past the
|
||||
// edge instead of pulling the character before it onto the next line.
|
||||
let text = "白なしで続きます。句読点も含めて。";
|
||||
let w = one_line("白なしで続きます") + 0.5;
|
||||
let lines = wrapped(text, w, |p| p.hanging_punct = Some(true));
|
||||
assert_eq!(lines[0], "白なしで続きます。", "{lines:?}");
|
||||
// Without it, "。" can't start a line, so "す。" moves down together.
|
||||
let lines = wrapped(text, w, |p| p.hanging_punct = Some(false));
|
||||
assert_eq!(lines[0], "白なしで続きま", "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emoji_take_a_colour_face_when_one_is_installed() {
|
||||
// PowerPoint draws emoji (and ZWJ sequences, skin tones, flags) from the colour emoji font
|
||||
// even when the run's font has a plain glyph for some of them. Skipped without an installed
|
||||
// colour emoji font.
|
||||
let db = FontDb::global();
|
||||
let probe = db.cascade(&['😀'], &[], "Regular");
|
||||
if !probe.is_color() {
|
||||
return;
|
||||
}
|
||||
for s in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧"] {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(format!("a{s}b"))], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let emoji = l.runs.iter().find(|r| r.cells.iter().any(|(_, c)| !c.is_ascii())).unwrap();
|
||||
assert!(emoji.face.is_color(), "{s:?} drawn with {}", emoji.face.family);
|
||||
assert!(l.runs.iter().filter(|r| r.cells.iter().all(|(_, c)| c.is_ascii())).all(|r| !r.face.is_color()), "the letters keep a text face");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,3 +28,7 @@ js-sys = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
toml = "1"
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Bake the contributor credits into the binary as static Rust tables (craftrules
|
||||
//! standards/contributors.md). Nothing is read at run time.
|
||||
//!
|
||||
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
|
||||
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
|
||||
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
|
||||
//! JSON says the entry was verified (`people_entry`).
|
||||
//!
|
||||
//! A missing or malformed file gives empty tables (or no names) and a warning.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
fn main() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors");
|
||||
let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| {
|
||||
serde_json::from_str::<Value>(t).map_err(|e| e.to_string())
|
||||
})
|
||||
.unwrap_or(Value::Null);
|
||||
let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::<toml::Table>().map_err(|e| e.to_string()))
|
||||
.unwrap_or_default();
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs");
|
||||
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
|
||||
println!("cargo::warning=writing {}: {e}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
let r = std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|t| parse(&t));
|
||||
if let Err(e) = &r {
|
||||
println!("cargo::warning={}: {e}; {fallback}", path.display());
|
||||
}
|
||||
r.ok()
|
||||
}
|
||||
|
||||
/// A non-empty string field of someone's people.toml entry, as a Rust `Option<&str>` literal.
|
||||
fn name(v: Option<&str>) -> String {
|
||||
match v.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(x) => format!("Some({x:?})"),
|
||||
None => "None".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn s(v: &Value, k: &str) -> String {
|
||||
format!("{:?}", v.get(k).and_then(Value::as_str).unwrap_or(""))
|
||||
}
|
||||
|
||||
fn n(v: &Value, k: &str) -> u64 {
|
||||
v.get(k).and_then(Value::as_u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn rows(json: &Value, k: &str) -> Vec<Value> {
|
||||
json.get(k).and_then(Value::as_array).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn generate(json: &Value, people: &toml::Table) -> String {
|
||||
let mut o = String::new();
|
||||
let _ = writeln!(o, "pub static TOTAL_COMMITS: u64 = {};", n(json, "commits"));
|
||||
let _ = writeln!(o, "pub static CONTRIBUTORS: &[Contributor] = &[");
|
||||
for c in rows(json, "contributors") {
|
||||
let login = c.get("login").and_then(Value::as_str).unwrap_or("");
|
||||
if login.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Names only from a verified, self-chosen people.toml entry.
|
||||
let entry = people.get("people").and_then(|p| p.get(login)).filter(|_| c.get("people_entry").and_then(Value::as_bool) == Some(true));
|
||||
let field = |k: &str| entry.and_then(|e| e.get(k)).and_then(toml::Value::as_str);
|
||||
let synced = entry.and_then(|e| e.get("sync_github_name")).and_then(toml::Value::as_bool) == Some(true);
|
||||
let display = field("display_name")
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(|| if synced { c.get("github_name").and_then(Value::as_str) } else { None });
|
||||
let _ = writeln!(
|
||||
o,
|
||||
" Contributor {{ login: {login:?}, display_name: {}, real_name: {}, prs: {}, commits: {}, lines_added: {}, lines_deleted: {}, binary_added: {}, binary_deleted: {}, first_commit: {}, last_commit: {} }},",
|
||||
name(display),
|
||||
name(field("real_name")),
|
||||
n(&c, "prs"),
|
||||
n(&c, "commits"),
|
||||
n(&c, "lines_added"),
|
||||
n(&c, "lines_deleted"),
|
||||
n(&c, "binary_added"),
|
||||
n(&c, "binary_deleted"),
|
||||
s(&c, "first_commit"),
|
||||
s(&c, "last_commit"),
|
||||
);
|
||||
}
|
||||
let _ = writeln!(o, "];");
|
||||
let _ = writeln!(o, "pub static MODELS: &[Model] = &[");
|
||||
for m in rows(json, "models") {
|
||||
let _ = writeln!(
|
||||
o,
|
||||
" Model {{ company: {}, model: {}, version: {}, commits: {}, lines_added: {}, lines_deleted: {} }},",
|
||||
s(&m, "company"),
|
||||
s(&m, "model"),
|
||||
s(&m, "version"),
|
||||
n(&m, "commits"),
|
||||
n(&m, "lines_added"),
|
||||
n(&m, "lines_deleted"),
|
||||
);
|
||||
}
|
||||
let _ = writeln!(o, "];");
|
||||
o
|
||||
}
|
||||
@@ -100,12 +100,19 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
painter.rect_filled(slide_rect.translate(vec2(0.0, 1.5)).expand(1.0), CornerRadius::same(2), t.shadow);
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32);
|
||||
let now = ui.input(|i| i.time);
|
||||
let gifs = match app.session.active() {
|
||||
Some(d) if d.selection.target == Target::Slides => app.media.gifs(&d.doc, d.selection.slide, now, &app.session.gif_paused),
|
||||
_ => vec![],
|
||||
};
|
||||
crate::media::gif_repaint(ui.ctx(), &gifs);
|
||||
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
|
||||
let st = app.session.active();
|
||||
let tex = match st.map(|d| (d.selection.target, d)) {
|
||||
Some((Target::Slides, d)) => {
|
||||
let idx = d.selection.slide;
|
||||
let doc = d.doc.clone();
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, true, 0, app.ui.grayscale)
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×)
|
||||
}
|
||||
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px),
|
||||
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px),
|
||||
@@ -146,9 +153,37 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
pointer(app, ui, &resp, xf);
|
||||
overlays(app, ui, &painter, xf, &t);
|
||||
media_bar(app, ui, xf);
|
||||
gif_buttons(app, ui, xf, &gifs);
|
||||
context_menu(app, &resp);
|
||||
}
|
||||
|
||||
/// Play/pause on an animated GIF under the pointer or selected on its own.
|
||||
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
|
||||
let Some(d) = app.session.active() else { return };
|
||||
if d.selection.text.is_some() || app.session.tool.dragging() {
|
||||
return;
|
||||
}
|
||||
let only = if d.selection.shapes.len() == 1 { d.selection.shapes.first().copied() } else { None };
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let mut toggle = None;
|
||||
for g in gifs {
|
||||
let r = screen_rect(xf, &g.rect);
|
||||
if only != Some(g.id) && !pointer.is_some_and(|p| r.contains(p)) {
|
||||
continue;
|
||||
}
|
||||
let b = crate::media::gif_button_rect(r);
|
||||
// Sensing drags too keeps the press from reaching the canvas (selecting or moving the GIF).
|
||||
let resp = ui.interact(b, ui.id().with(("gif_button", g.id.0)), Sense::click_and_drag());
|
||||
crate::media::paint_gif_button(ui.painter(), b, g.paused, resp.hovered());
|
||||
if resp.clicked() {
|
||||
toggle = Some(g.id);
|
||||
}
|
||||
}
|
||||
if let Some(id) = toggle {
|
||||
let _ = app.run("media.gifPlay", json!({"id": id}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen rect of a slide-space box (rotation ignored).
|
||||
fn screen_rect(xf: Xf, x: &Xfrm) -> Rect {
|
||||
Rect::from_min_max(xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h)))
|
||||
@@ -379,14 +414,9 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
||||
&& let Some(l) = deckcraft_engine::cmd::text::layout_for(st, ts)
|
||||
{
|
||||
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
|
||||
let a = x.affine();
|
||||
let rot = if l.rotation != 0.0 {
|
||||
let c = l.inner.center().to_vec2();
|
||||
deckcraft_geom::Affine::translate(c) * deckcraft_geom::Affine::rotate(l.rotation.to_radians()) * deckcraft_geom::Affine::translate(-c)
|
||||
} else {
|
||||
deckcraft_geom::Affine::IDENTITY
|
||||
};
|
||||
let m = a * rot;
|
||||
// Where the text is drawn: flips don't mirror it, vertical text is turned.
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
|
||||
let (s0, s1) = ts.ordered();
|
||||
for r in l.selection_rects(deckcraft_text::Pos { para: s0.0, ch: s0.1 }, deckcraft_text::Pos { para: s1.0, ch: s1.1 }) {
|
||||
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));
|
||||
@@ -407,9 +437,10 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
||||
&& let deckcraft_model::ShapeKind::Table(tb) = &sh.kind
|
||||
{
|
||||
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
|
||||
let y0: f64 = tb.rows.iter().take(r).map(|rr| rr.height).sum();
|
||||
let heights = deckcraft_engine::tools::table_rows(st, tb);
|
||||
let y0: f64 = heights.iter().take(r).sum();
|
||||
let x0: f64 = tb.cols.iter().take(c).sum();
|
||||
let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), tb.rows.get(r).map(|rr| rr.height).unwrap_or(0.0));
|
||||
let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), heights.get(r).copied().unwrap_or(0.0));
|
||||
let a = x.affine();
|
||||
let pts = [Point::new(x0, y0), Point::new(x0 + w, y0), Point::new(x0 + w, y0 + h), Point::new(x0, y0 + h)].map(|q| xf.to_screen(a * q));
|
||||
poly(painter, pts, Stroke::new(2.0, t.accent));
|
||||
|
||||
@@ -73,6 +73,7 @@ pub fn inspect(app: &SlideApp, ctx: &egui::Context) -> Value {
|
||||
"slideRect": app.slide_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
|
||||
"canvasScale": app.canvas_scale,
|
||||
"dialog": app.dialog.as_ref().map(|d| d.id.clone()),
|
||||
"home": app.home_open(),
|
||||
"showing": app.show.as_ref().map(|s| json!({"slide": s.state.slide, "step": s.state.step, "ended": s.ended})),
|
||||
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty(), "path": d.path})).collect::<Vec<_>>(),
|
||||
"status": app.status.as_ref().map(|s| s.0.clone()),
|
||||
@@ -232,7 +233,14 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
"ui.dialog.open" => {
|
||||
let Some(id) = s("id") else { return err("missing `id`") };
|
||||
app.dialog = Some(crate::dialogs::Dialog::new(id));
|
||||
let mut d = crate::dialogs::Dialog::new(id);
|
||||
if let Some(fields) = p.get("fields").and_then(Value::as_object) {
|
||||
for (k, v) in fields {
|
||||
let v = v.as_str().map_or_else(|| v.to_string(), str::to_string);
|
||||
d.fields.insert(k.clone(), v);
|
||||
}
|
||||
}
|
||||
app.dialog = Some(d);
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.screenshot" => Outcome::Screenshot { path: s("path").map(String::from) },
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//! The About window's credits: every contributor (GitHub login always; display and real names only
|
||||
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
|
||||
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
|
||||
//! read at run time.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use egui::RichText;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Contributor {
|
||||
pub login: &'static str,
|
||||
pub display_name: Option<&'static str>,
|
||||
pub real_name: Option<&'static str>,
|
||||
pub prs: u64,
|
||||
pub commits: u64,
|
||||
pub lines_added: u64,
|
||||
pub lines_deleted: u64,
|
||||
pub binary_added: u64,
|
||||
pub binary_deleted: u64,
|
||||
/// ISO 8601 UTC; empty for someone credited only through a merged PR.
|
||||
pub first_commit: &'static str,
|
||||
pub last_commit: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Model {
|
||||
pub company: &'static str,
|
||||
pub model: &'static str,
|
||||
pub version: &'static str,
|
||||
pub commits: u64,
|
||||
pub lines_added: u64,
|
||||
pub lines_deleted: u64,
|
||||
}
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/credits.rs"));
|
||||
|
||||
/// Which name to show. Display and real names fall back to the `@username` when not given.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum NameMode {
|
||||
#[default]
|
||||
Username,
|
||||
DisplayName,
|
||||
RealName,
|
||||
}
|
||||
|
||||
impl NameMode {
|
||||
pub const ALL: [NameMode; 3] = [NameMode::Username, NameMode::DisplayName, NameMode::RealName];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
NameMode::Username => "Username",
|
||||
NameMode::DisplayName => "Display name",
|
||||
NameMode::RealName => "Real name",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SortKey {
|
||||
Name,
|
||||
Prs,
|
||||
Commits,
|
||||
LinesAdded,
|
||||
LinesDeleted,
|
||||
LinesDelta,
|
||||
BinaryAdded,
|
||||
BinaryDeleted,
|
||||
#[default]
|
||||
FirstCommit,
|
||||
LastCommit,
|
||||
}
|
||||
|
||||
impl SortKey {
|
||||
pub const ALL: [SortKey; 10] = [
|
||||
SortKey::Name,
|
||||
SortKey::Prs,
|
||||
SortKey::Commits,
|
||||
SortKey::LinesAdded,
|
||||
SortKey::LinesDeleted,
|
||||
SortKey::LinesDelta,
|
||||
SortKey::BinaryAdded,
|
||||
SortKey::BinaryDeleted,
|
||||
SortKey::FirstCommit,
|
||||
SortKey::LastCommit,
|
||||
];
|
||||
|
||||
/// Menu label and table header.
|
||||
pub fn label(self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
SortKey::Name => ("Name (A–Z)", "Name"),
|
||||
SortKey::Prs => ("Merged PRs", "PRs"),
|
||||
SortKey::Commits => ("Commits", "Commits"),
|
||||
SortKey::LinesAdded => ("Lines added", "+LOC"),
|
||||
SortKey::LinesDeleted => ("Lines deleted", "−LOC"),
|
||||
SortKey::LinesDelta => ("Line delta", "ΔLOC"),
|
||||
SortKey::BinaryAdded => ("Binary assets added", "+Bin"),
|
||||
SortKey::BinaryDeleted => ("Binary assets removed", "−Bin"),
|
||||
SortKey::FirstCommit => ("First commit", "First"),
|
||||
SortKey::LastCommit => ("Last commit", "Last"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Names and dates read naturally oldest/A first; counts read biggest first.
|
||||
pub fn default_ascending(self) -> bool {
|
||||
matches!(self, SortKey::Name | SortKey::FirstCommit | SortKey::LastCommit)
|
||||
}
|
||||
}
|
||||
|
||||
impl Contributor {
|
||||
pub fn name(&self, mode: NameMode) -> String {
|
||||
let chosen = match mode {
|
||||
NameMode::Username => None,
|
||||
NameMode::DisplayName => self.display_name,
|
||||
NameMode::RealName => self.real_name,
|
||||
};
|
||||
chosen.map_or_else(|| format!("@{}", self.login), str::to_string)
|
||||
}
|
||||
|
||||
pub fn lines_delta(&self) -> i128 {
|
||||
i128::from(self.lines_added) - i128::from(self.lines_deleted)
|
||||
}
|
||||
|
||||
/// One line with everything we know, for tooltips.
|
||||
pub fn summary(&self) -> String {
|
||||
format!(
|
||||
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}",
|
||||
self.login,
|
||||
self.prs,
|
||||
self.commits,
|
||||
group(self.lines_added),
|
||||
group(self.lines_deleted),
|
||||
signed(self.lines_delta()),
|
||||
self.binary_added,
|
||||
self.binary_deleted,
|
||||
day(self.first_commit),
|
||||
day(self.last_commit),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Alphabetical key: case-insensitive, ignoring the `@` of usernames.
|
||||
fn name_key(name: &str) -> String {
|
||||
name.trim_start_matches('@').to_lowercase()
|
||||
}
|
||||
|
||||
fn compare(a: &Contributor, b: &Contributor, mode: NameMode, key: SortKey) -> Ordering {
|
||||
match key {
|
||||
SortKey::Name => name_key(&a.name(mode)).cmp(&name_key(&b.name(mode))),
|
||||
SortKey::Prs => a.prs.cmp(&b.prs),
|
||||
SortKey::Commits => a.commits.cmp(&b.commits),
|
||||
SortKey::LinesAdded => a.lines_added.cmp(&b.lines_added),
|
||||
SortKey::LinesDeleted => a.lines_deleted.cmp(&b.lines_deleted),
|
||||
SortKey::LinesDelta => a.lines_delta().cmp(&b.lines_delta()),
|
||||
SortKey::BinaryAdded => a.binary_added.cmp(&b.binary_added),
|
||||
SortKey::BinaryDeleted => a.binary_deleted.cmp(&b.binary_deleted),
|
||||
// A PR-only contributor (no commit date) sorts after everyone with a date.
|
||||
SortKey::FirstCommit => dated(a.first_commit).cmp(&dated(b.first_commit)),
|
||||
SortKey::LastCommit => dated(a.last_commit).cmp(&dated(b.last_commit)),
|
||||
}
|
||||
}
|
||||
|
||||
fn dated(d: &str) -> (bool, &str) {
|
||||
(d.is_empty(), d)
|
||||
}
|
||||
|
||||
/// Contributors in display order. Ties fall back to the name (A–Z) so the order is stable.
|
||||
pub fn sorted(list: &[Contributor], mode: NameMode, key: SortKey, ascending: bool) -> Vec<&Contributor> {
|
||||
let mut v: Vec<&Contributor> = list.iter().collect();
|
||||
v.sort_by(|a, b| {
|
||||
let o = compare(a, b, mode, key);
|
||||
let o = if ascending { o } else { o.reverse() };
|
||||
o.then_with(|| compare(a, b, mode, SortKey::Name)).then_with(|| a.login.cmp(b.login))
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
fn group(n: u64) -> String {
|
||||
let s = n.to_string();
|
||||
let mut out = String::with_capacity(s.len() + s.len() / 3);
|
||||
for (i, ch) in s.chars().enumerate() {
|
||||
if i > 0 && (s.len() - i).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn signed(n: i128) -> String {
|
||||
let g = group(u64::try_from(n.unsigned_abs()).unwrap_or(u64::MAX));
|
||||
if n < 0 { format!("−{g}") } else { format!("+{g}") }
|
||||
}
|
||||
|
||||
fn day(iso: &str) -> &str {
|
||||
if iso.is_empty() { "—" } else { iso.get(..10).unwrap_or(iso) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct View {
|
||||
names: NameMode,
|
||||
key: SortKey,
|
||||
ascending: bool,
|
||||
table: bool,
|
||||
}
|
||||
|
||||
impl Default for View {
|
||||
fn default() -> Self {
|
||||
View { names: NameMode::Username, key: SortKey::FirstCommit, ascending: true, table: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
|
||||
pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
let id = egui::Id::new("credits_view");
|
||||
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label("Show");
|
||||
for m in NameMode::ALL {
|
||||
if ui.selectable_label(v.names == m, m.label()).clicked() {
|
||||
v.names = m;
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
ui.label("Sort");
|
||||
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
if ui.selectable_label(v.key == k, k.label().0).clicked() {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
});
|
||||
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() {
|
||||
v.ascending = !v.ascending;
|
||||
}
|
||||
ui.separator();
|
||||
if ui.selectable_label(!v.table, "Grab bag").clicked() {
|
||||
v.table = false;
|
||||
}
|
||||
if ui.selectable_label(v.table, "Table").clicked() {
|
||||
v.table = true;
|
||||
}
|
||||
});
|
||||
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
|
||||
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
|
||||
ui.separator();
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
if list.is_empty() {
|
||||
ui.label("No contributor data was built into this copy.");
|
||||
} else if v.table {
|
||||
table(ui, &list, &mut v);
|
||||
} else {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
if i > 0 {
|
||||
ui.label(RichText::new("·").weak());
|
||||
}
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
|
||||
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
for c in list {
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
ui.label(group(c.prs));
|
||||
ui.label(group(c.commits));
|
||||
ui.label(group(c.lines_added));
|
||||
ui.label(group(c.lines_deleted));
|
||||
ui.label(signed(c.lines_delta()));
|
||||
ui.label(group(c.binary_added));
|
||||
ui.label(group(c.binary_deleted));
|
||||
ui.label(day(c.first_commit));
|
||||
ui.label(day(c.last_commit));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
|
||||
pub fn models_ui(ui: &mut egui::Ui) {
|
||||
if MODELS.is_empty() {
|
||||
ui.label("No model credits were built into this copy.");
|
||||
return;
|
||||
}
|
||||
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
|
||||
ui.label(RichText::new(h).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for m in MODELS {
|
||||
ui.label(m.company);
|
||||
ui.label(m.model);
|
||||
ui.label(m.version);
|
||||
ui.label(group(m.commits));
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
|
||||
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn c(
|
||||
login: &'static str,
|
||||
display: Option<&'static str>,
|
||||
real: Option<&'static str>,
|
||||
added: u64,
|
||||
deleted: u64,
|
||||
first: &'static str,
|
||||
) -> Contributor {
|
||||
Contributor {
|
||||
login,
|
||||
display_name: display,
|
||||
real_name: real,
|
||||
prs: 0,
|
||||
commits: 1,
|
||||
lines_added: added,
|
||||
lines_deleted: deleted,
|
||||
binary_added: 0,
|
||||
binary_deleted: 0,
|
||||
first_commit: first,
|
||||
last_commit: first,
|
||||
}
|
||||
}
|
||||
|
||||
fn people() -> [Contributor; 4] {
|
||||
[
|
||||
c("zed", None, None, 5, 50, "2026-01-02T00:00:00Z"),
|
||||
c("Bob", Some("Ann"), None, 10, 0, "2026-01-01T00:00:00Z"),
|
||||
c("alice", None, None, 7, 1, ""),
|
||||
c("carol", None, Some("Dana"), 1, 0, "2026-01-03T00:00:00Z"),
|
||||
]
|
||||
}
|
||||
|
||||
fn logins(v: &[&Contributor]) -> Vec<&'static str> {
|
||||
v.iter().map(|c| c.login).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_fall_back_to_the_username() {
|
||||
let p = c("bob", Some("Bobby"), None, 0, 0, "");
|
||||
assert_eq!(p.name(NameMode::Username), "@bob");
|
||||
assert_eq!(p.name(NameMode::DisplayName), "Bobby");
|
||||
assert_eq!(p.name(NameMode::RealName), "@bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alphabetical_ignores_at_and_case() {
|
||||
let all = people();
|
||||
let v = sorted(&all, NameMode::Username, SortKey::Name, true);
|
||||
assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]);
|
||||
let v = sorted(&all, NameMode::DisplayName, SortKey::Name, true);
|
||||
assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]); // "Ann" sorts with the @usernames
|
||||
let v = sorted(&all, NameMode::RealName, SortKey::Name, false);
|
||||
assert_eq!(logins(&v), ["zed", "carol", "Bob", "alice"]); // "Dana" between bob and zed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delta_and_dates() {
|
||||
let all = people();
|
||||
let v = sorted(&all, NameMode::Username, SortKey::LinesDelta, false);
|
||||
assert_eq!(logins(&v), ["Bob", "alice", "carol", "zed"]);
|
||||
let v = sorted(&all, NameMode::Username, SortKey::FirstCommit, true);
|
||||
assert_eq!(logins(&v), ["Bob", "zed", "carol", "alice"]); // undated last
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting() {
|
||||
assert_eq!(group(1234567), "1,234,567");
|
||||
assert_eq!(group(12), "12");
|
||||
assert_eq!(signed(-1000), "−1,000");
|
||||
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
|
||||
assert_eq!(day(""), "—");
|
||||
}
|
||||
}
|
||||
@@ -693,7 +693,24 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ok || cancel
|
||||
}
|
||||
"about" => {
|
||||
about(app, ui);
|
||||
let tab = d.get("tab", "about");
|
||||
ui.horizontal(|ui| {
|
||||
for (k, l) in [("about", "About"), ("contributors", "Contributors"), ("models", "Models")] {
|
||||
if ui.selectable_label(tab == k, l).clicked() {
|
||||
d.fields.insert("tab".into(), k.into());
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
match tab.as_str() {
|
||||
"contributors" | "models" => {
|
||||
// The credits need room for the table; the About tab keeps its compact size.
|
||||
ui.set_min_size(vec2(640.0, 380.0));
|
||||
ui.set_max_height(380.0);
|
||||
if tab == "models" { crate::credits::models_ui(ui) } else { crate::credits::contributors_ui(ui) }
|
||||
}
|
||||
_ => about(app, ui),
|
||||
}
|
||||
let (ok, _) = buttons(ui, "Close");
|
||||
ok
|
||||
}
|
||||
@@ -830,14 +847,23 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) {
|
||||
paint_logo(ui.painter(), r);
|
||||
ui.label(egui::RichText::new("DeckCraft").font(theme::bold(26.0)));
|
||||
});
|
||||
if app.home_open() {
|
||||
let title = app.session.active().map(|d| d.title()).unwrap_or_default();
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
if ui.button(format!("← Back to {title}")).on_hover_text("Esc").clicked() {
|
||||
let _ = app.run("app.home", json!({"on": false}));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
ui.label(egui::RichText::new("New presentation").font(theme::bold(16.0)));
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add_space(40.0);
|
||||
inset_wrapped(ui, |ui| {
|
||||
for th in deckcraft_model::theme::builtin_themes() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
|
||||
let tile = Rect::from_min_size(r.min, vec2(180.0, 101.0));
|
||||
@@ -888,7 +914,50 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Wrapped rows that all start 40 points in from the left, in line with the start screen's
|
||||
/// headings. The inset is a margin on the container: an `add_space` inside `horizontal_wrapped`
|
||||
/// would indent only the first row.
|
||||
fn inset_wrapped(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui)) {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 40, ..Default::default() }).show(ui, |ui| {
|
||||
ui.horizontal_wrapped(add_contents);
|
||||
});
|
||||
}
|
||||
|
||||
/// PDF bytes with the export dialog's options (web: downloaded instead of written).
|
||||
fn deckcraft_pdf_bytes(doc: &deckcraft_model::Presentation, params: &serde_json::Value) -> Vec<u8> {
|
||||
deckcraft_engine::cmd::file::pdf_bytes(doc, params).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
/// Start screen theme tiles that wrap onto a second row keep the 40-point inset (#17).
|
||||
#[test]
|
||||
fn inset_wrapped_indents_every_row() {
|
||||
let left = Cell::new(0.0_f32);
|
||||
let tiles = RefCell::new(Vec::<Rect>::new());
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(500.0, 400.0)).build_ui(|ui| {
|
||||
left.set(ui.max_rect().left());
|
||||
tiles.borrow_mut().clear();
|
||||
inset_wrapped(ui, |ui| {
|
||||
for _ in 0..4 {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
|
||||
tiles.borrow_mut().push(r);
|
||||
}
|
||||
});
|
||||
});
|
||||
harness.run();
|
||||
let tiles = tiles.borrow().clone();
|
||||
let first_top = tiles.first().map_or(0.0, |r| r.top());
|
||||
assert!(tiles.iter().any(|r| r.top() > first_top), "tiles should wrap onto a second row: {tiles:?}");
|
||||
let mut row_top = f32::NEG_INFINITY;
|
||||
for r in &tiles {
|
||||
if r.top() > row_top {
|
||||
row_top = r.top();
|
||||
assert!((r.left() - (left.get() + 40.0)).abs() < 0.5, "row starts at {} instead of {}", r.left(), left.get() + 40.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) ->
|
||||
let (sw, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
ui.painter().rect(sw, CornerRadius::same(3), c32(&s.color, sc), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let r = widgets::drop_button(ui, "", vec2(24.0, 20.0), true);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
widgets::color_popup(&r).show(|ui| {
|
||||
if let Some(Some(c)) = widgets::color_grid(ui, sc, None) {
|
||||
let mut ng = g.clone();
|
||||
ng.stops[sel].color = with_alpha(c, alpha_of(&s.color));
|
||||
|
||||
+103
-2
@@ -8,6 +8,7 @@
|
||||
|
||||
pub mod canvas;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
pub mod fillui;
|
||||
pub mod icons;
|
||||
@@ -149,6 +150,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("show.start", "Slide Show", Some("F5"), "{from?: index}"),
|
||||
("show.presenter", "Presenter View", None, "{on?: bool}"),
|
||||
("show.end", "End Show", None, "{}"),
|
||||
("app.home", "Home", None, "{on?}"),
|
||||
("app.about", "About DeckCraft", None, "{}"),
|
||||
("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"),
|
||||
("app.preferences", "Preferences…", Some("Cmd+,"), "{}"),
|
||||
@@ -189,6 +191,9 @@ pub struct SlideApp {
|
||||
pub palette: Option<(String, usize)>,
|
||||
/// Integrated title bar (macOS traffic lights over our chrome).
|
||||
pub integrated_titlebar: bool,
|
||||
/// The start screen is shown over the open presentations (Home), with the document count
|
||||
/// and active index when it was opened: opening or creating a presentation leaves it.
|
||||
pub home: Option<(usize, Option<usize>)>,
|
||||
pending_urls: Vec<String>,
|
||||
shot_token: u64,
|
||||
queued_shots: Vec<(u64, f64, u32)>,
|
||||
@@ -226,6 +231,7 @@ impl SlideApp {
|
||||
slide_rect: None,
|
||||
palette: None,
|
||||
integrated_titlebar: false,
|
||||
home: None,
|
||||
pending_urls: vec![],
|
||||
shot_token: 0,
|
||||
queued_shots: vec![],
|
||||
@@ -265,6 +271,15 @@ impl SlideApp {
|
||||
r
|
||||
}
|
||||
|
||||
fn docs_key(&self) -> (usize, Option<usize>) {
|
||||
(self.session.documents().len(), self.session.active_index())
|
||||
}
|
||||
|
||||
/// Home is open over a presentation (not just the start screen with nothing open).
|
||||
pub fn home_open(&self) -> bool {
|
||||
self.home.is_some() && self.session.active().is_some()
|
||||
}
|
||||
|
||||
fn flag(&mut self, params: &Value, cur: bool) -> bool {
|
||||
params.get("on").and_then(Value::as_bool).unwrap_or(!cur)
|
||||
}
|
||||
@@ -324,6 +339,10 @@ impl SlideApp {
|
||||
self.show = None;
|
||||
self.media.stop_owned(media::Owner::Show);
|
||||
}
|
||||
"app.home" => {
|
||||
let on = self.flag(p, self.home.is_some());
|
||||
self.home = (on && self.session.active().is_some()).then(|| self.docs_key());
|
||||
}
|
||||
"app.about" => self.dialog = Some(dialogs::Dialog::new("about")),
|
||||
"app.palette" => self.palette = Some((String::new(), 0)),
|
||||
"app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")),
|
||||
@@ -592,6 +611,22 @@ impl SlideApp {
|
||||
return;
|
||||
}
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
if self.home_open() {
|
||||
// Only app shortcuts (Open, Command Palette…) and Escape, which goes back to the
|
||||
// presentation: typing, pasting or deleting would edit the hidden presentation.
|
||||
for e in events {
|
||||
if let egui::Event::Key { key, pressed: true, modifiers, .. } = e {
|
||||
let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl };
|
||||
if key == egui::Key::Escape {
|
||||
self.home = None;
|
||||
} else if mods.cmd || mods.alt {
|
||||
menus::ui_shortcut(self, key, mods);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.drain_requests();
|
||||
return;
|
||||
}
|
||||
for e in events {
|
||||
match e {
|
||||
egui::Event::Text(t) => {
|
||||
@@ -660,14 +695,17 @@ impl SlideApp {
|
||||
self.perf.frame_ms = now_ms() - t0;
|
||||
return;
|
||||
}
|
||||
if self.home.is_some_and(|k| k != self.docs_key()) {
|
||||
self.home = None;
|
||||
}
|
||||
self.keyboard(&ctx);
|
||||
ribbon::title_bar(self, ui);
|
||||
if self.session.active().is_some() {
|
||||
if self.session.active().is_some() && !self.home_open() {
|
||||
ribbon::show(self, ui);
|
||||
status::status_bar(self, ui);
|
||||
}
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
|
||||
if self.session.active().is_none() {
|
||||
if self.session.active().is_none() || self.home_open() {
|
||||
dialogs::start_screen(self, ui);
|
||||
return;
|
||||
}
|
||||
@@ -838,3 +876,66 @@ pub fn now_ms() -> f64 {
|
||||
fn js_now() -> f64 {
|
||||
js_sys::Date::now()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn title_text(app: &SlideApp) -> String {
|
||||
let d = app.session.active().unwrap();
|
||||
let sh = &d.doc.slides[0].shapes[0];
|
||||
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// #17: Home created a new blank presentation instead of going to the start screen.
|
||||
#[test]
|
||||
fn home_shows_the_start_screen_over_the_open_presentation() {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
// The app keeps requesting repaints, so step frames instead of run().
|
||||
harness.run_steps(2);
|
||||
let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
|
||||
harness.state_mut().run("app.home", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().home_open());
|
||||
assert_eq!(harness.state().session.documents().len(), 1, "Home doesn't create a presentation");
|
||||
|
||||
// Typing on the start screen doesn't reach the presentation behind it; Escape goes back.
|
||||
harness.event(egui::Event::Text("a".into()));
|
||||
harness.key_press(egui::Key::Backspace);
|
||||
harness.run_steps(2);
|
||||
assert_eq!(title_text(harness.state()), "");
|
||||
assert!(!harness.state().session.active().unwrap().is_dirty());
|
||||
harness.key_press(egui::Key::Escape);
|
||||
harness.run_steps(2);
|
||||
assert!(!harness.state().home_open());
|
||||
|
||||
// Home again toggles; picking a theme on the start screen (a new presentation) leaves it.
|
||||
harness.state_mut().run("app.home", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().home_open());
|
||||
harness.state_mut().run("app.home", json!({})).unwrap();
|
||||
assert!(!harness.state().home_open());
|
||||
harness.state_mut().run("app.home", json!({"on": true})).unwrap();
|
||||
harness.state_mut().run("file.new", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(!harness.state().home_open(), "a new presentation leaves Home");
|
||||
assert_eq!(harness.state().session.documents().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_needs_an_open_presentation() {
|
||||
let mut app = SlideApp::new(Session::new(), Services::default());
|
||||
app.run("app.home", json!({})).unwrap();
|
||||
assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Media playback in the UI host: the [`deckcraft_media::Player`] (audio out + clocks), video
|
||||
//! feeds and their textures, the editor's media control bar, and the engine's `media.*` requests.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_engine::{MediaStatus, Session};
|
||||
@@ -31,6 +31,18 @@ pub struct MediaHost {
|
||||
videos: HashMap<ShapeId, Video>,
|
||||
/// Probed durations by media bytes (for media imported without a probe).
|
||||
durations: HashMap<usize, f64>,
|
||||
/// Animated GIF clocks: when each started and, while paused, the time it holds.
|
||||
gifs: HashMap<ShapeId, (f64, Option<f64>)>,
|
||||
}
|
||||
|
||||
/// An animated GIF picture on a slide: its box, play time, frame and seconds to the next frame.
|
||||
pub struct Gif {
|
||||
pub id: ShapeId,
|
||||
pub rect: deckcraft_geom::Xfrm,
|
||||
pub t: f64,
|
||||
pub frame: usize,
|
||||
pub next: f64,
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
impl Default for MediaHost {
|
||||
@@ -45,7 +57,30 @@ fn key(id: ShapeId) -> u64 {
|
||||
|
||||
impl MediaHost {
|
||||
pub fn new(out: Option<Box<dyn AudioOut>>) -> MediaHost {
|
||||
MediaHost { player: Player::new(out), owners: HashMap::new(), videos: HashMap::new(), durations: HashMap::new() }
|
||||
MediaHost { player: Player::new(out), owners: HashMap::new(), videos: HashMap::new(), durations: HashMap::new(), gifs: HashMap::new() }
|
||||
}
|
||||
|
||||
/// The animated GIF pictures directly on slide `index` at `now`: each plays from when it was
|
||||
/// first shown; a paused one holds its time and resumes from it.
|
||||
pub fn gifs(&mut self, doc: &Presentation, index: usize, now: f64, paused: &HashSet<ShapeId>) -> Vec<Gif> {
|
||||
let Some(slide) = doc.slides.get(index) else { return vec![] };
|
||||
let mut out = vec![];
|
||||
for sh in &slide.shapes {
|
||||
let (ShapeKind::Picture { fill }, Some(rect)) = (&sh.kind, sh.xfrm) else { continue };
|
||||
let Some(item) = doc.media(fill.media).filter(|m| m.data.starts_with(b"GIF8")) else { continue };
|
||||
let p = paused.contains(&sh.id);
|
||||
let c = self.gifs.entry(sh.id).or_insert((now, None));
|
||||
if p && c.1.is_none() {
|
||||
c.1 = Some(now - c.0);
|
||||
} else if !p && let Some(held) = c.1.take() {
|
||||
c.0 = now - held;
|
||||
}
|
||||
let t = c.1.unwrap_or(now - c.0);
|
||||
if let Some((frame, next)) = deckcraft_render::gif_frame(&item.data, &fill.adjust, t) {
|
||||
out.push(Gif { id: sh.id, rect, t, frame, next, paused: p });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace the audio output (the host injects cpal after start-up).
|
||||
@@ -261,6 +296,37 @@ pub fn paint_frame(painter: &egui::Painter, host: &MediaHost, id: ShapeId, rect:
|
||||
true
|
||||
}
|
||||
|
||||
/// Changes whenever a GIF shows another frame (part of the slide texture's cache key).
|
||||
pub fn gif_key(gifs: &[Gif]) -> u64 {
|
||||
gifs.iter().fold(0, |h: u64, g| (h ^ (((g.id.0 as u64) << 32) | g.frame as u64)).wrapping_mul(1099511628211))
|
||||
}
|
||||
|
||||
/// Come back when the next frame of a playing GIF is due.
|
||||
pub fn gif_repaint(ctx: &egui::Context, gifs: &[Gif]) {
|
||||
if let Some(next) = gifs.iter().filter(|g| !g.paused).map(|g| g.next).reduce(f64::min) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis((next * 1000.0).clamp(5.0, 1000.0) as u64));
|
||||
}
|
||||
}
|
||||
|
||||
/// The play/pause button in the bottom-left corner of a GIF's box `r` (PowerPoint puts it there).
|
||||
pub fn gif_button_rect(r: Rect) -> Rect {
|
||||
let s = 24.0f32.min(r.width() - 8.0).min(r.height() - 8.0).max(12.0);
|
||||
Rect::from_min_size(pos2(r.min.x + 6.0, r.max.y - 6.0 - s), vec2(s, s))
|
||||
}
|
||||
|
||||
pub fn paint_gif_button(painter: &egui::Painter, b: Rect, paused: bool, hot: bool) {
|
||||
painter.circle_filled(b.center(), b.width() / 2.0, Color32::from_black_alpha(if hot { 210 } else { 150 }));
|
||||
let r = b.shrink(b.width() * 0.3);
|
||||
let white = Color32::from_gray(235);
|
||||
if paused {
|
||||
icons::paint(painter, r, Icon::Play, white, false);
|
||||
} else {
|
||||
let w = r.width() * 0.3;
|
||||
painter.rect_filled(Rect::from_min_size(r.min, vec2(w, r.height())), CornerRadius::ZERO, white);
|
||||
painter.rect_filled(Rect::from_min_size(pos2(r.max.x - w, r.min.y), vec2(w, r.height())), CornerRadius::ZERO, white);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the control bar asks for.
|
||||
pub enum BarAction {
|
||||
Toggle,
|
||||
|
||||
@@ -210,6 +210,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
|
||||
("Center", "format.alignCenter"),
|
||||
("Align Right", "format.alignRight"),
|
||||
("Justify", "format.justify"),
|
||||
("-", ""),
|
||||
("Right-to-Left Text Direction", "format.rtl"),
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -75,7 +75,7 @@ fn color_button(
|
||||
ui.label(label);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = widgets::drop_button(ui, "Color", vec2(70.0, 22.0), true);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
widgets::color_popup(&r).show(|ui| {
|
||||
if let Some(c) = widgets::color_grid(ui, &sc, none_label) {
|
||||
let (id, p) = on_pick(c);
|
||||
run(app, &id, p);
|
||||
@@ -391,7 +391,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Color");
|
||||
let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
widgets::color_popup(&r).show(|ui| {
|
||||
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
|
||||
run(app, "design.background", json!({"color": cref_param(&c)}));
|
||||
}
|
||||
@@ -417,7 +417,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
widgets::color_popup(&r).show(|ui| {
|
||||
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
|
||||
let (f, b) = if fg { (c, pt.bg.clone()) } else { (pt.fg.clone(), c) };
|
||||
run(app, "design.background", json!({"pattern": {"preset": pt.preset, "fg": cref_param(&f), "bg": cref_param(&b)}}));
|
||||
|
||||
@@ -47,8 +47,8 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
child.spacing_mut().item_spacing.x = 2.0;
|
||||
let has_doc = app.session.active().is_some();
|
||||
let dirty = app.session.active().is_some_and(|d| d.is_dirty());
|
||||
if icon_toggle(&mut child, Icon::Home, "Home", true, false).clicked() {
|
||||
let _ = app.run("file.new", json!({}));
|
||||
if icon_toggle(&mut child, Icon::Home, "Home", true, app.home_open()).clicked() {
|
||||
let _ = app.run("app.home", json!({}));
|
||||
}
|
||||
if icon_toggle(&mut child, Icon::Save, "Save (⌘S)", has_doc, false).clicked() {
|
||||
app.save();
|
||||
@@ -473,7 +473,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
|
||||
if m.clicked() {
|
||||
run(app, "format.highlight", json!({"color": "#FFFF00"}));
|
||||
}
|
||||
egui::Popup::menu(&a).show(|ui| {
|
||||
widgets::color_popup(&a).show(|ui| {
|
||||
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Color")) {
|
||||
run(app, "format.highlight", json!({"color": c.map(|c| serde_json::to_value(cref_param(&c)).unwrap_or_default())}));
|
||||
}
|
||||
@@ -482,7 +482,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
|
||||
if m.clicked() {
|
||||
run(app, "format.color", json!({"color": "#C00000"}));
|
||||
}
|
||||
egui::Popup::menu(&a).show(|ui| {
|
||||
widgets::color_popup(&a).show(|ui| {
|
||||
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
|
||||
run(app, "format.color", json!({"color": cref_param(&c)}));
|
||||
}
|
||||
@@ -743,7 +743,7 @@ fn drawing_group(app: &mut SlideApp, ui: &mut Ui) {
|
||||
run(app, "shape.fill", json!({"color": "accent1"}));
|
||||
}
|
||||
if let Some(a) = a {
|
||||
egui::Popup::menu(&a).show(|ui| {
|
||||
widgets::color_popup(&a).show(|ui| {
|
||||
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) {
|
||||
match c {
|
||||
Some(c) => run(app, "shape.fill", json!({"color": cref_param(&c)})),
|
||||
@@ -757,7 +757,7 @@ fn drawing_group(app: &mut SlideApp, ui: &mut Ui) {
|
||||
run(app, "shape.line", json!({"color": "tx1", "width": 1}));
|
||||
}
|
||||
if let Some(a) = a {
|
||||
egui::Popup::menu(&a).show(|ui| outline_menu(app, ui, &sc));
|
||||
widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc));
|
||||
}
|
||||
let (_, a) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true);
|
||||
if let Some(a) = a {
|
||||
@@ -778,6 +778,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color
|
||||
for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
|
||||
if ui.button(format!("{w} pt")).clicked() {
|
||||
run(app, "shape.line", json!({"width": w}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -793,6 +794,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color
|
||||
] {
|
||||
if ui.button(l).clicked() {
|
||||
run(app, "shape.line", json!({"dash": d}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -807,6 +809,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color
|
||||
] {
|
||||
if ui.button(l).clicked() {
|
||||
run(app, "shape.line", json!({"head": h, "tail": tl}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1624,6 +1627,19 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
});
|
||||
let on = app.session.anim_painter.is_some();
|
||||
let en = enabled(app, "animation.painter");
|
||||
let (r, _) = big_button_ex(ui, Icon::AnimationPainter, "Animation\nPainter", en, false, on);
|
||||
let r = r.on_hover_text("Animation Painter (double-click to keep it on)");
|
||||
if r.double_clicked() {
|
||||
run(app, "animation.painter", json!({"sticky": true}));
|
||||
} else if r.clicked() {
|
||||
if on {
|
||||
app.session.anim_painter = None;
|
||||
} else {
|
||||
run(app, "animation.painter", json!({}));
|
||||
}
|
||||
}
|
||||
});
|
||||
group(ui, |ui| {
|
||||
rows(ui, |ui| {
|
||||
@@ -1881,7 +1897,7 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
|
||||
run(app, "shape.fill", json!({"color": "accent1"}));
|
||||
}
|
||||
if let Some(a) = a {
|
||||
egui::Popup::menu(&a).show(|ui| {
|
||||
widgets::color_popup(&a).show(|ui| {
|
||||
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) {
|
||||
match c {
|
||||
Some(c) => run(app, "shape.fill", json!({"color": cref_param(&c)})),
|
||||
@@ -1890,12 +1906,13 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
if ui.button("Gradient…").clicked() {
|
||||
let _ = app.run("view.pane", json!({"pane": "format", "tab": "fill"}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
let (_, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true);
|
||||
if let Some(a) = a {
|
||||
egui::Popup::menu(&a).show(|ui| outline_menu(app, ui, &sc));
|
||||
widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc));
|
||||
}
|
||||
let (_, a) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true);
|
||||
if let Some(a) = a {
|
||||
@@ -1907,7 +1924,7 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
|
||||
rows(ui, |ui| {
|
||||
let (_, a) = small_button_ex(ui, Icon::TextFill, "Text Fill", sel, false, true);
|
||||
if let Some(a) = a {
|
||||
egui::Popup::menu(&a).show(|ui| {
|
||||
widgets::color_popup(&a).show(|ui| {
|
||||
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
|
||||
run(app, "format.color", json!({"color": cref_param(&c)}));
|
||||
}
|
||||
@@ -1915,7 +1932,7 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
let (_, a) = small_button_ex(ui, Icon::TextOutline, "Text Outline", sel, false, true);
|
||||
if let Some(a) = a {
|
||||
egui::Popup::menu(&a).show(|ui| {
|
||||
widgets::color_popup(&a).show(|ui| {
|
||||
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) {
|
||||
run(app, "format.textOutline", json!({"color": c.map(|c| cref_param(&c))}));
|
||||
}
|
||||
@@ -2183,7 +2200,7 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if m.clicked() {
|
||||
run(app, "table.cellFill", json!({"color": "accent1"}));
|
||||
}
|
||||
egui::Popup::menu(&a).show(|ui| {
|
||||
widgets::color_popup(&a).show(|ui| {
|
||||
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) {
|
||||
match c {
|
||||
Some(c) => run(app, "table.cellFill", json!({"color": cref_param(&c)})),
|
||||
|
||||
+165
-7
@@ -1,7 +1,8 @@
|
||||
//! Slide Show: full-screen playback with transitions and animations (`deckcraft-anim`),
|
||||
//! keyboard/mouse navigation, pen, black/white screens, presenter view and rehearsed timings.
|
||||
|
||||
use deckcraft_anim::{Layer, ShowAction, ShowState, Source, Timeline};
|
||||
use deckcraft_anim::{Layer, LinkJump, ShowAction, ShowState, Source, Timeline};
|
||||
use deckcraft_model::text::Hyperlink;
|
||||
use deckcraft_model::{Presentation, ShapeId};
|
||||
use deckcraft_render::{ParaState, RenderOpts, ShapeState};
|
||||
use egui::{Align2, Color32, CornerRadius, Mesh, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2};
|
||||
@@ -37,6 +38,10 @@ pub struct Show {
|
||||
pub ended: bool,
|
||||
/// The slide whose media was last started (auto-play runs once per visit).
|
||||
media_slide: Option<usize>,
|
||||
/// The slide shown before the current one (the Last Slide Viewed action).
|
||||
last_viewed: Option<usize>,
|
||||
/// Click areas of hyperlinks and shape actions on slide `.0`, in slide points.
|
||||
links: Option<(usize, Vec<deckcraft_render::PlacedLink>)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -47,10 +52,15 @@ struct Key {
|
||||
doc: usize,
|
||||
/// Media hidden while not playing.
|
||||
hidden: Vec<ShapeId>,
|
||||
/// The frames animated GIFs show.
|
||||
gif: u64,
|
||||
}
|
||||
|
||||
struct Transition {
|
||||
old: Option<TextureHandle>,
|
||||
/// The slide left behind (Morph matches its shapes) and the Morph frame texture.
|
||||
from: Option<usize>,
|
||||
morph: Option<TextureHandle>,
|
||||
start: f64,
|
||||
kind: String,
|
||||
option: String,
|
||||
@@ -83,6 +93,8 @@ impl Show {
|
||||
last_move: 0.0,
|
||||
ended: false,
|
||||
media_slide: None,
|
||||
last_viewed: None,
|
||||
links: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,9 +130,15 @@ impl Show {
|
||||
.and_then(|s| s.transition.as_ref())
|
||||
.map(|t| (t.kind.clone(), t.option.clone(), t.duration_ms as f64 / 1000.0))
|
||||
.unwrap_or(("none".into(), String::new(), 0.0));
|
||||
let old = self.tex.as_ref().map(|(_, t)| t.clone());
|
||||
// Take the old slide's texture rather than cloning its handle: the new slide is
|
||||
// otherwise rendered into the same texture, and the transition blends the new
|
||||
// slide with itself (#33).
|
||||
let (from, old) = self.tex.take().map(|(k, t)| (k.slide, t)).unzip();
|
||||
if from != Some(i) {
|
||||
self.last_viewed = from;
|
||||
}
|
||||
self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation {
|
||||
Some(Transition { old, start: now, kind, option, dur })
|
||||
Some(Transition { old, from, morph: None, start: now, kind, option, dur })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -273,9 +291,22 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let media_click = if clicked && !show.pen && !show.ended && show.blank.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None };
|
||||
if let Some((id, clip)) = media_click {
|
||||
let on_slide = !show.pen && !show.ended && show.blank.is_none() && !show.reading_bar_hit(&ctx);
|
||||
let gif_click = if clicked && on_slide { gif_hit(&ctx, ui, &show, &doc, app) } else { None };
|
||||
let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None };
|
||||
let link = if on_slide && gif_click.is_none() && media_click.is_none() { link_hit(&ctx, ui, &mut show, &doc) } else { None };
|
||||
if let Some(id) = gif_click {
|
||||
let _ = app.run("media.gifPlay", json!({"id": id}));
|
||||
} else if let Some((id, clip)) = media_click {
|
||||
app.media.toggle(&doc, id, &clip, crate::media::Owner::Show);
|
||||
} else if clicked && let Some(l) = &link {
|
||||
let last = show.last_viewed;
|
||||
match show.state.follow(&doc, &l.action, last) {
|
||||
LinkJump::Show(a) => actions.push(a),
|
||||
LinkJump::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
||||
LinkJump::Exit => keep = false,
|
||||
LinkJump::None => {}
|
||||
}
|
||||
} else if clicked && !show.pen && !show.reading_bar_hit(&ctx) {
|
||||
if show.ended {
|
||||
keep = false;
|
||||
@@ -288,6 +319,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if secondary && !show.ended {
|
||||
actions.push(show.state.prev(&doc));
|
||||
}
|
||||
// Apply input first: a step started by this click must restart the clock before it is read.
|
||||
for a in actions.drain(..) {
|
||||
if !show.apply(a, &doc, now) {
|
||||
keep = false;
|
||||
}
|
||||
}
|
||||
// Step clock.
|
||||
let (step, playing) = (show.state.step, show.state.playing);
|
||||
let t_step = if playing { now - show.step_start } else { 0.0 };
|
||||
@@ -345,7 +382,11 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
})
|
||||
.map(|m| m.0)
|
||||
.collect();
|
||||
let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone() };
|
||||
let gifs = app.media.gifs(&doc, idx, now, &app.session.gif_paused);
|
||||
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
|
||||
crate::media::gif_repaint(&ctx, &gifs);
|
||||
let gif = crate::media::gif_key(&gifs);
|
||||
let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone(), gif };
|
||||
let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) {
|
||||
show.tex.as_ref().map(|(_, t)| t.clone())
|
||||
} else {
|
||||
@@ -367,6 +408,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
scale: size.0 as f64 / doc.slide_size.width.max(1.0),
|
||||
size: Some(size),
|
||||
state: Some(&f),
|
||||
gif_times: ×,
|
||||
threads,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -383,7 +425,52 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
Some(tex)
|
||||
};
|
||||
let mut drew = false;
|
||||
if let (Some(tr), Some(new)) = (&show.trans, &tex) {
|
||||
let morphing = show.trans.as_ref().is_some_and(|tr| tr.kind == "morph" && tr.from.is_some() && now - tr.start < tr.dur);
|
||||
let tl = if morphing { Some(show.timeline(&doc).clone()) } else { None };
|
||||
let morph = match (&show.trans, tl) {
|
||||
(Some(tr), Some(tl)) => {
|
||||
let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0);
|
||||
let hidden_b = |id: ShapeId| {
|
||||
let st = state_for(&tl, id, 0, 0.0);
|
||||
!st.visible || st.opacity <= 0.0 || hidden.contains(&id)
|
||||
};
|
||||
tr.from
|
||||
.and_then(|i| doc.slides.get(i).map(|a| (i, a)))
|
||||
.zip(doc.slides.get(idx))
|
||||
.map(|((i, a), b)| (i, deckcraft_anim::morph_frame(&doc, a, b, p, &hidden_b)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let (Some((from, m)), Some(tr)) = (morph, show.trans.as_mut()) {
|
||||
let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() };
|
||||
let threads =
|
||||
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) };
|
||||
let opts =
|
||||
RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), state: Some(&f), threads, ..Default::default() };
|
||||
let text: Vec<deckcraft_render::TextMorph> = m
|
||||
.text
|
||||
.iter()
|
||||
.map(|x| deckcraft_render::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix })
|
||||
.collect();
|
||||
let paths: Vec<deckcraft_render::PathMorph> = m
|
||||
.paths
|
||||
.iter()
|
||||
.map(|x| deckcraft_render::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix })
|
||||
.collect();
|
||||
let img = deckcraft_render::render_blend(&doc, from, idx, m.mix, &m.shapes, &text, &paths, &opts);
|
||||
let ci = crate::textures::to_color_image(&img, false);
|
||||
let frame = match tr.morph.take() {
|
||||
Some(mut h) => {
|
||||
h.set(ci, egui::TextureOptions::LINEAR);
|
||||
h
|
||||
}
|
||||
None => ctx.load_texture("morph", ci, egui::TextureOptions::LINEAR),
|
||||
};
|
||||
painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
tr.morph = Some(frame);
|
||||
drew = true;
|
||||
ctx.request_repaint();
|
||||
} else if let (Some(tr), Some(new)) = (&show.trans, &tex) {
|
||||
let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0);
|
||||
if p < 1.0 {
|
||||
for l in deckcraft_anim::transition_layers(&tr.kind, &tr.option, p) {
|
||||
@@ -420,6 +507,16 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
};
|
||||
crate::media::paint_frame(&painter, &app.media, *id, r);
|
||||
}
|
||||
// Play/pause on the animated GIF under the pointer.
|
||||
if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && now - show.last_move < 2.5) {
|
||||
for g in &gifs {
|
||||
let r = on_screen(srect, k, &g.rect);
|
||||
if r.contains(p) {
|
||||
let b = crate::media::gif_button_rect(r);
|
||||
crate::media::paint_gif_button(&painter, b, g.paused, b.contains(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if animating {
|
||||
ctx.request_repaint();
|
||||
@@ -440,6 +537,8 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
s.push(pos2((p.x - srect.min.x) / srect.width(), (p.y - srect.min.y) / srect.height()));
|
||||
}
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
} else if link.is_some() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
} else if now - show.last_move > 2.5 && !show.reading {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::None);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(500));
|
||||
@@ -584,6 +683,39 @@ fn media_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation) -> O
|
||||
})
|
||||
}
|
||||
|
||||
/// The link under the pointer: the topmost shape action or text hyperlink on the current slide
|
||||
/// (a text link wins over its shape's action).
|
||||
fn link_hit(ctx: &egui::Context, ui: &Ui, show: &mut Show, doc: &Presentation) -> Option<Hyperlink> {
|
||||
let p = ctx.input(|i| i.pointer.hover_pos())?;
|
||||
let full = ui.max_rect();
|
||||
let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full };
|
||||
let srect = fit(avail, doc);
|
||||
let k = (srect.width() / doc.slide_size.width.max(1.0) as f32) as f64;
|
||||
let (x, y) = ((p.x - srect.min.x) as f64 / k, (p.y - srect.min.y) as f64 / k);
|
||||
let i = show.state.slide;
|
||||
if show.links.as_ref().is_none_or(|(s, _)| *s != i) {
|
||||
show.links = Some((i, deckcraft_render::place_slide(doc, i).links));
|
||||
}
|
||||
let (_, links) = show.links.as_ref()?;
|
||||
links.iter().rev().find(|l| l.rect.x0 <= x && x <= l.rect.x1 && l.rect.y0 <= y && y <= l.rect.y1).map(|l| l.link.clone())
|
||||
}
|
||||
|
||||
/// A slide-space box on screen, the slide being drawn in `srect` at `k` pixels per point.
|
||||
fn on_screen(srect: Rect, k: f32, x: &deckcraft_geom::Xfrm) -> Rect {
|
||||
Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k))
|
||||
}
|
||||
|
||||
/// The animated GIF whose play/pause button is under the pointer on the shown slide.
|
||||
fn gif_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation, app: &mut SlideApp) -> Option<ShapeId> {
|
||||
let p = ctx.input(|i| i.pointer.interact_pos())?;
|
||||
let full = ui.max_rect();
|
||||
let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full };
|
||||
let srect = fit(avail, doc);
|
||||
let k = srect.width() / doc.slide_size.width.max(1.0) as f32;
|
||||
let gifs = app.media.gifs(doc, show.state.slide, ctx.input(|i| i.time), &app.session.gif_paused);
|
||||
gifs.iter().rev().find(|g| crate::media::gif_button_rect(on_screen(srect, k, &g.rect)).contains(p)).map(|g| g.id)
|
||||
}
|
||||
|
||||
impl Show {
|
||||
fn reading_bar_hit(&self, ctx: &egui::Context) -> bool {
|
||||
// Clicks on the on-screen controls must not also advance.
|
||||
@@ -686,3 +818,29 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_transition_keeps_the_old_slide_in_its_own_texture() {
|
||||
// #33: the old slide's handle was cloned, so the new slide was rendered into the same
|
||||
// texture and every transition blended the new slide with itself.
|
||||
let mut s = deckcraft_engine::Session::with_new();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("transition.set", &json!({"kind": "fade", "index": 1})).unwrap();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let ctx = egui::Context::default();
|
||||
let red = ctx.load_texture("show", egui::ColorImage::filled([1, 1], Color32::RED), egui::TextureOptions::LINEAR);
|
||||
let mut show = Show::new(&doc, 0, false, false);
|
||||
show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0 }, red.clone()));
|
||||
assert!(show.apply(ShowAction::Slide(1), &doc, 0.0));
|
||||
let tr = show.trans.as_ref().expect("the fade plays");
|
||||
assert_eq!(tr.old.as_ref().map(TextureHandle::id), Some(red.id()));
|
||||
assert_eq!(tr.from, Some(0));
|
||||
assert!(show.tex.is_none(), "the new slide gets a texture of its own instead of overwriting the old one");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::{Master, Presentation, Slide};
|
||||
use deckcraft_model::{Master, Presentation, ShapeId, Slide};
|
||||
use deckcraft_render::{Image, RenderOpts};
|
||||
use egui::{ColorImage, TextureHandle, TextureOptions};
|
||||
|
||||
@@ -60,7 +60,7 @@ fn threads() -> u16 {
|
||||
}
|
||||
|
||||
impl Textures {
|
||||
/// The canvas texture for slide `index` at `size` pixels.
|
||||
/// The canvas texture for slide `index` at `size` pixels; `extra` changes with the GIF frames.
|
||||
pub fn slide(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
@@ -70,6 +70,7 @@ impl Textures {
|
||||
edit: bool,
|
||||
extra: u64,
|
||||
grayscale: bool,
|
||||
gif_times: &[(ShapeId, f64)],
|
||||
) -> Option<TextureHandle> {
|
||||
let slide = p.slides.get(index)?.clone();
|
||||
let master = master_of(p, &slide);
|
||||
@@ -87,7 +88,8 @@ impl Textures {
|
||||
}
|
||||
let t0 = crate::now_ms();
|
||||
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
|
||||
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, edit, threads: threads(), size: Some(size), ..Default::default() });
|
||||
let opts = RenderOpts { scale, edit, gif_times, threads: threads(), size: Some(size), ..Default::default() };
|
||||
let img = deckcraft_render::render_slide(p, index, &opts);
|
||||
self.last_render_ms = crate::now_ms() - t0;
|
||||
let ci = to_color_image(&img, grayscale);
|
||||
let tex = match self.canvas.take() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user